diff --git a/README.md b/README.md index 9613078dd..f459b44f3 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,41 @@ Same as the beloved Python Development UI. A built-in development UI to help you test, evaluate, debug, and showcase your agent(s). +### Custom OpenAI-Compatible Endpoints + +ADK-Java supports any OpenAI-compatible LLM endpoint (Groq, Ollama, Azure OpenAI, Perplexity, etc.) via `OpenAiCompatibleLlm`: + +```java +import com.google.adk.models.OpenAiCompatibleLlm; +import com.google.common.collect.ImmutableMap; + +// Register custom endpoint +OpenAiCompatibleLlm.builder() + .baseUrl("https://api.groq.com/openai/v1/") + .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey)) + .modelName("groq-llama3-70b") + .build() + .registerWithPattern("groq-.*"); + +// Use in agent +LlmAgent agent = LlmAgent.builder() + .model("groq-llama3-70b") + .instruction("You are a helpful assistant.") + .build(); +``` + +**Supported Providers:** Groq, Ollama, Azure OpenAI, Perplexity, or any service implementing OpenAI's Chat Completions API. + +**Local Development with Ollama:** +```java +OpenAiCompatibleLlm.builder() + .baseUrl("http://localhost:11434/v1/") + .headers(ImmutableMap.of()) + .modelName("ollama-llama2") + .build() + .registerWithPattern("ollama-.*"); +``` + ### Evaluate Agents Coming soon... diff --git a/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java new file mode 100644 index 000000000..09637152c --- /dev/null +++ b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java @@ -0,0 +1,225 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.models; + +import com.google.adk.models.chat.ChatCompletionsHttpClient; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.HttpOptions; +import io.reactivex.rxjava3.core.Flowable; +import java.util.Map; +import java.util.Objects; + +/** + * Represents any OpenAI-compatible LLM endpoint. + * + *

This class enables ADK agents to connect to any LLM provider that implements the OpenAI Chat + * Completions API format, including: + * + *

+ * + *

Example usage with Groq: + * + *

{@code
+ * OpenAiCompatibleLlm groq = OpenAiCompatibleLlm.builder()
+ *     .baseUrl("https://api.groq.com/openai/v1/")
+ *     .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ *     .modelName("groq-llama3-70b")
+ *     .build();
+ * groq.registerWithPattern("groq-.*");
+ *
+ * LlmAgent agent = LlmAgent.builder()
+ *     .model("groq-llama3-70b")
+ *     .instruction("You are a helpful assistant.")
+ *     .build();
+ * }
+ * + *

Example usage with Ollama (local): + * + *

{@code
+ * OpenAiCompatibleLlm ollama = OpenAiCompatibleLlm.builder()
+ *     .baseUrl("http://localhost:11434/v1/")
+ *     .headers(ImmutableMap.of())
+ *     .modelName("ollama-llama2")
+ *     .build();
+ * ollama.registerWithPattern("ollama-.*");
+ * }
+ * + *

