This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new e2c278648255 CAMEL-24275: Add native Google Gemini API support for 
Camel JBang TUI
e2c278648255 is described below

commit e2c278648255c5438b84ff37e53fc120b3eaf0d4
Author: Omar Atie <[email protected]>
AuthorDate: Sun Aug 2 22:04:31 2026 -0700

    CAMEL-24275: Add native Google Gemini API support for Camel JBang TUI
    
    Adds ApiType.gemini to LlmClient for the Google Generative Language API
    with env auto-detection (GEMINI_API_KEY; GOOGLE_API_KEY when
    --api-type=gemini is explicit), model listing, chat/generate with
    function calling (including id and thoughtSignature round-trip),
    Gemini-aware URL health checks, and provider selector integration.
    Uses x-goog-api-key header for authentication. Includes documentation
    in the JBang AI manual and 4.22 upgrade guide.
    
    Closes #25172
    
    Co-authored-by: Cursor <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |   4 +
 .../modules/ROOT/pages/camel-jbang-ai.adoc         |   8 +-
 .../camel/dsl/jbang/core/commands/LlmClient.java   | 424 ++++++++++++++++++++-
 .../jbang/core/commands/LlmClientGeminiTest.java   | 198 ++++++++++
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |   2 +-
 .../core/commands/tui/AiProviderSelector.java      |   3 +
 .../dsl/jbang/core/commands/tui/DoctorPopup.java   |   6 +-
 .../core/commands/tui/AiProviderSelectorTest.java  |  37 +-
 8 files changed, 660 insertions(+), 22 deletions(-)

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 32db49b7f90c..b7f9cb5457e8 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 8f493f5fd11c..b68e7245cafe 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 175129681fbb..18d517badab5 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.JsonObject;
 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 class LlmClient {
         ollama,
         openai,
         anthropic,
+        gemini,
         watsonx
     }
 
