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 943819e02632 CAMEL-24276: Add Azure OpenAI support for TUI/CLI AI 
prompt
943819e02632 is described below

commit 943819e02632f825a765a29048afd94f840c92c0
Author: Omar Atie <[email protected]>
AuthorDate: Tue Jul 28 12:40:41 2026 -0700

    CAMEL-24276: Add Azure OpenAI support for TUI/CLI AI prompt
    
    Auto-detect Azure OpenAI when AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT
    are set. Azure requests use the api-key header instead of Authorization: 
Bearer.
    Includes optional AZURE_OPENAI_DEPLOYMENT_NAME and AZURE_OPENAI_API_VERSION
    env vars, TUI Doctor hints, and user manual / upgrade guide updates.
    
    Closes #25171
    
    Co-authored-by: Cursor <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |   5 +
 .../modules/ROOT/pages/camel-jbang-ai.adoc         |   7 +-
 .../camel/dsl/jbang/core/commands/LlmClient.java   | 220 +++++++++++++++++++--
 .../jbang/core/commands/LlmClientAzureTest.java    | 153 ++++++++++++++
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |   3 +-
 .../dsl/jbang/core/commands/tui/DoctorPopup.java   |   5 +-
 6 files changed, 369 insertions(+), 24 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 76a851fecad1..bc48de399f7f 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
@@ -21,6 +21,11 @@ The MCP Server has also been promoted to _Stable_.
 The Camel JBang CLI now automatic resolves quarkus version to use,
 instead of hardcoded `3.33.1.1` 
