mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
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:
parent
952f73ddfa
commit
4de1e4ff0e
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user