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");
}
}