From a288d99c775607f4c1f4d49961cff30ab7c66c51 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Mon, 27 Jul 2026 17:30:29 -0700 Subject: [PATCH 1/7] CAMEL-24275: Add native Google Gemini support for Camel JBang TUI Wire ApiType.gemini to generativelanguage.googleapis.com with env key detection, model listing, tool calling, provider selector, and docs/tests. Co-authored-by: Cursor --- .../pages/camel-4x-upgrade-guide-4_22.adoc | 4 + .../modules/ROOT/pages/camel-jbang-ai.adoc | 8 +- .../dsl/jbang/core/commands/LlmClient.java | 370 +++++++++++++++++- .../core/commands/LlmClientGeminiTest.java | 147 +++++++ .../dsl/jbang/core/commands/tui/AiPanel.java | 2 +- .../core/commands/tui/AiProviderSelector.java | 3 + .../jbang/core/commands/tui/DoctorPopup.java | 4 +- .../commands/tui/AiProviderSelectorTest.java | 19 +- 8 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index aa4417a1e2a62..bfb3eeccf6280 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -56,6 +56,10 @@ instead of hardcoded `3.33.1.1` (https://github.com/apache/camel/commit/d1f4713e The Camel CLI and TUI AI prompt (`camel ask`, TUI F8 panel) now auto-detect **Azure OpenAI** when `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_ENDPOINT` are set (optional `AZURE_OPENAI_DEPLOYMENT_NAME` and `AZURE_OPENAI_API_VERSION`). Azure requests use the `api-key` header instead of `Authorization: Bearer`. + +The Camel CLI and TUI AI prompt now support native **Google Gemini** when `GEMINI_API_KEY` is set +(auto-detected before OpenAI). With `--api-type=gemini`, `GOOGLE_API_KEY` is also accepted. Gemini uses +the `generativelanguage.googleapis.com` API with function-calling support. See xref:camel-jbang-ai.adoc[Camel CLI - AI Tools] for the full detection order. The `camel cmd route-diagram` and `camel cmd route-topology` now accept one or more Camel route source files diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc index 8f493f5fd11c5..b68e7245cafeb 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc @@ -117,9 +117,10 @@ All commands auto-detect the LLM provider. The detection order is: 1. `ANTHROPIC_API_KEY` environment variable → Anthropic API (`ask` and `explain` only) 2. `CLOUD_ML_REGION` + `ANTHROPIC_VERTEX_PROJECT_ID` → Vertex AI (`ask` and `explain` only) 3. `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT` → Azure OpenAI (uses the `api-key` header; optional `AZURE_OPENAI_DEPLOYMENT_NAME` and `AZURE_OPENAI_API_VERSION`) -4. `OPENAI_API_KEY` or `LLM_API_KEY` → OpenAI-compatible API -5. Ollama running via `camel infra` → local Ollama -6. Ollama at `localhost:11434` → local Ollama +4. `GEMINI_API_KEY` environment variable → Google Gemini native API (`generativelanguage.googleapis.com`). With `--api-type=gemini`, `GOOGLE_API_KEY` is also accepted. +5. `OPENAI_API_KEY` or `LLM_API_KEY` → OpenAI-compatible API +6. Ollama running via `camel infra` → local Ollama +7. Ollama at `localhost:11434` → local Ollama Override with explicit options: @@ -127,6 +128,7 @@ Override with explicit options: ---- camel ask "check health" --api-type=anthropic --api-key=sk-... camel ask "check health" --api-type=openai --model=gpt-4 +camel ask "check health" --api-type=gemini --model=gemini-2.0-flash camel ask "check health" --api-type=ollama --model=llama3.1 ---- diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 175129681fbbd..fde90b53d0ab9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -40,8 +40,8 @@ import org.apache.camel.util.json.Jsoner; /** - * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, Anthropic (including Vertex AI), and IBM watsonx.ai - * APIs. + * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, Anthropic (including Vertex AI), Google Gemini, and + * IBM watsonx.ai APIs. */ public class LlmClient { @@ -58,6 +58,8 @@ public class LlmClient { private static final String DEFAULT_AZURE_API_VERSION = "2024-10-21"; /** Last-resort Azure deployment segment when URL normalization runs before model detection completes. */ private static final String DEFAULT_AZURE_DEPLOYMENT_FALLBACK = "gpt-4o"; + private static final String DEFAULT_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta"; + private static final String DEFAULT_GEMINI_MODEL = "gemini-2.0-flash"; private static final int CONNECT_TIMEOUT_SECONDS = 10; private static final int HEALTH_CHECK_TIMEOUT_SECONDS = 5; @@ -71,6 +73,7 @@ public enum ApiType { ollama, openai, anthropic, + gemini, watsonx } @@ -246,14 +249,16 @@ public boolean detectEndpoint() { found = switch (apiType) { case anthropic -> tryAnthropicOrVertex(); case openai -> tryAzureOpenAi() || tryOpenAi(); + case gemini -> tryGemini(); case ollama -> tryInfraOllama() || tryDefaultOllama(); case watsonx -> tryWatsonx(); }; } else { - // auto-detect priority: anthropic → vertex → azure openai → openai → watsonx → ollama + // auto-detect priority: anthropic → vertex → azure openai → gemini → openai → watsonx → ollama found = tryAnthropicApiKey() || tryVertexAi() || tryAzureOpenAi() + || tryGemini() || tryOpenAi() || tryWatsonx() || tryInfraOllama() @@ -290,6 +295,11 @@ private void applyDefaultModel() { model = DEFAULT_OPENAI_MODEL; } } + case gemini -> { + if (model == null || model.isBlank() || DEFAULT_OLLAMA_MODEL.equals(model)) { + model = DEFAULT_GEMINI_MODEL; + } + } case ollama -> { if (model == null || model.isBlank()) { model = resolveDefaultOllamaModel(); @@ -318,6 +328,14 @@ String resolveApiKey() { // Vertex AI uses gcloud token, not API key return null; } + if (apiType == ApiType.gemini) { + String key = resolveGeminiApiKeyFromEnv(); + if (key != null) { + apiKey = key; + return key; + } + return null; + } if (apiType == ApiType.watsonx) { String key = System.getenv("WATSONX_APIKEY"); if (key != null && !key.isBlank()) { @@ -354,6 +372,7 @@ String generate(String systemPrompt, String userPrompt) { case ollama -> generateOllama(systemPrompt, userPrompt); case openai, watsonx -> generateOpenAi(systemPrompt, userPrompt); case anthropic -> generateAnthropic(systemPrompt, userPrompt); + case gemini -> generateGemini(systemPrompt, userPrompt); }; } @@ -364,6 +383,7 @@ public ChatResponse chatWithTools(String systemPrompt, List messages, L case ollama -> chatOllamaFormat(systemPrompt, messages, tools); case openai, watsonx -> chatOpenAiFormat(systemPrompt, messages, tools); case anthropic -> chatAnthropicFormat(systemPrompt, messages, tools); + case gemini -> chatGeminiFormat(systemPrompt, messages, tools); }; } @@ -384,6 +404,7 @@ public List listModels() { case ollama -> listOllamaModels(); case openai -> listOpenAiModels(); case anthropic -> isVertexAi() ? List.of() : listAnthropicModels(); + case gemini -> listGeminiModels(); case watsonx -> listWatsonxModels(); }; } @@ -418,6 +439,46 @@ private List listAnthropicModels() { return extractStringList(response, "data", "id"); } + private List listGeminiModels() { + String key = resolveGeminiApiKey(); + if (key == null || key.isBlank()) { + return List.of(); + } + String base = normalizeGeminiBaseUrl(url); + JsonObject response = sendGetRequest(appendGeminiApiKey(base + "/models", key), Map.of()); + return extractGeminiModelIds(response); + } + + static List extractGeminiModelIds(JsonObject response) { + if (response == null || !(response.get("models") instanceof JsonArray models)) { + return List.of(); + } + List ids = new ArrayList<>(); + for (Object obj : models) { + if (!(obj instanceof JsonObject model)) { + continue; + } + JsonArray methods = (JsonArray) model.get("supportedGenerationMethods"); + if (methods != null) { + boolean supportsGenerate = false; + for (Object method : methods) { + if ("generateContent".equals(String.valueOf(method))) { + supportsGenerate = true; + break; + } + } + if (!supportsGenerate) { + continue; + } + } + String name = model.getString("name"); + if (name != null && !name.isBlank()) { + ids.add(normalizeGeminiModelId(name)); + } + } + return ids; + } + private List listWatsonxModels() { Map headers = buildOpenAiAuthHeaders(resolveApiKey()); JsonObject response = sendGetRequest(normalizeWatsonxModelsUrl(url), headers); @@ -518,6 +579,287 @@ private String generateOpenAi(String systemPrompt, String userPrompt) { return extractOpenAiContent(response); } + // ---- Gemini generate ---- + + private String generateGemini(String systemPrompt, String userPrompt) { + JsonObject request = buildGeminiGenerateRequest(systemPrompt, userPrompt, null); + String apiUrl = geminiGenerateContentUrl(resolveGeminiApiKey()); + JsonObject response = sendGeminiRequest(apiUrl, request); + return extractGeminiTextFromResponse(response); + } + + private ChatResponse chatGeminiFormat(String systemPrompt, List messages, List tools) { + JsonObject request = buildGeminiGenerateRequest(systemPrompt, null, tools); + request.put("contents", buildGeminiContents(messages)); + String apiUrl = geminiGenerateContentUrl(resolveGeminiApiKey()); + JsonObject response = sendGeminiRequest(apiUrl, request); + return parseGeminiChatResponse(response); + } + + private JsonObject buildGeminiGenerateRequest(String systemPrompt, String userPrompt, List tools) { + JsonObject request = new JsonObject(); + if (systemPrompt != null && !systemPrompt.isBlank()) { + JsonObject systemInstruction = new JsonObject(); + JsonArray parts = new JsonArray(); + JsonObject text = new JsonObject(); + text.put("text", systemPrompt); + parts.add(text); + systemInstruction.put("parts", parts); + request.put("systemInstruction", systemInstruction); + } + if (userPrompt != null) { + JsonArray contents = new JsonArray(); + JsonObject user = new JsonObject(); + user.put("role", "user"); + JsonArray parts = new JsonArray(); + JsonObject text = new JsonObject(); + text.put("text", userPrompt); + parts.add(text); + user.put("parts", parts); + contents.add(user); + request.put("contents", contents); + } + JsonObject generationConfig = new JsonObject(); + generationConfig.put("temperature", temperature); + if (maxTokens > 0) { + generationConfig.put("maxOutputTokens", maxTokens); + } + request.put("generationConfig", generationConfig); + JsonArray declarations = buildGeminiFunctionDeclarations(tools); + if (declarations != null) { + JsonObject toolsObj = new JsonObject(); + toolsObj.put("functionDeclarations", declarations); + JsonArray toolsArray = new JsonArray(); + toolsArray.add(toolsObj); + request.put("tools", toolsArray); + } + return request; + } + + JsonObject buildGeminiGenerateRequestForTest(String systemPrompt, List messages, List tools) { + JsonObject request = buildGeminiGenerateRequest(systemPrompt, null, tools); + request.put("contents", buildGeminiContents(messages)); + return request; + } + + private JsonArray buildGeminiContents(List messages) { + JsonArray contents = new JsonArray(); + for (Message msg : messages) { + if (msg.toolCalls() != null && !msg.toolCalls().isEmpty()) { + JsonObject modelMsg = new JsonObject(); + modelMsg.put("role", "model"); + JsonArray parts = new JsonArray(); + if (msg.content() != null && !msg.content().isBlank()) { + JsonObject text = new JsonObject(); + text.put("text", msg.content()); + parts.add(text); + } + for (ToolCall tc : msg.toolCalls()) { + JsonObject functionCall = new JsonObject(); + functionCall.put("name", tc.name()); + functionCall.put("args", tc.arguments()); + JsonObject part = new JsonObject(); + part.put("functionCall", functionCall); + parts.add(part); + } + modelMsg.put("parts", parts); + contents.add(modelMsg); + } else if (msg.toolResults() != null && !msg.toolResults().isEmpty()) { + JsonObject userMsg = new JsonObject(); + userMsg.put("role", "user"); + JsonArray parts = new JsonArray(); + for (ToolResult tr : msg.toolResults()) { + JsonObject responseBody = new JsonObject(); + responseBody.put("content", tr.content()); + JsonObject functionResponse = new JsonObject(); + functionResponse.put("name", toolResultFunctionName(tr.toolCallId())); + functionResponse.put("response", responseBody); + JsonObject part = new JsonObject(); + part.put("functionResponse", functionResponse); + parts.add(part); + } + userMsg.put("parts", parts); + contents.add(userMsg); + } else { + JsonObject turn = new JsonObject(); + turn.put("role", "user".equals(msg.role()) ? "user" : "model"); + JsonArray parts = new JsonArray(); + JsonObject text = new JsonObject(); + text.put("text", msg.content()); + parts.add(text); + turn.put("parts", parts); + contents.add(turn); + } + } + return contents; + } + + private static String toolResultFunctionName(String toolCallId) { + if (toolCallId == null || toolCallId.isBlank()) { + return "tool"; + } + return toolCallId; + } + + private JsonArray buildGeminiFunctionDeclarations(List tools) { + if (tools == null || tools.isEmpty()) { + return null; + } + JsonArray declarations = new JsonArray(); + for (ToolDef tool : tools) { + JsonObject decl = new JsonObject(); + decl.put("name", tool.name()); + decl.put("description", tool.description()); + decl.put("parameters", tool.parameters()); + declarations.add(decl); + } + return declarations; + } + + ChatResponse parseGeminiChatResponse(JsonObject response) { + if (response == null) { + return new ChatResponse(null, List.of(), "error", false, TokenUsage.EMPTY); + } + TokenUsage usage = extractGeminiUsage(response); + JsonArray candidates = (JsonArray) response.get("candidates"); + if (candidates == null || candidates.isEmpty()) { + return new ChatResponse(null, List.of(), "error", false, usage); + } + JsonObject candidate = (JsonObject) candidates.get(0); + String finishReason = candidate.getString("finishReason"); + JsonObject content = (JsonObject) candidate.get("content"); + if (content == null) { + return new ChatResponse(null, List.of(), finishReason, false, usage); + } + JsonArray parts = (JsonArray) content.get("parts"); + if (parts == null) { + return new ChatResponse(null, List.of(), finishReason, false, usage); + } + StringBuilder text = new StringBuilder(); + List toolCalls = new ArrayList<>(); + for (Object obj : parts) { + if (!(obj instanceof JsonObject part)) { + continue; + } + if (part.get("text") != null) { + text.append(part.getString("text")); + } + JsonObject functionCall = (JsonObject) part.get("functionCall"); + if (functionCall != null) { + String name = functionCall.getString("name"); + JsonObject args = functionCall.get("args") instanceof JsonObject jo ? jo : new JsonObject(); + toolCalls.add(new ToolCall(name, name, args)); + } + } + String stopReason = !toolCalls.isEmpty() ? "tool_calls" : finishReason; + String contentText = text.length() > 0 ? text.toString() : null; + return new ChatResponse(contentText, toolCalls, stopReason, false, usage); + } + + String extractGeminiTextFromResponse(JsonObject response) { + if (response == null) { + return null; + } + JsonArray candidates = (JsonArray) response.get("candidates"); + if (candidates == null || candidates.isEmpty()) { + return null; + } + JsonObject candidate = (JsonObject) candidates.get(0); + JsonObject content = (JsonObject) candidate.get("content"); + if (content == null) { + return null; + } + JsonArray parts = (JsonArray) content.get("parts"); + if (parts == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (Object obj : parts) { + if (obj instanceof JsonObject part && part.get("text") != null) { + sb.append(part.getString("text")); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + private TokenUsage extractGeminiUsage(JsonObject response) { + JsonObject usageMetadata = (JsonObject) response.get("usageMetadata"); + if (usageMetadata == null) { + return TokenUsage.EMPTY; + } + int input = getIntValue(usageMetadata, "promptTokenCount"); + int output = getIntValue(usageMetadata, "candidatesTokenCount"); + int total = getIntValue(usageMetadata, "totalTokenCount"); + if (total == 0) { + total = input + output; + } + return new TokenUsage(input, output, total); + } + + private JsonObject sendGeminiRequest(String requestUrl, JsonObject body) { + return sendRequestWithHeaders(requestUrl, body, Map.of("Content-Type", "application/json")); + } + + String geminiGenerateContentUrl(String key) { + String base = normalizeGeminiBaseUrl(url); + String modelId = normalizeGeminiModelId(model); + return appendGeminiApiKey(base + "/models/" + modelId + "%3AgenerateContent", key); + } + + static String normalizeGeminiModelId(String modelId) { + if (modelId == null) { + return DEFAULT_GEMINI_MODEL; + } + if (modelId.startsWith("models/")) { + return modelId.substring("models/".length()); + } + return modelId; + } + + static String normalizeGeminiBaseUrl(String endpoint) { + if (endpoint == null || endpoint.isBlank()) { + return DEFAULT_GEMINI_URL; + } + String u = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + if (u.endsWith(":generateContent")) { + int modelsIdx = u.indexOf("/models/"); + if (modelsIdx > 0) { + return u.substring(0, modelsIdx); + } + } + return u; + } + + static String appendGeminiApiKey(String requestUrl, String key) { + if (key == null || key.isBlank()) { + return requestUrl; + } + return requestUrl + (requestUrl.contains("?") ? "&" : "?") + "key=" + key; + } + + static boolean isGeminiEndpoint(String endpoint) { + return endpoint != null && endpoint.contains("generativelanguage.googleapis.com"); + } + + private String resolveGeminiApiKey() { + if (apiKey != null && !apiKey.isBlank()) { + return apiKey; + } + String key = resolveGeminiApiKeyFromEnv(); + if (key != null) { + apiKey = key; + } + return key; + } + + static String resolveGeminiApiKeyFromEnv() { + return Stream.of("GEMINI_API_KEY", "GOOGLE_API_KEY") + .map(System::getenv) + .filter(k -> k != null && !k.isBlank()) + .findFirst() + .orElse(null); + } + // ---- Anthropic generate ---- private String generateAnthropic(String systemPrompt, String userPrompt) { @@ -1321,6 +1663,7 @@ private String sendAnthropicStreamingRequest(String requestUrl, JsonObject body, // Order matters: /api/tags is checked before /v1/models, matching the original priority. private static final List> EXPLICIT_URL_HEALTH_CHECK_SUFFIXES = List.of( Map.entry("/api/tags", ApiType.ollama), + Map.entry("/v1beta/models", ApiType.gemini), Map.entry("/v1/models", ApiType.openai)); private boolean tryExplicitUrl() { @@ -1432,6 +1775,19 @@ private boolean tryAzureOpenAi() { return true; } + private boolean tryGemini() { + String key = resolveGeminiApiKeyFromEnv(); + if (key == null || key.isBlank()) { + return false; + } + apiType = ApiType.gemini; + apiKey = key; + if (url == null || url.isBlank()) { + url = DEFAULT_GEMINI_URL; + } + return true; + } + private boolean isGenericPlaceholderModel(String configuredModel) { return DEFAULT_OLLAMA_MODEL.equals(configuredModel) || DEFAULT_OPENAI_MODEL.equals(configuredModel); } @@ -1562,6 +1918,14 @@ boolean isEndpointReachable(String endpoint) { String healthUrl = appendAzureApiVersionQuery(base + "/openai/models"); return tryHealthCheck(healthUrl, buildOpenAiAuthHeaders(resolveApiKey()), true); } + if (isGeminiEndpoint(endpoint)) { + String key = resolveGeminiApiKey(); + if (key == null || key.isBlank()) { + return false; + } + String base = normalizeGeminiBaseUrl(endpoint); + return tryHealthCheck(appendGeminiApiKey(base + "/models", key)); + } return tryHealthCheck(endpoint + "/api/tags", Map.of(), false) || tryHealthCheck(endpoint + "/v1/models", Map.of(), false) || tryHealthCheck(endpoint, Map.of(), false); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java new file mode 100644 index 0000000000000..be2b6b4ddafc8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.dsl.jbang.core.commands; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LlmClientGeminiTest { + + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void normalizeGeminiModelIdStripsModelsPrefix() { + assertThat(LlmClient.normalizeGeminiModelId("models/gemini-2.0-flash")).isEqualTo("gemini-2.0-flash"); + assertThat(LlmClient.normalizeGeminiModelId("gemini-2.0-flash")).isEqualTo("gemini-2.0-flash"); + } + + @Test + void extractGeminiModelIdsKeepsGenerateContentModelsOnly() throws Exception { + String json = """ + { + "models": [ + {"name": "models/gemini-2.0-flash", "supportedGenerationMethods": ["generateContent"]}, + {"name": "models/embedding-001", "supportedGenerationMethods": ["embedContent"]} + ] + } + """; + assertThat(LlmClient.extractGeminiModelIds((JsonObject) Jsoner.deserialize(json))) + .containsExactly("gemini-2.0-flash"); + } + + @Test + void geminiGenerateContentUrlAppendsApiKeyQueryParameter() { + LlmClient client = LlmClient.create() + .withApiType(LlmClient.ApiType.gemini) + .withUrl("https://generativelanguage.googleapis.com/v1beta") + .withModel("gemini-2.0-flash") + .withApiKey("gemini-key"); + + assertThat(client.geminiGenerateContentUrl("gemini-key")) + .isEqualTo( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash%3AgenerateContent?key=gemini-key"); + } + + @Test + void listsGeminiModelsWithKeyQueryParameter() throws IOException { + AtomicReference query = new AtomicReference<>(); + String baseUrl = startGeminiModelsServer(query); + LlmClient client = LlmClient.create() + .withApiType(LlmClient.ApiType.gemini) + .withUrl(baseUrl) + .withApiKey("gemini-key"); + + assertThat(client.listModels()).containsExactly("gemini-2.0-flash", "gemini-1.5-flash"); + assertThat(query.get()).contains("key=gemini-key"); + } + + @Test + void parseGeminiChatResponseExtractsFunctionCall() throws Exception { + String json + = """ + {"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_routes","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}} + """; + LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.gemini); + LlmClient.ChatResponse response = client.parseGeminiChatResponse((JsonObject) Jsoner.deserialize(json)); + + assertThat(response.toolCalls()).hasSize(1); + assertThat(response.toolCalls().get(0).name()).isEqualTo("list_routes"); + assertThat(response.stopReason()).isEqualTo("tool_calls"); + } + + @Test + void extractGeminiTextFromResponseReadsCandidateParts() throws Exception { + String json = """ + {"candidates":[{"content":{"parts":[{"text":"Gemini says hi"}]},"finishReason":"STOP"}]} + """; + LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.gemini); + + assertThat(client.extractGeminiTextFromResponse((JsonObject) Jsoner.deserialize(json))).isEqualTo("Gemini says hi"); + } + + @Test + void buildGeminiGenerateRequestIncludesFunctionDeclarations() { + JsonObject parameters = new JsonObject(); + parameters.put("type", "object"); + LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.gemini); + JsonObject request = client.buildGeminiGenerateRequestForTest( + "system", + List.of(LlmClient.Message.user("hello")), + List.of(new LlmClient.ToolDef("list_routes", "List routes", parameters))); + + assertThat(request.get("tools")).isNotNull(); + assertThat(request.toJson()).contains("functionDeclarations").contains("list_routes"); + } + + private String startGeminiModelsServer(AtomicReference capturedQuery) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1beta/models", exchange -> { + capturedQuery.set(exchange.getRequestURI().getQuery()); + byte[] bytes = """ + {"models":[ + {"name":"models/gemini-2.0-flash","supportedGenerationMethods":["generateContent"]}, + {"name":"models/gemini-1.5-flash","supportedGenerationMethods":["generateContent"]} + ]} + """.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + }); + server.start(); + return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1beta"; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index 389efab6df1cc..e54790a40cec6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -291,7 +291,7 @@ private void initClient() { client = created; if (!client.detectEndpoint()) { initError - = "No LLM service reachable. Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama."; + = "No LLM service reachable. Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama."; client = null; return; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java index b12ef6af0f73c..d691a583b3b70 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java @@ -52,6 +52,9 @@ List buildChoices() { if (!"openai".equals(defaultProvider)) { choices.add(new AiProviderSwitchPopup.ProviderChoice("openai", "", "", false)); } + if (!"gemini".equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice("gemini", "", "", false)); + } if (!"ollama".equals(defaultProvider)) { choices.add(new AiProviderSwitchPopup.ProviderChoice("ollama", "", "", false)); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java index b20a07a9e3389..6a23a1186e7f7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java @@ -290,6 +290,8 @@ private void checkAiProvider(List result) { provider = "Vertex AI"; } else if (envSet("AZURE_OPENAI_API_KEY") && envSet("AZURE_OPENAI_ENDPOINT")) { provider = "Azure OpenAI"; + } else if (envSet("GEMINI_API_KEY")) { + provider = "Gemini"; } else if (envSet("OPENAI_API_KEY")) { provider = "OpenAI"; } else if (envSet("LLM_API_KEY")) { @@ -308,7 +310,7 @@ private void checkAiProvider(List result) { Span.raw(String.format("%-30s", "No API key configured")), Span.raw(" " + TuiIcons.WARN))); result.add(Line.from(Span.styled( - " Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, or OPENAI_API_KEY", + " Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, or OPENAI_API_KEY", Style.EMPTY.dim()))); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java index bd9087afdd18e..d69ec7d75c480 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java @@ -58,7 +58,7 @@ void buildChoicesAlwaysListsAllProvidersForManualSelection(@TempDir Path tempDir List choices = selector.buildChoices(); - assertEquals(List.of("auto", "anthropic", "openai", "ollama", "watsonx"), + assertEquals(List.of("auto", "anthropic", "openai", "gemini", "ollama", "watsonx"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(), "all known providers must be offered, even without a detected API key, so they remain selectable"); assertTrue(choices.get(0).persistedDefault()); @@ -73,7 +73,7 @@ void defaultProviderIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("anthropic", "openai", "ollama", "watsonx"), + assertEquals(List.of("anthropic", "openai", "gemini", "ollama", "watsonx"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(), "anthropic must not be listed twice when it's already the default"); } @@ -87,7 +87,7 @@ void ollamaDefaultIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("ollama", "anthropic", "openai", "watsonx"), + assertEquals(List.of("ollama", "anthropic", "openai", "gemini", "watsonx"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList()); } @@ -100,7 +100,7 @@ void watsonxDefaultIsNotDuplicated(@TempDir Path tempDir) { List choices = selector.buildChoices(); - assertEquals(List.of("watsonx", "anthropic", "openai", "ollama"), + assertEquals(List.of("watsonx", "anthropic", "openai", "gemini", "ollama"), choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList()); } @@ -126,6 +126,16 @@ void applyChoiceLeavesApiTypeUntouchedForAutoOrBlank() { assertEquals(before, client.apiType()); } + @Test + void applyChoiceAcceptsGeminiProvider() { + LlmClient client = LlmClient.create(); + + selector.applyChoice(client, "gemini", "gemini-2.0-flash", ""); + + assertEquals(LlmClient.ApiType.gemini, client.apiType()); + assertEquals("gemini-2.0-flash", client.model()); + } + @Test void applyChoiceRejectsUnknownProviderWithActionableMessage() { LlmClient client = LlmClient.create(); @@ -134,6 +144,7 @@ void applyChoiceRejectsUnknownProviderWithActionableMessage() { () -> selector.applyChoice(client, "bogus", null, null)); assertTrue(ex.getMessage().contains("bogus")); + assertTrue(ex.getMessage().contains("gemini")); assertTrue(ex.getMessage().contains("ollama")); assertTrue(ex.getMessage().contains("openai")); assertTrue(ex.getMessage().contains("anthropic")); From 8efba5e1428c12cdb6728fb0e08dba17cc8e24dc Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Mon, 27 Jul 2026 17:37:29 -0700 Subject: [PATCH 2/7] CAMEL-24275: Address Gemini review feedback for LlmClient and tests Honor configured API keys, use x-goog-api-key headers, preserve function call id/thoughtSignature, limit GOOGLE_API_KEY to explicit gemini mode, and fix explicit URL health checks. Co-authored-by: Cursor --- .../dsl/jbang/core/commands/LlmClient.java | 61 +++++++++++++++---- .../core/commands/LlmClientGeminiTest.java | 60 ++++++++++++++---- .../commands/tui/AiProviderSelectorTest.java | 24 ++++---- 3 files changed, 111 insertions(+), 34 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index fde90b53d0ab9..943ac48223505 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -87,7 +87,11 @@ enum OpenAiAuthMode { public record ToolDef(String name, String description, JsonObject parameters) { } - public record ToolCall(String id, String name, JsonObject arguments) { + public record ToolCall(String id, String name, JsonObject arguments, String thoughtSignature) { + + public ToolCall(String id, String name, JsonObject arguments) { + this(id, name, arguments, null); + } } public record ToolResult(String toolCallId, String content) { @@ -249,7 +253,7 @@ public boolean detectEndpoint() { found = switch (apiType) { case anthropic -> tryAnthropicOrVertex(); case openai -> tryAzureOpenAi() || tryOpenAi(); - case gemini -> tryGemini(); + case gemini -> tryGemini(true); case ollama -> tryInfraOllama() || tryDefaultOllama(); case watsonx -> tryWatsonx(); }; @@ -258,7 +262,7 @@ public boolean detectEndpoint() { found = tryAnthropicApiKey() || tryVertexAi() || tryAzureOpenAi() - || tryGemini() + || tryGemini(false) || tryOpenAi() || tryWatsonx() || tryInfraOllama() @@ -445,7 +449,7 @@ private List listGeminiModels() { return List.of(); } String base = normalizeGeminiBaseUrl(url); - JsonObject response = sendGetRequest(appendGeminiApiKey(base + "/models", key), Map.of()); + JsonObject response = sendGetRequest(base + "/models", buildGeminiHeaders(key)); return extractGeminiModelIds(response); } @@ -658,6 +662,12 @@ private JsonArray buildGeminiContents(List messages) { JsonObject functionCall = new JsonObject(); functionCall.put("name", tc.name()); functionCall.put("args", tc.arguments()); + if (tc.id() != null && !tc.id().isBlank() && !tc.id().equals(tc.name())) { + functionCall.put("id", tc.id()); + } + if (tc.thoughtSignature() != null && !tc.thoughtSignature().isBlank()) { + functionCall.put("thoughtSignature", tc.thoughtSignature()); + } JsonObject part = new JsonObject(); part.put("functionCall", functionCall); parts.add(part); @@ -747,8 +757,13 @@ ChatResponse parseGeminiChatResponse(JsonObject response) { JsonObject functionCall = (JsonObject) part.get("functionCall"); if (functionCall != null) { String name = functionCall.getString("name"); + String callId = functionCall.getString("id"); + if (callId == null || callId.isBlank()) { + callId = name; + } JsonObject args = functionCall.get("args") instanceof JsonObject jo ? jo : new JsonObject(); - toolCalls.add(new ToolCall(name, name, args)); + String thoughtSignature = functionCall.getString("thoughtSignature"); + toolCalls.add(new ToolCall(callId, name, args, thoughtSignature)); } } String stopReason = !toolCalls.isEmpty() ? "tool_calls" : finishReason; @@ -797,13 +812,13 @@ private TokenUsage extractGeminiUsage(JsonObject response) { } private JsonObject sendGeminiRequest(String requestUrl, JsonObject body) { - return sendRequestWithHeaders(requestUrl, body, Map.of("Content-Type", "application/json")); + return sendRequestWithHeaders(requestUrl, body, buildGeminiHeaders(resolveGeminiApiKey())); } String geminiGenerateContentUrl(String key) { String base = normalizeGeminiBaseUrl(url); String modelId = normalizeGeminiModelId(model); - return appendGeminiApiKey(base + "/models/" + modelId + "%3AgenerateContent", key); + return base + "/models/" + modelId + ":generateContent"; } static String normalizeGeminiModelId(String modelId) { @@ -821,7 +836,7 @@ static String normalizeGeminiBaseUrl(String endpoint) { return DEFAULT_GEMINI_URL; } String u = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; - if (u.endsWith(":generateContent")) { + if (u.endsWith(":generateContent") || u.endsWith("%3AgenerateContent")) { int modelsIdx = u.indexOf("/models/"); if (modelsIdx > 0) { return u.substring(0, modelsIdx); @@ -830,6 +845,15 @@ static String normalizeGeminiBaseUrl(String endpoint) { return u; } + static Map buildGeminiHeaders(String key) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + if (key != null && !key.isBlank()) { + headers.put("x-goog-api-key", key); + } + return headers; + } + static String appendGeminiApiKey(String requestUrl, String key) { if (key == null || key.isBlank()) { return requestUrl; @@ -860,6 +884,11 @@ static String resolveGeminiApiKeyFromEnv() { .orElse(null); } + static String resolveGeminiApiKeyFromEnvForAutoDetect() { + String key = System.getenv("GEMINI_API_KEY"); + return key != null && !key.isBlank() ? key : null; + } + // ---- Anthropic generate ---- private String generateAnthropic(String systemPrompt, String userPrompt) { @@ -1674,6 +1703,13 @@ private boolean tryExplicitUrl() { return isEndpointReachable(url); } for (Map.Entry check : EXPLICIT_URL_HEALTH_CHECK_SUFFIXES) { + if (check.getValue() == ApiType.gemini && isGeminiEndpoint(url)) { + if (isEndpointReachable(url)) { + apiType = ApiType.gemini; + return true; + } + continue; + } if (tryHealthCheck(url + check.getKey())) { apiType = check.getValue(); return true; @@ -1775,8 +1811,11 @@ private boolean tryAzureOpenAi() { return true; } - private boolean tryGemini() { - String key = resolveGeminiApiKeyFromEnv(); + private boolean tryGemini(boolean includeGoogleApiKeyEnv) { + String key = apiKey; + if (key == null || key.isBlank()) { + key = includeGoogleApiKeyEnv ? resolveGeminiApiKeyFromEnv() : resolveGeminiApiKeyFromEnvForAutoDetect(); + } if (key == null || key.isBlank()) { return false; } @@ -1924,7 +1963,7 @@ boolean isEndpointReachable(String endpoint) { return false; } String base = normalizeGeminiBaseUrl(endpoint); - return tryHealthCheck(appendGeminiApiKey(base + "/models", key)); + return tryHealthCheck(base + "/models", buildGeminiHeaders(key)); } return tryHealthCheck(endpoint + "/api/tags", Map.of(), false) || tryHealthCheck(endpoint + "/v1/models", Map.of(), false) diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java index be2b6b4ddafc8..702000d16e543 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java @@ -48,6 +48,16 @@ void normalizeGeminiModelIdStripsModelsPrefix() { assertThat(LlmClient.normalizeGeminiModelId("gemini-2.0-flash")).isEqualTo("gemini-2.0-flash"); } + @Test + void normalizeGeminiBaseUrlStripsGenerateContentSuffix() { + assertThat(LlmClient.normalizeGeminiBaseUrl( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent")) + .isEqualTo("https://generativelanguage.googleapis.com/v1beta"); + assertThat(LlmClient.normalizeGeminiBaseUrl( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash%3AgenerateContent")) + .isEqualTo("https://generativelanguage.googleapis.com/v1beta"); + } + @Test void extractGeminiModelIdsKeepsGenerateContentModelsOnly() throws Exception { String json = """ @@ -63,7 +73,7 @@ void extractGeminiModelIdsKeepsGenerateContentModelsOnly() throws Exception { } @Test - void geminiGenerateContentUrlAppendsApiKeyQueryParameter() { + void geminiGenerateContentUrlUsesColonActionSegment() { LlmClient client = LlmClient.create() .withApiType(LlmClient.ApiType.gemini) .withUrl("https://generativelanguage.googleapis.com/v1beta") @@ -72,36 +82,54 @@ void geminiGenerateContentUrlAppendsApiKeyQueryParameter() { assertThat(client.geminiGenerateContentUrl("gemini-key")) .isEqualTo( - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash%3AgenerateContent?key=gemini-key"); + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"); } @Test - void listsGeminiModelsWithKeyQueryParameter() throws IOException { - AtomicReference query = new AtomicReference<>(); - String baseUrl = startGeminiModelsServer(query); + void listsGeminiModelsWithApiKeyHeader() throws IOException { + AtomicReference apiKeyHeader = new AtomicReference<>(); + String baseUrl = startGeminiModelsServer(apiKeyHeader); LlmClient client = LlmClient.create() .withApiType(LlmClient.ApiType.gemini) .withUrl(baseUrl) .withApiKey("gemini-key"); assertThat(client.listModels()).containsExactly("gemini-2.0-flash", "gemini-1.5-flash"); - assertThat(query.get()).contains("key=gemini-key"); + assertThat(apiKeyHeader.get()).isEqualTo("gemini-key"); } @Test - void parseGeminiChatResponseExtractsFunctionCall() throws Exception { + void parseGeminiChatResponseExtractsFunctionCallIdArgsAndThoughtSignature() throws Exception { String json = """ - {"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_routes","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}} + {"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-42","name":"list_routes","args":{"q":"x"},"thoughtSignature":"sig-abc"}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}} """; LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.gemini); LlmClient.ChatResponse response = client.parseGeminiChatResponse((JsonObject) Jsoner.deserialize(json)); assertThat(response.toolCalls()).hasSize(1); - assertThat(response.toolCalls().get(0).name()).isEqualTo("list_routes"); + LlmClient.ToolCall call = response.toolCalls().get(0); + assertThat(call.id()).isEqualTo("call-42"); + assertThat(call.name()).isEqualTo("list_routes"); + assertThat(call.arguments().getString("q")).isEqualTo("x"); + assertThat(call.thoughtSignature()).isEqualTo("sig-abc"); assertThat(response.stopReason()).isEqualTo("tool_calls"); } + @Test + void buildGeminiGenerateRequestPreservesThoughtSignatureOnModelTurn() { + JsonObject args = new JsonObject(); + args.put("q", "x"); + LlmClient.ToolCall call = new LlmClient.ToolCall("call-42", "list_routes", args, "sig-abc"); + LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.gemini); + JsonObject request = client.buildGeminiGenerateRequestForTest( + "system", + List.of(LlmClient.Message.assistantWithToolCalls(null, List.of(call))), + null); + + assertThat(request.toJson()).contains("thoughtSignature").contains("sig-abc").contains("call-42"); + } + @Test void extractGeminiTextFromResponseReadsCandidateParts() throws Exception { String json = """ @@ -126,10 +154,20 @@ void buildGeminiGenerateRequestIncludesFunctionDeclarations() { assertThat(request.toJson()).contains("functionDeclarations").contains("list_routes"); } - private String startGeminiModelsServer(AtomicReference capturedQuery) throws IOException { + @Test + void detectEndpointAcceptsConfiguredGeminiApiKeyWithoutEnv() { + LlmClient client = LlmClient.create() + .withApiType(LlmClient.ApiType.gemini) + .withApiKey("configured-key"); + + assertThat(client.detectEndpoint()).isTrue(); + assertThat(client.apiType()).isEqualTo(LlmClient.ApiType.gemini); + } + + private String startGeminiModelsServer(AtomicReference capturedApiKeyHeader) throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/v1beta/models", exchange -> { - capturedQuery.set(exchange.getRequestURI().getQuery()); + capturedApiKeyHeader.set(exchange.getRequestHeaders().getFirst("x-goog-api-key")); byte[] bytes = """ {"models":[ {"name":"models/gemini-2.0-flash","supportedGenerationMethods":["generateContent"]}, diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java index d69ec7d75c480..b8f85286c6e5a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java @@ -26,8 +26,9 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Isolated; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -132,22 +133,21 @@ void applyChoiceAcceptsGeminiProvider() { selector.applyChoice(client, "gemini", "gemini-2.0-flash", ""); - assertEquals(LlmClient.ApiType.gemini, client.apiType()); - assertEquals("gemini-2.0-flash", client.model()); + assertThat(client.apiType()).isEqualTo(LlmClient.ApiType.gemini); + assertThat(client.model()).isEqualTo("gemini-2.0-flash"); } @Test void applyChoiceRejectsUnknownProviderWithActionableMessage() { LlmClient client = LlmClient.create(); - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> selector.applyChoice(client, "bogus", null, null)); - - assertTrue(ex.getMessage().contains("bogus")); - assertTrue(ex.getMessage().contains("gemini")); - assertTrue(ex.getMessage().contains("ollama")); - assertTrue(ex.getMessage().contains("openai")); - assertTrue(ex.getMessage().contains("anthropic")); - assertTrue(ex.getMessage().contains("watsonx")); + assertThatThrownBy(() -> selector.applyChoice(client, "bogus", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bogus") + .hasMessageContaining("gemini") + .hasMessageContaining("ollama") + .hasMessageContaining("openai") + .hasMessageContaining("anthropic") + .hasMessageContaining("watsonx"); } } From 8f8e53c7493928df8141933d87f286705d6ec061 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Mon, 27 Jul 2026 22:40:41 -0700 Subject: [PATCH 3/7] CAMEL-24275: Align TUI hints with Gemini auto-detect and remove dead code Doctor and AiPanel mention GEMINI_API_KEY only for auto-detect; drop unused appendGeminiApiKey and geminiGenerateContentUrl key parameter. Co-authored-by: Cursor --- .../camel/dsl/jbang/core/commands/LlmClient.java | 13 +++---------- .../jbang/core/commands/LlmClientGeminiTest.java | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 943ac48223505..434e3117e3bad 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -587,7 +587,7 @@ private String generateOpenAi(String systemPrompt, String userPrompt) { private String generateGemini(String systemPrompt, String userPrompt) { JsonObject request = buildGeminiGenerateRequest(systemPrompt, userPrompt, null); - String apiUrl = geminiGenerateContentUrl(resolveGeminiApiKey()); + String apiUrl = geminiGenerateContentUrl(); JsonObject response = sendGeminiRequest(apiUrl, request); return extractGeminiTextFromResponse(response); } @@ -595,7 +595,7 @@ private String generateGemini(String systemPrompt, String userPrompt) { private ChatResponse chatGeminiFormat(String systemPrompt, List messages, List tools) { JsonObject request = buildGeminiGenerateRequest(systemPrompt, null, tools); request.put("contents", buildGeminiContents(messages)); - String apiUrl = geminiGenerateContentUrl(resolveGeminiApiKey()); + String apiUrl = geminiGenerateContentUrl(); JsonObject response = sendGeminiRequest(apiUrl, request); return parseGeminiChatResponse(response); } @@ -815,7 +815,7 @@ private JsonObject sendGeminiRequest(String requestUrl, JsonObject body) { return sendRequestWithHeaders(requestUrl, body, buildGeminiHeaders(resolveGeminiApiKey())); } - String geminiGenerateContentUrl(String key) { + String geminiGenerateContentUrl() { String base = normalizeGeminiBaseUrl(url); String modelId = normalizeGeminiModelId(model); return base + "/models/" + modelId + ":generateContent"; @@ -854,13 +854,6 @@ static Map buildGeminiHeaders(String key) { return headers; } - static String appendGeminiApiKey(String requestUrl, String key) { - if (key == null || key.isBlank()) { - return requestUrl; - } - return requestUrl + (requestUrl.contains("?") ? "&" : "?") + "key=" + key; - } - static boolean isGeminiEndpoint(String endpoint) { return endpoint != null && endpoint.contains("generativelanguage.googleapis.com"); } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java index 702000d16e543..c1515d02b7ae0 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java @@ -80,7 +80,7 @@ void geminiGenerateContentUrlUsesColonActionSegment() { .withModel("gemini-2.0-flash") .withApiKey("gemini-key"); - assertThat(client.geminiGenerateContentUrl("gemini-key")) + assertThat(client.geminiGenerateContentUrl()) .isEqualTo( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"); } From 02c159edb38bf05d75e96624482d4a0e68991e4f Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Mon, 27 Jul 2026 23:01:45 -0700 Subject: [PATCH 4/7] CAMEL-24275: Authenticate Gemini explicit URL detection for proxies Probe /models with x-goog-api-key for non-google hosts instead of an unauthenticated /v1beta/models suffix; add AssertJ coverage. Co-authored-by: Cursor --- .../dsl/jbang/core/commands/LlmClient.java | 20 +++++++++++++++++-- .../core/commands/LlmClientGeminiTest.java | 13 ++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 434e3117e3bad..594bbce442e64 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -1696,8 +1696,8 @@ private boolean tryExplicitUrl() { return isEndpointReachable(url); } for (Map.Entry check : EXPLICIT_URL_HEALTH_CHECK_SUFFIXES) { - if (check.getValue() == ApiType.gemini && isGeminiEndpoint(url)) { - if (isEndpointReachable(url)) { + if (check.getValue() == ApiType.gemini) { + if (tryDetectGeminiAtExplicitUrl(url)) { apiType = ApiType.gemini; return true; } @@ -1715,6 +1715,22 @@ private boolean tryExplicitUrl() { return false; } + private boolean tryDetectGeminiAtExplicitUrl(String endpoint) { + if (isGeminiEndpoint(endpoint)) { + return isEndpointReachable(endpoint); + } + String key = apiKey; + if (key == null || key.isBlank()) { + key = resolveGeminiApiKeyFromEnv(); + } + if (key == null || key.isBlank()) { + return false; + } + apiKey = key; + String base = normalizeGeminiBaseUrl(endpoint); + return tryHealthCheck(base + "/models", buildGeminiHeaders(key)); + } + private boolean isOllamaRoot(String endpoint) { try { HttpRequest request = HttpRequest.newBuilder() diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java index c1515d02b7ae0..277e365ec3153 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java @@ -154,6 +154,19 @@ void buildGeminiGenerateRequestIncludesFunctionDeclarations() { assertThat(request.toJson()).contains("functionDeclarations").contains("list_routes"); } + @Test + void detectEndpointRecognizesGeminiCompatibleProxyWithApiKeyHeader() throws IOException { + AtomicReference apiKeyHeader = new AtomicReference<>(); + String baseUrl = startGeminiModelsServer(apiKeyHeader); + LlmClient client = LlmClient.create() + .withUrl(baseUrl) + .withApiKey("proxy-key"); + + assertThat(client.detectEndpoint()).isTrue(); + assertThat(client.apiType()).isEqualTo(LlmClient.ApiType.gemini); + assertThat(apiKeyHeader.get()).isEqualTo("proxy-key"); + } + @Test void detectEndpointAcceptsConfiguredGeminiApiKeyWithoutEnv() { LlmClient client = LlmClient.create() From 77702fae2daa7c6407ce7beef9b27e54195ff1e8 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Thu, 30 Jul 2026 08:37:43 -0700 Subject: [PATCH 5/7] CAMEL-24275: Restore Gemini health-check overload after Azure rebase Add tryHealthCheck(url, headers) delegating to the Azure-aware 3-arg overload so Gemini explicit URL detection compiles alongside Azure OpenAI. Co-authored-by: Cursor --- .../org/apache/camel/dsl/jbang/core/commands/LlmClient.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 594bbce442e64..9eaaaa9aef547 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -1983,6 +1983,10 @@ private boolean tryHealthCheck(String healthUrl) { return tryHealthCheck(healthUrl, Map.of(), false); } + private boolean tryHealthCheck(String healthUrl, Map headers) { + return tryHealthCheck(healthUrl, headers, false); + } + private boolean tryHealthCheck(String healthUrl, Map headers, boolean azureEndpoint) { try { HttpRequest.Builder builder = HttpRequest.newBuilder() From 84e886d2a68deddc369b913fdf21031bd4be9876 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Sun, 2 Aug 2026 05:11:45 -0700 Subject: [PATCH 6/7] CAMEL-24275: Align Doctor with watsonx auto-detect and review nits DoctorPopup now detects WATSONX_APIKEY in the same order as LlmClient auto-detect, and the hint lists WATSONX_APIKEY. Use isEmpty() in Gemini response parsing per review feedback. Co-authored-by: Cursor --- .../org/apache/camel/dsl/jbang/core/commands/LlmClient.java | 4 ++-- .../apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 9eaaaa9aef547..c80ea9b81bee4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -767,7 +767,7 @@ ChatResponse parseGeminiChatResponse(JsonObject response) { } } String stopReason = !toolCalls.isEmpty() ? "tool_calls" : finishReason; - String contentText = text.length() > 0 ? text.toString() : null; + String contentText = !text.isEmpty() ? text.toString() : null; return new ChatResponse(contentText, toolCalls, stopReason, false, usage); } @@ -794,7 +794,7 @@ String extractGeminiTextFromResponse(JsonObject response) { sb.append(part.getString("text")); } } - return sb.length() > 0 ? sb.toString() : null; + return !sb.isEmpty() ? sb.toString() : null; } private TokenUsage extractGeminiUsage(JsonObject response) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java index 6a23a1186e7f7..9623f115eac6b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java @@ -294,6 +294,8 @@ private void checkAiProvider(List result) { provider = "Gemini"; } else if (envSet("OPENAI_API_KEY")) { provider = "OpenAI"; + } else if (envSet("WATSONX_APIKEY")) { + provider = "watsonx.ai"; } else if (envSet("LLM_API_KEY")) { provider = "Custom (LLM_API_KEY)"; } @@ -310,7 +312,7 @@ private void checkAiProvider(List result) { Span.raw(String.format("%-30s", "No API key configured")), Span.raw(" " + TuiIcons.WARN))); result.add(Line.from(Span.styled( - " Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, or OPENAI_API_KEY", + " Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, OPENAI_API_KEY, or WATSONX_APIKEY", Style.EMPTY.dim()))); } } From 0cac4e4227247fc2ed5e6c01c3e72d64f9faa543 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Sun, 2 Aug 2026 12:48:36 -0700 Subject: [PATCH 7/7] CAMEL-24275: Fix LlmClient javadoc formatting for sourcecheck Align class javadoc line wrapping with the formatter output expected by CI regen/sourcecheck. Co-authored-by: Cursor --- .../org/apache/camel/dsl/jbang/core/commands/LlmClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index c80ea9b81bee4..18d517badab59 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -40,8 +40,8 @@ import org.apache.camel.util.json.Jsoner; /** - * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, Anthropic (including Vertex AI), Google Gemini, and - * IBM watsonx.ai APIs. + * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, Anthropic (including Vertex AI), Google Gemini, and IBM + * watsonx.ai APIs. */ public class LlmClient {