From 4de1e4ff0e26977360295c7e6e2e2f98d1acd987 Mon Sep 17 00:00:00 2001 From: "Kenny.He" <72521213+KennyHe2099@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:09:08 +0800 Subject: [PATCH] fix(llm): support Azure unified OpenAI endpoints Prevent legacy Azure URL rewriting on unified /openai/v1 endpoints and remove orphan tool options from requests without tools. Preserve tool_choice none when tools are present. --- .../OpenAiCompatibleChatModelBuilder.java | 37 +++++++++++++++++-- .../OpenAiCompatibleHttpInterceptor.java | 19 +++++++++- .../llm/chatmodel/OpenAiRequestRewriter.java | 17 +++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java index a7ae609e..6f8309e8 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java @@ -75,10 +75,21 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { chain.request().newBuilder().removeHeader("Authorization").build())); observed.interceptor(interceptor); PinnedOpenAiHttpClient transport = new PinnedOpenAiHttpClient(observed.build()); - ClientOptions clientOptions = ClientOptions.builder().httpClient(transport).baseUrl(baseUrl) - .apiKey(authenticated ? options.getApiKey() : "mateclaw-keyless-placeholder") - .timeout(options.getTimeout()).maxRetries(0).build(); - OpenAIClientImpl sdk = new OpenAIClientImpl(clientOptions); + ClientOptions.Builder clientOptions = ClientOptions.builder().httpClient(transport).baseUrl(baseUrl) + .apiKey(authenticated ? options.getApiKey() : "mateclaw-keyless-placeholder") + .timeout(options.getTimeout()).maxRetries(0); + /* + * fix:https://github.com/mateaix/mateclaw/issues/640 + * Azure unified (/openai/v1) base URLs must not be downgraded by the SDK: + * with the default AUTO mode openai-java classifies *.openai.azure.com hosts + * as AZURE_LEGACY (our /v1-stripped baseUrl never ends in /openai/v1), rewrites + * request paths to /openai/deployments/{model} and appends a default + * api-version query — both wrong for the v1 surface the operator configured. + */ + if (isAzureUnifiedBaseUrl(provider.getBaseUrl())) { + clientOptions.azureUrlPathMode(com.openai.azure.AzureUrlPathMode.UNIFIED); + } + OpenAIClientImpl sdk = new OpenAIClientImpl(clientOptions.build()); ChatModel raw = OpenAiChatModel.builder().options(options).openAiClient(sdk).openAiClientAsync(sdk.async()) .observationRegistry(observations).build(); raw = new OpenAiWirePolicyChatModel(raw, retry, transport); @@ -263,6 +274,24 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { return path; } + /** + * fix:https://github.com/mateaix/mateclaw/issues/640 + * Whether the operator configured an Azure OpenAI resource through the unified + * {@code /openai/v1} surface (per Microsoft's "switching endpoints" guidance). + * On that surface no {@code api-version} query parameter may be sent, and the + * SDK must not rewrite paths into the legacy {@code /openai/deployments/{name}} + * form — see {@link #build(ModelConfigEntity, ModelProviderEntity, RetryTemplate)}. + */ + private static boolean isAzureUnifiedBaseUrl(String rawBaseUrl) { + if (rawBaseUrl == null) return false; + String url = rawBaseUrl.trim(); + while (url.endsWith("/") || url.endsWith("?")) { + url = url.substring(0, url.length() - 1); + } + return url.endsWith("/openai/v1") + && (url.contains("openai.azure.com") || url.contains("cognitiveservices.azure.com")); + } + /** Defensive scrub for provider error bodies, which may echo request data. */ static String redactAndTruncate(String body) { String redacted = body diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleHttpInterceptor.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleHttpInterceptor.java index 1055afd2..fef8c8c9 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleHttpInterceptor.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleHttpInterceptor.java @@ -59,8 +59,23 @@ final class OpenAiCompatibleHttpInterceptor implements Interceptor { rewritten.path("messages").size(), rewritten.path("tools").size(), rewritten.path("stream").asBoolean()); - HttpUrl.Builder url = HttpUrl.get(endpoint).newBuilder(); - if (original.url().encodedQuery() != null) url.encodedQuery(original.url().encodedQuery()); + // fix:https://github.com/mateaix/mateclaw/issues/640 + // The endpoint (provider baseUrl + completions path) is the authoritative URL. + // Carry over SDK-attached query params selectively: openai-java classifies + // *.openai.azure.com hosts whose baseUrl does not end in /openai/v1 as + // AZURE_LEGACY and appends a default `api-version` query — which the unified + // /openai/v1 surface rejects with 400 "API version not supported". Keep + // `api-version` only when the endpoint itself is a legacy deployments URL, + // where Azure requires it. + HttpUrl parsedEndpoint = HttpUrl.get(endpoint); + boolean legacyDeploymentsEndpoint = parsedEndpoint.encodedPath().contains("/deployments/"); + HttpUrl.Builder url = parsedEndpoint.newBuilder(); + for (String name : original.url().queryParameterNames()) { + if ("api-version".equalsIgnoreCase(name) && !legacyDeploymentsEndpoint) continue; + for (String value : original.url().queryParameterValues(name)) { + url.addQueryParameter(name, value); + } + } Request.Builder updated = original.newBuilder() .url(url.build()) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java index df3d9e19..4ae615a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java @@ -71,6 +71,23 @@ final class OpenAiRequestRewriter { ArrayNode tools = request.path("tools").isArray() ? (ArrayNode) request.get("tools") : request.putArray("tools"); tools.addObject().put("type", "builtin_function").putObject("function").put("name", "$web_search"); } + // fix:https://github.com/mateaix/mateclaw/issues/640 + // OpenAI/Azure reject tool_choice and parallel_tool_calls on a request that + // carries no tools (400 "'tool_choice' is only allowed when 'tools' are + // specified"). Lenient gateways silently ignore the orphan fields, which is + // why this only surfaced on Azure. TaskFinalizationCoordinator builds + // finalization/summary options with cleared tool callbacks AND + // toolChoice("none") — that belt-and-braces only makes sense while tools + // actually reach the wire, so keep it when they do and drop the orphan + // fields when they don't. An empty tools array is itself spec-invalid + // ("minimum length 1"); drop it too. + JsonNode toolsNode = request.path("tools"); + boolean hasTools = toolsNode.isArray() && !toolsNode.isEmpty(); + if (toolsNode.isArray() && toolsNode.isEmpty()) request.remove("tools"); + if (!hasTools) { + request.remove("tool_choice"); + request.remove("parallel_tool_calls"); + } return request; } }