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()));
|
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
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user