(https://github.com/apache/camel/commit/d1f4713ebdf787ab04a31c50a6f07e3ca66f0c2b).
 
+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`.
+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
 (instead of only the name/pid of a running integration), so diagrams and 
inter-route topology can be
 generated at design/source time without starting the application first, for 
example:
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 177eb7df62ca..8f493f5fd11c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
@@ -116,9 +116,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. `OPENAI_API_KEY` or `LLM_API_KEY` → OpenAI-compatible API
-4. Ollama running via `camel infra` → local Ollama
-5. Ollama at `localhost:11434` → local Ollama
+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
 
 Override with explicit options:
 
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 f14196231890..7bb8a1f39681 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
@@ -52,6 +52,9 @@ public class LlmClient {
     private static final String DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
     private static final String DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
     private static final String DEFAULT_OLLAMA_MODEL = "llama3.2";
+    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 int CONNECT_TIMEOUT_SECONDS = 10;
     private static final int HEALTH_CHECK_TIMEOUT_SECONDS = 5;
 
@@ -67,6 +70,11 @@ public class LlmClient {
         anthropic
     }
 
+    enum OpenAiAuthMode {
+        bearer,
+        api_key
+    }
+
     // -- Unified abstractions for tool-calling across API formats --
 
     public record ToolDef(String name, String description, JsonObject 
parameters) {
@@ -156,6 +164,10 @@ public class LlmClient {
     private String vertexRegion;
     private String vertexProjectId;
 
+    /** Azure OpenAI uses {@code api-key} instead of {@code Authorization: 
Bearer}. */
+    private OpenAiAuthMode openAiAuthMode = OpenAiAuthMode.bearer;
+    private String azureApiVersion = DEFAULT_AZURE_API_VERSION;
+
     public String model() {
         return model;
     }
@@ -229,18 +241,22 @@ public class LlmClient {
         } else if (apiType != null) {
             found = switch (apiType) {
                 case anthropic -> tryAnthropicOrVertex();
-                case openai -> tryOpenAi();
+                case openai -> tryAzureOpenAi() || tryOpenAi();
                 case ollama -> tryInfraOllama() || tryDefaultOllama();
             };
         } else {
-            // auto-detect priority: anthropic → vertex → openai → ollama
+            // auto-detect priority: anthropic → vertex → azure openai → 
openai → ollama
             found = tryAnthropicApiKey()
                     || tryVertexAi()
+                    || tryAzureOpenAi()
                     || tryOpenAi()
                     || tryInfraOllama()
                     || tryDefaultOllama();
         }
         if (found) {
+            if (apiType == ApiType.openai && url != null && 
isAzureOpenAiEndpoint(url)) {
+                openAiAuthMode = OpenAiAuthMode.api_key;
+            }
             applyDefaultModel();
         }
         return found;
@@ -261,7 +277,10 @@ public class LlmClient {
                 }
             }
             case openai -> {
-                if (model == null || model.isBlank()) {
+                if (openAiAuthMode == OpenAiAuthMode.api_key
+                        || (url != null && isAzureOpenAiEndpoint(url))) {
+                    resolveAzureOpenAiModel();
+                } else if (model == null || model.isBlank()) {
                     model = DEFAULT_OPENAI_MODEL;
                 }
             }
@@ -288,12 +307,22 @@ public class LlmClient {
             // Vertex AI uses gcloud token, not API key
             return null;
         }
+        if (apiType == ApiType.openai && openAiAuthMode == 
OpenAiAuthMode.api_key) {
+            String key = System.getenv("AZURE_OPENAI_API_KEY");
+            if (key != null && !key.isBlank()) {
+                apiKey = key;
+                return key;
+            }
+        }
         return Stream.of("OPENAI_API_KEY", "LLM_API_KEY")
                 .map(System::getenv)
                 .filter(k -> k != null && !k.isBlank())
                 .findFirst()
                 .map(k -> {
                     apiKey = k;
+                    if (openAiAuthMode != OpenAiAuthMode.api_key) {
+                        openAiAuthMode = OpenAiAuthMode.bearer;
+                    }
                     return k;
                 })
                 .orElse(null);
@@ -345,13 +374,22 @@ public class LlmClient {
     }
 
     private List<String> listOpenAiModels() {
+        Map<String, String> headers = buildOpenAiAuthHeaders(resolveApiKey());
+        JsonObject response = sendGetRequest(normalizeOpenAiModelsUrl(url), 
headers);
+        return extractStringList(response, "data", "id");
+    }
+
+    Map<String, String> buildOpenAiAuthHeaders(String key) {
         Map<String, String> headers = new HashMap<>();
-        String key = resolveApiKey();
-        if (key != null) {
+        if (key == null || key.isBlank()) {
+            return headers;
+        }
+        if (openAiAuthMode == OpenAiAuthMode.api_key) {
+            headers.put("api-key", key);
+        } else {
             headers.put("Authorization", "Bearer " + key);
         }
-        JsonObject response = sendGetRequest(normalizeOpenAiModelsUrl(url), 
headers);
-        return extractStringList(response, "data", "id");
+        return headers;
     }
 
     private List<String> listAnthropicModels() {
@@ -380,7 +418,10 @@ public class LlmClient {
     }
 
     String normalizeOpenAiModelsUrl(String endpoint) {
-        String u = endpoint.endsWith("/") ? endpoint.substring(0, 
endpoint.length() - 1) : endpoint;
+        String u = stripTrailingSlash(endpoint);
+        if (isAzureOpenAiEndpoint(u)) {
+            return appendAzureApiVersionQuery(azureResourceBase(u) + 
"/openai/models");
+        }
         // A configured full chat endpoint (.../v1/chat/completions) shares 
the same /v1 base as the models
         // endpoint, so strip the chat suffix rather than appending /v1/models 
onto it.
         if (u.endsWith("/v1/chat/completions")) {
@@ -1080,7 +1121,7 @@ public class LlmClient {
                     .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
 
             if (authKey != null && !authKey.isBlank()) {
-                builder.header("Authorization", "Bearer " + authKey);
+                applyOpenAiAuthorization(builder, authKey);
             }
 
             HttpResponse<String> response = httpClient.send(builder.build(), 
HttpResponse.BodyHandlers.ofString());
@@ -1152,7 +1193,7 @@ public class LlmClient {
                     .POST(HttpRequest.BodyPublishers.ofString(body.toJson()));
 
             if (authKey != null && !authKey.isBlank()) {
-                builder.header("Authorization", "Bearer " + authKey);
+                applyOpenAiAuthorization(builder, authKey);
             }
 
             HttpResponse<Stream<String>> response = httpClient.send(
@@ -1334,6 +1375,7 @@ public class LlmClient {
         if (key != null && !key.isBlank()) {
             apiType = ApiType.openai;
             apiKey = key;
+            openAiAuthMode = OpenAiAuthMode.bearer;
             if (url == null || url.isBlank()) {
                 url = "https://api.openai.com";;
             }
@@ -1342,6 +1384,51 @@ public class LlmClient {
         return false;
     }
 
+    private boolean tryAzureOpenAi() {
+        String key = System.getenv("AZURE_OPENAI_API_KEY");
+        String endpoint = System.getenv("AZURE_OPENAI_ENDPOINT");
+        if (key == null || key.isBlank() || endpoint == null || 
endpoint.isBlank()) {
+            return false;
+        }
+        apiType = ApiType.openai;
+        apiKey = key;
+        openAiAuthMode = OpenAiAuthMode.api_key;
+        url = stripTrailingSlash(endpoint);
+        String version = System.getenv("AZURE_OPENAI_API_VERSION");
+        if (version != null && !version.isBlank()) {
+            azureApiVersion = version;
+        }
+        String deployment = System.getenv("AZURE_OPENAI_DEPLOYMENT_NAME");
+        if (deployment != null && !deployment.isBlank()
+                && (model == null || model.isBlank() || 
isGenericPlaceholderModel(model))) {
+            model = deployment;
+        }
+        return true;
+    }
+
+    private boolean isGenericPlaceholderModel(String configuredModel) {
+        return DEFAULT_OLLAMA_MODEL.equals(configuredModel) || 
DEFAULT_OPENAI_MODEL.equals(configuredModel);
+    }
+
+    /**
+     * Azure chat URLs use deployment names, not OpenAI model ids. Replace CLI 
placeholders with env, then the first
+     * listed deployment when reachable.
+     */
+    private void resolveAzureOpenAiModel() {
+        if (model != null && !model.isBlank() && 
!isGenericPlaceholderModel(model)) {
+            return;
+        }
+        String deployment = System.getenv("AZURE_OPENAI_DEPLOYMENT_NAME");
+        if (deployment != null && !deployment.isBlank()) {
+            model = deployment;
+            return;
+        }
+        List<String> deployments = listModels();
+        if (!deployments.isEmpty()) {
+            model = deployments.get(0);
+        }
+    }
+
     private boolean tryInfraOllama() {
         try {
             Map<Long, Path> pids = findOllamaPids();
@@ -1429,20 +1516,34 @@ public class LlmClient {
     }
 
     boolean isEndpointReachable(String endpoint) {
-        return tryHealthCheck(endpoint + "/api/tags")
-                || tryHealthCheck(endpoint + "/v1/models")
-                || tryHealthCheck(endpoint);
+        if (isAzureOpenAiEndpoint(endpoint)) {
+            String base = azureResourceBase(stripTrailingSlash(endpoint));
+            String healthUrl = appendAzureApiVersionQuery(base + 
"/openai/models");
+            return tryHealthCheck(healthUrl, 
buildOpenAiAuthHeaders(resolveApiKey()), true);
+        }
+        return tryHealthCheck(endpoint + "/api/tags", Map.of(), false)
+                || tryHealthCheck(endpoint + "/v1/models", Map.of(), false)
+                || tryHealthCheck(endpoint, Map.of(), false);
     }
 
     private boolean tryHealthCheck(String healthUrl) {
+        return tryHealthCheck(healthUrl, Map.of(), false);
+    }
+
+    private boolean tryHealthCheck(String healthUrl, Map<String, String> 
headers, boolean azureEndpoint) {
         try {
-            HttpRequest request = HttpRequest.newBuilder()
+            HttpRequest.Builder builder = HttpRequest.newBuilder()
                     .uri(URI.create(healthUrl))
                     .timeout(Duration.ofSeconds(HEALTH_CHECK_TIMEOUT_SECONDS))
-                    .GET()
-                    .build();
-            HttpResponse<String> response = httpClient.send(request, 
HttpResponse.BodyHandlers.ofString());
-            return response.statusCode() == 200;
+                    .GET();
+            headers.forEach(builder::header);
+            HttpResponse<String> response = httpClient.send(builder.build(), 
HttpResponse.BodyHandlers.ofString());
+            int status = response.statusCode();
+            if (status == 200) {
+                return true;
+            }
+            // Azure returns 401 without a valid key; treat as reachable when 
probing explicit URLs with a key configured
+            return azureEndpoint && status == 401 && !headers.isEmpty();
         } catch (Exception e) {
             return false;
         }
@@ -1481,8 +1582,89 @@ public class LlmClient {
 
     // ---- URL helpers ----
 
+    static boolean isAzureOpenAiEndpoint(String endpoint) {
+        if (endpoint == null || endpoint.isBlank()) {
+            return false;
+        }
+        return endpoint.contains(".openai.azure.com") || 
endpoint.contains("/openai/deployments/");
+    }
+
+    static String stripTrailingSlash(String endpoint) {
+        if (endpoint == null || endpoint.isEmpty()) {
+            return endpoint;
+        }
+        return endpoint.endsWith("/") ? endpoint.substring(0, 
endpoint.length() - 1) : endpoint;
+    }
+
+    String azureResourceBase(String endpoint) {
+        String u = stripTrailingSlash(endpoint);
+        int openAiPath = u.indexOf("/openai");
+        if (openAiPath > 0) {
+            return u.substring(0, openAiPath);
+        }
+        return u;
+    }
+
+    String resolveAzureApiVersion() {
+        String fromEnv = System.getenv("AZURE_OPENAI_API_VERSION");
+        if (fromEnv != null && !fromEnv.isBlank()) {
+            return fromEnv;
+        }
+        return azureApiVersion != null ? azureApiVersion : 
DEFAULT_AZURE_API_VERSION;
+    }
+
+    String appendAzureApiVersionQuery(String url) {
+        if (url.contains("api-version=")) {
+            return url;
+        }
+        return url + (url.contains("?") ? "&" : "?") + "api-version=" + 
resolveAzureApiVersion();
+    }
+
+    /**
+     * Deployment name embedded in Azure chat URLs. Prefer the configured 
model (usually set by
+     * {@link #resolveAzureOpenAiModel()}), then {@code 
AZURE_OPENAI_DEPLOYMENT_NAME}, then a generic fallback.
+     */
+    String resolveAzureDeploymentNameForChatUrl() {
+        return resolveAzureDeploymentNameForChatUrl(model, 
System.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"));
+    }
+
+    static String resolveAzureDeploymentNameForChatUrl(String configuredModel, 
String deploymentFromEnv) {
+        if (configuredModel != null && !configuredModel.isBlank()) {
+            return configuredModel;
+        }
+        if (deploymentFromEnv != null && !deploymentFromEnv.isBlank()) {
+            return deploymentFromEnv;
+        }
+        return DEFAULT_AZURE_DEPLOYMENT_FALLBACK;
+    }
+
+    String normalizeAzureOpenAiChatUrl(String endpoint) {
+        String u = stripTrailingSlash(endpoint);
+        if (u.contains("/openai/deployments/") && 
u.contains("/chat/completions")) {
+            return appendAzureApiVersionQuery(u);
+        }
+        String deployment = resolveAzureDeploymentNameForChatUrl();
+        String base = azureResourceBase(u);
+        return appendAzureApiVersionQuery(
+                base + "/openai/deployments/" + deployment + 
"/chat/completions");
+    }
+
+    void applyOpenAiAuthorization(HttpRequest.Builder builder, String authKey) 
{
+        if (authKey == null || authKey.isBlank()) {
+            return;
+        }
+        if (apiType == ApiType.openai && openAiAuthMode == 
OpenAiAuthMode.api_key) {
+            builder.header("api-key", authKey);
+        } else {
+            builder.header("Authorization", "Bearer " + authKey);
+        }
+    }
+
     String normalizeOpenAiUrl(String endpoint) {
-        String u = endpoint.endsWith("/") ? endpoint.substring(0, 
endpoint.length() - 1) : endpoint;
+        String u = stripTrailingSlash(endpoint);
+        if (isAzureOpenAiEndpoint(u)) {
+            return normalizeAzureOpenAiChatUrl(u);
+        }
         if (!u.endsWith("/v1/chat/completions")) {
             u = u.endsWith("/v1") ? u : u + "/v1";
             u = u + "/chat/completions";
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientAzureTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientAzureTest.java
new file mode 100644
index 000000000000..75f2e848ca82
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientAzureTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.concurrent.atomic.AtomicReference;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LlmClientAzureTest {
+
+    private HttpServer server;
+
+    @AfterEach
+    void stopServer() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void isAzureOpenAiEndpointDetectsResourceHostAndDeploymentPaths() {
+        
assertThat(LlmClient.isAzureOpenAiEndpoint("https://myresource.openai.azure.com";)).isTrue();
+        
assertThat(LlmClient.isAzureOpenAiEndpoint("http://127.0.0.1:8080/openai/deployments/gpt-4o";)).isTrue();
+        
assertThat(LlmClient.isAzureOpenAiEndpoint("https://models.inference.ai.azure.com";)).isFalse();
+        
assertThat(LlmClient.isAzureOpenAiEndpoint("https://api.openai.com";)).isFalse();
+    }
+
+    @Test
+    void resolveAzureDeploymentNameForChatUrlPrefersModelThenEnvThenFallback() 
{
+        assertThat(LlmClient.resolveAzureDeploymentNameForChatUrl("my-deploy", 
"from-env")).isEqualTo("my-deploy");
+        assertThat(LlmClient.resolveAzureDeploymentNameForChatUrl(null, 
"from-env")).isEqualTo("from-env");
+        assertThat(LlmClient.resolveAzureDeploymentNameForChatUrl(null, 
null)).isEqualTo("gpt-4o");
+    }
+
+    @Test
+    void normalizeOpenAiUrlBuildsAzureDeploymentChatPathWithApiVersion() {
+        LlmClient client = LlmClient.create()
+                .withApiType(LlmClient.ApiType.openai)
+                .withModel("gpt-4o-deployment");
+
+        String chatUrl = 
client.normalizeOpenAiUrl("https://myresource.openai.azure.com/";);
+
+        assertThat(chatUrl).isEqualTo(
+                
"https://myresource.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21";);
+    }
+
+    @Test
+    void normalizeOpenAiUrlPreservesExistingAzureChatCompletionsUrl() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.openai);
+        String input = 
"https://myresource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-08-01";;
+
+        assertThat(client.normalizeOpenAiUrl(input)).isEqualTo(input);
+    }
+
+    @Test
+    void normalizeOpenAiUrlStillNormalizesStandardOpenAiHost() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.openai);
+
+        assertThat(client.normalizeOpenAiUrl("https://api.openai.com";))
+                .isEqualTo("https://api.openai.com/v1/chat/completions";);
+    }
+
+    @Test
+    void 
detectEndpointReplacesLlamaPlaceholderWithFirstAzureDeploymentFromModelsApi() 
throws IOException {
+        AtomicReference<String> apiKeyHeader = new AtomicReference<>();
+        String baseUrl = startAzureModelsServer(apiKeyHeader);
+        LlmClient client = LlmClient.create()
+                .withApiType(LlmClient.ApiType.openai)
+                .withUrl(baseUrl + "/openai/deployments/ignored")
+                .withApiKey("azure-secret")
+                .withModel("llama3.2");
+
+        assertThat(client.detectEndpoint()).isTrue();
+        assertThat(client.model()).isEqualTo("deployment-a");
+    }
+
+    @Test
+    void normalizeOpenAiModelsUrlBuildsAzureModelsPathWithApiVersion() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.openai);
+
+        
assertThat(client.normalizeOpenAiModelsUrl("https://myresource.openai.azure.com";))
+                
.isEqualTo("https://myresource.openai.azure.com/openai/models?api-version=2024-10-21";);
+    }
+
+    @Test
+    void buildOpenAiAuthHeadersUsesBearerForStandardOpenAi() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.openai).withApiKey("sk-test");
+
+        assertThat(client.buildOpenAiAuthHeaders("sk-test"))
+                .containsEntry("Authorization", "Bearer sk-test")
+                .doesNotContainKey("api-key");
+    }
+
+    @Test
+    void listsAzureOpenAiModelsWithApiKeyHeader() throws IOException {
+        AtomicReference<String> apiKeyHeader = new AtomicReference<>();
+        String baseUrl = startAzureModelsServer(apiKeyHeader);
+        LlmClient client = LlmClient.create()
+                .withApiType(LlmClient.ApiType.openai)
+                .withUrl(baseUrl + "/openai/deployments/list-test")
+                .withApiKey("azure-secret");
+
+        assertThat(client.detectEndpoint()).isTrue();
+        assertThat(client.listModels()).containsExactly("deployment-a", 
"deployment-b");
+        assertThat(apiKeyHeader.get()).isEqualTo("azure-secret");
+        
assertThat(client.buildOpenAiAuthHeaders("azure-secret")).containsEntry("api-key",
 "azure-secret");
+    }
+
+    private String startAzureModelsServer(AtomicReference<String> 
capturedApiKey) throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/openai/models", exchange -> {
+            if (capturedApiKey != null) {
+                
capturedApiKey.set(exchange.getRequestHeaders().getFirst("api-key"));
+            }
+            String query = exchange.getRequestURI().getQuery();
+            if (query == null || !query.contains("api-version=")) {
+                exchange.sendResponseHeaders(400, -1);
+                exchange.close();
+                return;
+            }
+            byte[] bytes = 
"{\"data\":[{\"id\":\"deployment-a\"},{\"id\":\"deployment-b\"}]}"
+                    .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();
+    }
+}
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 ed6f0daee7ac..cf4baac492d2 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
@@ -290,7 +290,8 @@ class AiPanel {
             }
             client = created;
             if (!client.detectEndpoint()) {
-                initError = "No LLM service reachable. Set ANTHROPIC_API_KEY, 
OPENAI_API_KEY, or start Ollama.";
+                initError
+                        = "No LLM service reachable. Set ANTHROPIC_API_KEY, 
AZURE_OPENAI_*, OPENAI_API_KEY, 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/DoctorPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
index d0c4f3d43c35..b20a07a9e338 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
@@ -288,6 +288,8 @@ class DoctorPopup {
             provider = "Anthropic";
         } else if (envSet("CLOUD_ML_REGION") && 
envSet("ANTHROPIC_VERTEX_PROJECT_ID")) {
             provider = "Vertex AI";
+        } else if (envSet("AZURE_OPENAI_API_KEY") && 
envSet("AZURE_OPENAI_ENDPOINT")) {
+            provider = "Azure OpenAI";
         } else if (envSet("OPENAI_API_KEY")) {
             provider = "OpenAI";
         } else if (envSet("LLM_API_KEY")) {
@@ -305,7 +307,8 @@ class DoctorPopup {
                     Span.styled(String.format("%-14s", "AI"), Theme.muted()),
                     Span.raw(String.format("%-30s", "No API key configured")),
                     Span.raw(" " + TuiIcons.WARN)));
-            result.add(Line.from(Span.styled("                    Set 
ANTHROPIC_API_KEY or OPENAI_API_KEY",
+            result.add(Line.from(Span.styled(
+                    "                    Set ANTHROPIC_API_KEY, 
AZURE_OPENAI_*, or OPENAI_API_KEY",
                     Style.EMPTY.dim())));
         }
     }

Reply via email to