Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,18 @@ 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:

[source,bash]
----
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
----

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ List<AiProviderSwitchPopup.ProviderChoice> 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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,12 @@ private void checkAiProvider(List<Line> 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("WATSONX_APIKEY")) {
provider = "watsonx.ai";
} else if (envSet("LLM_API_KEY")) {
provider = "Custom (LLM_API_KEY)";
}
Expand All @@ -308,7 +312,7 @@ private void checkAiProvider(List<Line> 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, OPENAI_API_KEY, or WATSONX_APIKEY",
Style.EMPTY.dim())));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -58,7 +59,7 @@ void buildChoicesAlwaysListsAllProvidersForManualSelection(@TempDir Path tempDir

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());
Expand All @@ -73,7 +74,7 @@ void defaultProviderIsNotDuplicated(@TempDir Path tempDir) {

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");
}
Expand All @@ -87,7 +88,7 @@ void ollamaDefaultIsNotDuplicated(@TempDir Path tempDir) {

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

Expand All @@ -100,7 +101,7 @@ void watsonxDefaultIsNotDuplicated(@TempDir Path tempDir) {

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

Expand All @@ -127,16 +128,26 @@ void applyChoiceLeavesApiTypeUntouchedForAutoOrBlank() {
}

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