@@ -84,7 +87,11 @@ public class LlmClient {
     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) {
@@ -246,14 +253,16 @@ public class LlmClient {
             found = switch (apiType) {
                 case anthropic -> tryAnthropicOrVertex();
                 case openai -> tryAzureOpenAi() || tryOpenAi();
+                case gemini -> tryGemini(true);
                 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(false)
                     || tryOpenAi()
                     || tryWatsonx()
                     || tryInfraOllama()
@@ -290,6 +299,11 @@ public class LlmClient {
                     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 +332,14 @@ public class LlmClient {
             // 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 +376,7 @@ public class LlmClient {
             case ollama -> generateOllama(systemPrompt, userPrompt);
             case openai, watsonx -> generateOpenAi(systemPrompt, userPrompt);
             case anthropic -> generateAnthropic(systemPrompt, userPrompt);
+            case gemini -> generateGemini(systemPrompt, userPrompt);
         };
     }
 
@@ -364,6 +387,7 @@ public class LlmClient {
             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 +408,7 @@ public class LlmClient {
             case ollama -> listOllamaModels();
             case openai -> listOpenAiModels();
             case anthropic -> isVertexAi() ? List.of() : listAnthropicModels();
+            case gemini -> listGeminiModels();
             case watsonx -> listWatsonxModels();
         };
     }
@@ -418,6 +443,46 @@ public class LlmClient {
         return extractStringList(response, "data", "id");
     }
 
+    private List<String> listGeminiModels() {
+        String key = resolveGeminiApiKey();
+        if (key == null || key.isBlank()) {
+            return List.of();
+        }
+        String base = normalizeGeminiBaseUrl(url);
+        JsonObject response = sendGetRequest(base + "/models", 
buildGeminiHeaders(key));
+        return extractGeminiModelIds(response);
+    }
+
+    static List<String> extractGeminiModelIds(JsonObject response) {
+        if (response == null || !(response.get("models") instanceof JsonArray 
models)) {
+            return List.of();
+        }
+        List<String> 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<String> listWatsonxModels() {
         Map<String, String> headers = buildOpenAiAuthHeaders(resolveApiKey());
         JsonObject response = sendGetRequest(normalizeWatsonxModelsUrl(url), 
headers);
@@ -518,6 +583,305 @@ public class LlmClient {
         return extractOpenAiContent(response);
     }
 
+    // ---- Gemini generate ----
+
+    private String generateGemini(String systemPrompt, String userPrompt) {
+        JsonObject request = buildGeminiGenerateRequest(systemPrompt, 
userPrompt, null);
+        String apiUrl = geminiGenerateContentUrl();
+        JsonObject response = sendGeminiRequest(apiUrl, request);
+        return extractGeminiTextFromResponse(response);
+    }
+
+    private ChatResponse chatGeminiFormat(String systemPrompt, List<Message> 
messages, List<ToolDef> tools) {
+        JsonObject request = buildGeminiGenerateRequest(systemPrompt, null, 
tools);
+        request.put("contents", buildGeminiContents(messages));
+        String apiUrl = geminiGenerateContentUrl();
+        JsonObject response = sendGeminiRequest(apiUrl, request);
+        return parseGeminiChatResponse(response);
+    }
+
+    private JsonObject buildGeminiGenerateRequest(String systemPrompt, String 
userPrompt, List<ToolDef> 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<Message> messages, List<ToolDef> tools) {
+        JsonObject request = buildGeminiGenerateRequest(systemPrompt, null, 
tools);
+        request.put("contents", buildGeminiContents(messages));
+        return request;
+    }
+
+    private JsonArray buildGeminiContents(List<Message> 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());
+                    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);
+                }
+                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<ToolDef> 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<ToolCall> 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");
+                String callId = functionCall.getString("id");
+                if (callId == null || callId.isBlank()) {
+                    callId = name;
+                }
+                JsonObject args = functionCall.get("args") instanceof 
JsonObject jo ? jo : new JsonObject();
+                String thoughtSignature = 
functionCall.getString("thoughtSignature");
+                toolCalls.add(new ToolCall(callId, name, args, 
thoughtSignature));
+            }
+        }
+        String stopReason = !toolCalls.isEmpty() ? "tool_calls" : finishReason;
+        String contentText = !text.isEmpty() ? 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.isEmpty() ? 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, 
buildGeminiHeaders(resolveGeminiApiKey()));
+    }
+
+    String geminiGenerateContentUrl() {
+        String base = normalizeGeminiBaseUrl(url);
+        String modelId = normalizeGeminiModelId(model);
+        return base + "/models/" + modelId + ":generateContent";
+    }
+
+    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") || 
u.endsWith("%3AgenerateContent")) {
+            int modelsIdx = u.indexOf("/models/");
+            if (modelsIdx > 0) {
+                return u.substring(0, modelsIdx);
+            }
+        }
+        return u;
+    }
+
+    static Map<String, String> buildGeminiHeaders(String key) {
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Content-Type", "application/json");
+        if (key != null && !key.isBlank()) {
+            headers.put("x-goog-api-key", key);
+        }
+        return headers;
+    }
+
+    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);
+    }
+
+    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) {
@@ -1321,6 +1685,7 @@ public class LlmClient {
     // Order matters: /api/tags is checked before /v1/models, matching the 
original priority.
     private static final List<Map.Entry<String, ApiType>> 
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() {
@@ -1331,6 +1696,13 @@ public class LlmClient {
             return isEndpointReachable(url);
         }
         for (Map.Entry<String, ApiType> check : 
EXPLICIT_URL_HEALTH_CHECK_SUFFIXES) {
+            if (check.getValue() == ApiType.gemini) {
+                if (tryDetectGeminiAtExplicitUrl(url)) {
+                    apiType = ApiType.gemini;
+                    return true;
+                }
+                continue;
+            }
             if (tryHealthCheck(url + check.getKey())) {
                 apiType = check.getValue();
                 return true;
@@ -1343,6 +1715,22 @@ public class LlmClient {
         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()
@@ -1432,6 +1820,22 @@ public class LlmClient {
         return true;
     }
 
+    private boolean tryGemini(boolean includeGoogleApiKeyEnv) {
+        String key = apiKey;
+        if (key == null || key.isBlank()) {
+            key = includeGoogleApiKeyEnv ? resolveGeminiApiKeyFromEnv() : 
resolveGeminiApiKeyFromEnvForAutoDetect();
+        }
+        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 +1966,14 @@ public class LlmClient {
             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(base + "/models", buildGeminiHeaders(key));
+        }
         return tryHealthCheck(endpoint + "/api/tags", Map.of(), false)
                 || tryHealthCheck(endpoint + "/v1/models", Map.of(), false)
                 || tryHealthCheck(endpoint, Map.of(), false);
@@ -1571,6 +1983,10 @@ public class LlmClient {
         return tryHealthCheck(healthUrl, Map.of(), false);
     }
 
+    private boolean tryHealthCheck(String healthUrl, Map<String, String> 
headers) {
+        return tryHealthCheck(healthUrl, headers, false);
+    }
+
     private boolean tryHealthCheck(String healthUrl, Map<String, String> 
headers, boolean azureEndpoint) {
         try {
             HttpRequest.Builder builder = 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
new file mode 100644
index 000000000000..277e365ec315
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientGeminiTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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 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 = """
+                {
+                  "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 geminiGenerateContentUrlUsesColonActionSegment() {
+        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())
+                .isEqualTo(
+                        
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";);
+    }
+
+    @Test
+    void listsGeminiModelsWithApiKeyHeader() throws IOException {
+        AtomicReference<String> 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(apiKeyHeader.get()).isEqualTo("gemini-key");
+    }
+
+    @Test
+    void 
parseGeminiChatResponseExtractsFunctionCallIdArgsAndThoughtSignature() throws 
Exception {
+        String json
+                = """
+                        
{"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);
+        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 = """
+                {"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");
+    }
+
+    @Test
+    void detectEndpointRecognizesGeminiCompatibleProxyWithApiKeyHeader() 
throws IOException {
+        AtomicReference<String> 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()
+                .withApiType(LlmClient.ApiType.gemini)
+                .withApiKey("configured-key");
+
+        assertThat(client.detectEndpoint()).isTrue();
+        assertThat(client.apiType()).isEqualTo(LlmClient.ApiType.gemini);
+    }
+
+    private String startGeminiModelsServer(AtomicReference<String> 
capturedApiKeyHeader) throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/v1beta/models", exchange -> {
+            
capturedApiKeyHeader.set(exchange.getRequestHeaders().getFirst("x-goog-api-key"));
+            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 389efab6df1c..e54790a40cec 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 @@ class AiPanel {
             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 b12ef6af0f73..d691a583b3b7 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 @@ final class AiProviderSelector {
         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 b20a07a9e338..9623f115eac6 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,8 +290,12 @@ class DoctorPopup {
             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("WATSONX_APIKEY")) {
+            provider = "watsonx.ai";
         } else if (envSet("LLM_API_KEY")) {
             provider = "Custom (LLM_API_KEY)";
         }
@@ -308,7 +312,7 @@ class DoctorPopup {
                     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, OPENAI_API_KEY, or WATSONX_APIKEY",
                     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 bd9087afdd18..b8f85286c6e5 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.Test;
 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;
 
 /**
@@ -58,7 +59,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> 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 +74,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> 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 +88,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> 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 +101,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> 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());
     }
 
@@ -127,16 +128,26 @@ class AiProviderSelectorTest {
     }
 
     @Test
-    void applyChoiceRejectsUnknownProviderWithActionableMessage() {
+    void applyChoiceAcceptsGeminiProvider() {
         LlmClient client = LlmClient.create();
 
-        IllegalArgumentException ex = 
assertThrows(IllegalArgumentException.class,
-                () -> selector.applyChoice(client, "bogus", null, null));
+        selector.applyChoice(client, "gemini", "gemini-2.0-flash", "");
+
+        assertThat(client.apiType()).isEqualTo(LlmClient.ApiType.gemini);
+        assertThat(client.model()).isEqualTo("gemini-2.0-flash");
+    }
+
+    @Test
+    void applyChoiceRejectsUnknownProviderWithActionableMessage() {
+        LlmClient client = LlmClient.create();
 
-        assertTrue(ex.getMessage().contains("bogus"));
-        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");
     }
 }

Reply via email to