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.
This commit is contained in:
Kenny.He 2026-11-02 14:09:08 +08:00 committed by GitHub
parent 952f73ddfa
commit 4de1e4ff0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 67 additions and 6 deletions

View File

@ -75,10 +75,21 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
chain.request().newBuilder().removeHeader("Authorization").build())); chain.request().newBuilder().removeHeader("Authorization").build()));
observed.interceptor(interceptor); observed.interceptor(interceptor);
PinnedOpenAiHttpClient transport = new PinnedOpenAiHttpClient(observed.build()); PinnedOpenAiHttpClient transport = new PinnedOpenAiHttpClient(observed.build());
ClientOptions clientOptions = ClientOptions.builder().httpClient(transport).baseUrl(baseUrl) ClientOptions.Builder clientOptions = ClientOptions.builder().httpClient(transport).baseUrl(baseUrl)
.apiKey(authenticated ? options.getApiKey() : "mateclaw-keyless-placeholder") .apiKey(authenticated ? options.getApiKey() : "mateclaw-keyless-placeholder")
.timeout(options.getTimeout()).maxRetries(0).build(); .timeout(options.getTimeout()).maxRetries(0);
OpenAIClientImpl sdk = new OpenAIClientImpl(clientOptions); /*
* 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()) ChatModel raw = OpenAiChatModel.builder().options(options).openAiClient(sdk).openAiClientAsync(sdk.async())
.observationRegistry(observations).build(); .observationRegistry(observations).build();
raw = new OpenAiWirePolicyChatModel(raw, retry, transport); raw = new OpenAiWirePolicyChatModel(raw, retry, transport);
@ -263,6 +274,24 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
return path; 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. */ /** Defensive scrub for provider error bodies, which may echo request data. */
static String redactAndTruncate(String body) { static String redactAndTruncate(String body) {
String redacted = body String redacted = body

View File

@ -59,8 +59,23 @@ final class OpenAiCompatibleHttpInterceptor implements Interceptor {
rewritten.path("messages").size(), rewritten.path("messages").size(),
rewritten.path("tools").size(), rewritten.path("tools").size(),
rewritten.path("stream").asBoolean()); rewritten.path("stream").asBoolean());
HttpUrl.Builder url = HttpUrl.get(endpoint).newBuilder(); // fix:https://github.com/mateaix/mateclaw/issues/640
if (original.url().encodedQuery() != null) url.encodedQuery(original.url().encodedQuery()); // 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 = Request.Builder updated =
original.newBuilder() original.newBuilder()
.url(url.build()) .url(url.build())

View File

@ -71,6 +71,23 @@ final class OpenAiRequestRewriter {
ArrayNode tools = request.path("tools").isArray() ? (ArrayNode) request.get("tools") : request.putArray("tools"); 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"); 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; return request;
} }
} }