Note: Streaming support depends on {@link ChatCompletionsHttpClient} implementation. + * Currently non-streaming only. Live bidirectional connections are not supported as the OpenAI Chat + * Completions API does not provide this capability. + */ +public class OpenAiCompatibleLlm extends BaseLlm { + + private final ChatCompletionsHttpClient client; + + /** + * Constructs a new OpenAiCompatibleLlm instance. + * + * @param modelName The model name for registry identification + * @param client The HTTP client configured for the endpoint + */ + private OpenAiCompatibleLlm(String modelName, ChatCompletionsHttpClient client) { + super(modelName); + this.client = Objects.requireNonNull(client, "client cannot be null"); + } + + /** + * Creates a new builder for configuring an OpenAI-compatible LLM. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public Flowable generateContent(LlmRequest llmRequest, boolean stream) { + return client.complete(llmRequest, stream); + } + + @Override + public BaseLlmConnection connect(LlmRequest llmRequest) { + throw new UnsupportedOperationException( + "Live bidirectional connections are not supported for OpenAI-compatible HTTP endpoints. " + + "The OpenAI Chat Completions API does not provide live connection capabilities."); + } + + /** + * Convenience method to register this LLM with a pattern in {@link LlmRegistry}. + * + *

This allows agents to reference the model by name pattern. For example: + * + *

{@code
+   * llm.registerWithPattern("groq-.*");
+   * // Now agents can use model names like "groq-llama3-70b", "groq-mixtral", etc.
+   * }
+ * + * @param pattern regex pattern for model name matching (e.g., "groq-.*", "ollama-.*") + */ + public void registerWithPattern(String pattern) { + LlmRegistry.registerLlm(pattern, modelName -> this); + } + + /** Builder for {@link OpenAiCompatibleLlm}. */ + public static class Builder { + private String baseUrl; + private ImmutableMap headers = ImmutableMap.of(); + private int timeoutMillis = 300_000; // 5 minutes default + private String modelName; + + private Builder() {} + + /** + * Sets the base URL of the OpenAI-compatible endpoint. + * + *

The base URL should end with the API version path (e.g., "/v1/"). The client will + * automatically append "/chat/completions" to form the complete endpoint URL. + * + *

Examples: + * + *

+ * + * @param url the base URL (required) + * @return this builder + */ + public Builder baseUrl(String url) { + this.baseUrl = url; + return this; + } + + /** + * Sets custom HTTP headers to include in all requests. + * + *

Typically used for authorization headers. Example: + * + *

{@code
+     * .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+     * }
+ * + * @param headers map of header names to values + * @return this builder + */ + public Builder headers(ImmutableMap headers) { + this.headers = Objects.requireNonNull(headers, "headers cannot be null"); + return this; + } + + /** + * Sets custom HTTP headers from a regular map. + * + * @param headers map of header names to values + * @return this builder + */ + public Builder headers(Map headers) { + return headers(ImmutableMap.copyOf(headers)); + } + + /** + * Sets the request timeout in milliseconds. + * + *

Defaults to 300,000ms (5 minutes) if not specified. Set to 0 for infinite timeout (not + * recommended for production). + * + * @param millis timeout in milliseconds + * @return this builder + */ + public Builder timeoutMillis(int millis) { + this.timeoutMillis = millis; + return this; + } + + /** + * Sets the model name used for registry pattern matching. + * + * @param name the model name (e.g., "groq-llama3-70b") + * @return this builder + */ + public Builder modelName(String name) { + this.modelName = name; + return this; + } + + /** + * Builds the {@link OpenAiCompatibleLlm} instance. + * + * @return a configured OpenAiCompatibleLlm instance + * @throws IllegalArgumentException if baseUrl or modelName is not set, or if baseUrl is invalid + */ + public OpenAiCompatibleLlm build() { + Objects.requireNonNull(baseUrl, "baseUrl must be set"); + Objects.requireNonNull(modelName, "modelName must be set"); + + HttpOptions httpOptions = + HttpOptions.builder().baseUrl(baseUrl).headers(headers).timeout(timeoutMillis).build(); + + ChatCompletionsHttpClient httpClient = new ChatCompletionsHttpClient(httpOptions); + + return new OpenAiCompatibleLlm(modelName, httpClient); + } + } +} diff --git a/core/src/test/java/com/google/adk/models/ManualGroqTest.java b/core/src/test/java/com/google/adk/models/ManualGroqTest.java new file mode 100644 index 000000000..cf1a0db8c --- /dev/null +++ b/core/src/test/java/com/google/adk/models/ManualGroqTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.models; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.Part; + +/** + * Manual test for OpenAiCompatibleLlm with Groq. + * + *

This is NOT an automated test - it's for manual verification with a real API key. + * + *

Usage: + * + *

+ * # Set your Groq API key
+ * export GROQ_API_KEY="your-api-key-here"
+ *
+ * # Run the test
+ * cd /Users/sm0704/Documents/projects/ghc/shaamam/adk-java
+ * ./mvnw -f core/pom.xml exec:java -Dexec.mainClass="com.google.adk.models.ManualGroqTest" \
+ *   -Dexec.classpathScope=test
+ * 
+ * + *

Or pass API key directly: + * + *

+ * ./mvnw -f core/pom.xml exec:java -Dexec.mainClass="com.google.adk.models.ManualGroqTest" \
+ *   -Dexec.classpathScope=test -Dexec.args="your-groq-api-key"
+ * 
+ */ +public class ManualGroqTest { + + public static void main(String[] args) { + // Get API key from args or environment + String apiKey = null; + if (args.length > 0) { + apiKey = args[0]; + } else { + apiKey = System.getenv("GROQ_API_KEY"); + } + + if (apiKey == null || apiKey.isEmpty()) { + System.err.println("ERROR: No API key provided!"); + System.err.println("Usage:"); + System.err.println(" 1. Set GROQ_API_KEY environment variable"); + System.err.println(" 2. Or pass API key as first argument"); + System.exit(1); + } + + System.out.println("=== OpenAiCompatibleLlm Manual Test with Groq ===\n"); + + try { + // Test 1: Direct OpenAiCompatibleLlm usage + System.out.println("[Test 1] Direct OpenAiCompatibleLlm usage"); + System.out.println("-----------------------------------------"); + + OpenAiCompatibleLlm groq = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.groq.com/openai/v1/") + .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey)) + .modelName("llama-3.3-70b-versatile") + .timeoutMillis(30_000) + .build(); + + System.out.println("✓ Created OpenAiCompatibleLlm for Groq"); + System.out.println(" Model: " + groq.model()); + + LlmRequest request = + LlmRequest.builder() + .model(groq.model()) + .contents( + ImmutableList.of( + Content.fromParts(Part.fromText("Say 'Hello from Groq!' and nothing else.")))) + .build(); + + System.out.println("\nSending request to Groq..."); + LlmResponse response = groq.generateContent(request, false).blockingFirst(); + + if (response.content().isPresent() + && response.content().get().parts().isPresent() + && !response.content().get().parts().get().isEmpty()) { + String responseText = response.content().get().parts().get().get(0).text().orElse(""); + System.out.println("✓ Got response: " + responseText); + } else { + System.out.println("✗ No response content"); + System.exit(1); + } + + // Test 2: Registry integration + System.out.println("\n[Test 2] Registry integration"); + System.out.println("-----------------------------"); + + groq.registerWithPattern("groq-.*"); + System.out.println("✓ Registered pattern 'groq-.*'"); + + BaseLlm resolvedLlm = LlmRegistry.getLlm("groq-llama-3.3-70b-versatile"); + if (resolvedLlm != null) { + System.out.println("✓ Successfully resolved model from registry"); + } else { + System.out.println("✗ Failed to resolve model from registry"); + System.exit(1); + } + + // Test 3: Multi-turn conversation + System.out.println("\n[Test 3] Multi-turn conversation"); + System.out.println("---------------------------------"); + + LlmRequest multiTurnRequest = + LlmRequest.builder() + .model(groq.model()) + .contents( + ImmutableList.of( + Content.fromParts(Part.fromText("My name is Shaamam.")), + Content.fromParts(Part.fromText("Nice to meet you, Shaamam!")), + Content.fromParts(Part.fromText("What's my name?")))) + .build(); + + System.out.println("Sending multi-turn conversation..."); + LlmResponse multiTurnResponse = groq.generateContent(multiTurnRequest, false).blockingFirst(); + + if (multiTurnResponse.content().isPresent() + && multiTurnResponse.content().get().parts().isPresent() + && !multiTurnResponse.content().get().parts().get().isEmpty()) { + String multiTurnText = + multiTurnResponse.content().get().parts().get().get(0).text().orElse(""); + System.out.println("✓ Got response: " + multiTurnText); + + if (multiTurnText.toLowerCase().contains("shaamam")) { + System.out.println("✓ Correctly remembered name from conversation!"); + } else { + System.out.println("⚠ Warning: Model didn't remember name from context"); + } + } + + // Success summary + System.out.println("\n" + "=".repeat(50)); + System.out.println("✓ ALL TESTS PASSED"); + System.out.println("=".repeat(50)); + System.out.println("\nOpenAiCompatibleLlm is working correctly with Groq!"); + System.out.println("Safe to commit and create PR."); + + } catch (Exception e) { + System.err.println("\n✗ TEST FAILED"); + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } +} diff --git a/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java new file mode 100644 index 000000000..3a6434f47 --- /dev/null +++ b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.models; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Integration tests for {@link OpenAiCompatibleLlm} against real endpoints. + * + *

These tests require external services to be running. To separate from fast unit tests, run + * these manually or configure your build to skip them in CI. + * + *

Prerequisites: + * + *

    + *
  • Ollama tests: Ollama must be running locally on port 11434 with a model pulled (e.g., + * {@code ollama pull llama2}) + *
+ */ +@RunWith(JUnit4.class) +public final class OpenAiCompatibleLlmIntegrationTest { + + private static final String OLLAMA_BASE_URL = "http://localhost:11434/v1/"; + private static final String OLLAMA_MODEL = "llama2"; + + @Before + public void checkOllamaAvailable() { + assumeTrue( + "Ollama is not running on localhost:11434. " + + "Start Ollama and pull a model (e.g., 'ollama pull llama2') to run this test.", + isOllamaRunning()); + } + + @Test + public void testOllamaSimpleCompletion() { + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl(OLLAMA_BASE_URL) + .headers(ImmutableMap.of()) // Ollama doesn't require auth for local + .modelName(OLLAMA_MODEL) + .timeoutMillis(30_000) // 30 seconds for slower local models + .build(); + + LlmRequest request = + LlmRequest.builder() + .contents( + ImmutableList.of(Content.fromParts(Part.fromText("Say 'hello' and nothing else.")))) + .build(); + + LlmResponse response = ollama.generateContent(request, false).blockingFirst(); + + assertThat(response.content()).isPresent(); + assertThat(response.content().get().parts()).isPresent(); + assertThat(response.content().get().parts().get()).isNotEmpty(); + String responseText = + response.content().get().parts().get().get(0).text().orElse("").toLowerCase(); + assertThat(responseText).contains("hello"); + } + + @Test + public void testOllamaMultiTurnConversation() { + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl(OLLAMA_BASE_URL) + .headers(ImmutableMap.of()) + .modelName(OLLAMA_MODEL) + .timeoutMillis(30_000) + .build(); + + LlmRequest request = + LlmRequest.builder() + .contents( + ImmutableList.of( + Content.fromParts(Part.fromText("What is 2+2?")), + Content.fromParts(Part.fromText("2+2 equals 4.")), + Content.fromParts(Part.fromText("What about 3+3?")))) + .build(); + + LlmResponse response = ollama.generateContent(request, false).blockingFirst(); + + assertThat(response.content()).isPresent(); + assertThat(response.content().get().parts()).isPresent(); + assertThat(response.content().get().parts().get()).isNotEmpty(); + String responseText = response.content().get().parts().get().get(0).text().orElse(""); + assertThat(responseText).containsMatch("[6|six]"); // Could be "6" or "six" + } + + @Test + public void testOllamaWithCustomTimeout() { + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl(OLLAMA_BASE_URL) + .headers(ImmutableMap.of()) + .modelName(OLLAMA_MODEL) + .timeoutMillis(60_000) // 1 minute for slower prompts + .build(); + + LlmRequest request = + LlmRequest.builder() + .contents( + ImmutableList.of( + Content.fromParts(Part.fromText("List 3 colors, comma separated.")))) + .build(); + + LlmResponse response = ollama.generateContent(request, false).blockingFirst(); + + assertThat(response.content()).isPresent(); + assertThat(response.content().get().parts()).isPresent(); + assertThat(response.content().get().parts().get()).isNotEmpty(); + } + + @Test + public void testOllamaRegistryIntegration() { + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl(OLLAMA_BASE_URL) + .headers(ImmutableMap.of()) + .modelName("ollama-test") + .timeoutMillis(30_000) + .build(); + + ollama.registerWithPattern("ollama-test-.*"); + + // Verify registry resolution works + BaseLlm resolved = LlmRegistry.getLlm("ollama-test-model"); + assertThat(resolved).isNotNull(); + assertThat(resolved).isInstanceOf(OpenAiCompatibleLlm.class); + + // Verify the resolved model can make requests + LlmRequest request = + LlmRequest.builder() + .contents(ImmutableList.of(Content.fromParts(Part.fromText("Say 'test'.")))) + .build(); + + LlmResponse response = resolved.generateContent(request, false).blockingFirst(); + assertThat(response.content()).isPresent(); + } + + /** + * Checks if Ollama is accessible at the expected URL. + * + * @return true if Ollama is running and responsive, false otherwise + */ + private static boolean isOllamaRunning() { + try { + URL url = new URL("http://localhost:11434/api/tags"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + int responseCode = connection.getResponseCode(); + return responseCode == 200; + } catch (IOException e) { + return false; + } + } +} diff --git a/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java new file mode 100644 index 000000000..2500e87ee --- /dev/null +++ b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java @@ -0,0 +1,229 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.models; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class OpenAiCompatibleLlmTest { + + @Test + public void builderRequiresBaseUrl() { + NullPointerException exception = + assertThrows( + NullPointerException.class, + () -> + OpenAiCompatibleLlm.builder() + .modelName("test-model") + .headers(ImmutableMap.of("Authorization", "Bearer token")) + .build()); + + assertThat(exception.getMessage()).contains("baseUrl"); + } + + @Test + public void builderAcceptsValidBaseUrl() { + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .build(); + + assertThat(llm.model()).isEqualTo("test-model"); + } + + @Test + public void builderRejectsInvalidBaseUrl() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + OpenAiCompatibleLlm.builder() + .baseUrl("not-a-valid-url") + .modelName("test-model") + .build()); + + assertThat(exception.getMessage()).contains("not a valid HTTP(S) URL"); + } + + @Test + public void builderAcceptsCustomTimeout() { + // Just verify the builder accepts timeout without error + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .timeoutMillis(60_000) + .build(); + + assertThat(llm.model()).isEqualTo("test-model"); + } + + @Test + public void builderAcceptsHeadersAsImmutableMap() { + ImmutableMap headers = + ImmutableMap.of( + "Authorization", "Bearer test-token", + "X-Custom-Header", "custom-value"); + + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .headers(headers) + .build(); + + // Headers are internal, just verify build succeeds + assertThat(llm.model()).isEqualTo("test-model"); + } + + @Test + public void builderAcceptsHeadersAsMutableMap() { + Map headers = + Map.of( + "Authorization", "Bearer test-token", + "X-Custom-Header", "custom-value"); + + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .headers(headers) + .build(); + + // Headers are internal, just verify build succeeds + assertThat(llm.model()).isEqualTo("test-model"); + } + + @Test + public void modelNameIsAccessible() { + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("groq-llama3-70b") + .build(); + + assertThat(llm.model()).isEqualTo("groq-llama3-70b"); + } + + @Test + public void connectThrowsUnsupportedOperationException() { + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .build(); + + LlmRequest request = + LlmRequest.builder() + .contents(ImmutableList.of(Content.fromParts(Part.fromText("test")))) + .build(); + + UnsupportedOperationException exception = + assertThrows(UnsupportedOperationException.class, () -> llm.connect(request)); + + assertThat(exception.getMessage()).contains("Live bidirectional connections are not supported"); + } + + @Test + public void registerWithPatternRegistersInLlmRegistry() { + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .build(); + + llm.registerWithPattern("test-.*"); + + // Verify the model can be resolved from registry + BaseLlm resolvedLlm = LlmRegistry.getLlm("test-anything"); + assertThat(resolvedLlm).isNotNull(); + assertThat(resolvedLlm).isInstanceOf(OpenAiCompatibleLlm.class); + } + + @Test + public void registerWithPatternAllowsMultipleModels() { + OpenAiCompatibleLlm groq = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.groq.com/openai/v1/") + .modelName("groq-base") + .build(); + groq.registerWithPattern("groq-.*"); + + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl("http://localhost:11434/v1/") + .modelName("ollama-base") + .build(); + ollama.registerWithPattern("ollama-.*"); + + // Both should resolve correctly + BaseLlm groqResolved = LlmRegistry.getLlm("groq-llama3"); + BaseLlm ollamaResolved = LlmRegistry.getLlm("ollama-llama2"); + + assertThat(groqResolved).isInstanceOf(OpenAiCompatibleLlm.class); + assertThat(ollamaResolved).isInstanceOf(OpenAiCompatibleLlm.class); + } + + @Test + public void builderExampleFromJavadocGroq() { + // This test verifies the example code in the class Javadoc compiles and runs + OpenAiCompatibleLlm groq = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.groq.com/openai/v1/") + .headers(ImmutableMap.of("Authorization", "Bearer fake-key")) + .modelName("groq-llama3-70b") + .build(); + + assertThat(groq.model()).isEqualTo("groq-llama3-70b"); + } + + @Test + public void builderExampleFromJavadocOllama() { + // This test verifies the example code in the class Javadoc compiles and runs + OpenAiCompatibleLlm ollama = + OpenAiCompatibleLlm.builder() + .baseUrl("http://localhost:11434/v1/") + .headers(ImmutableMap.of()) + .modelName("ollama-llama2") + .build(); + + assertThat(ollama.model()).isEqualTo("ollama-llama2"); + } + + @Test + public void clientIsInitializedAfterBuild() { + OpenAiCompatibleLlm llm = + OpenAiCompatibleLlm.builder() + .baseUrl("https://api.example.com/v1/") + .modelName("test-model") + .build(); + + // Verify the LLM was constructed successfully by checking model name + assertThat(llm.model()).isEqualTo("test-model"); + } +}