fix(llm): scope Azure query filtering and preserve endpoint parameters

Follow up PR #677: only remove api-version on Azure unified v1 endpoints, retain versions for other compatible gateways, and prefer explicit endpoint parameters over SDK defaults. Add regression coverage for query policies and tool-free finalization.
This commit is contained in:
matevip 2026-11-02 01:10:27 -05:00
parent 4de1e4ff0e
commit 07613e30ca
2 changed files with 111 additions and 10 deletions

View File

@ -59,19 +59,23 @@ final class OpenAiCompatibleHttpInterceptor implements Interceptor {
rewritten.path("messages").size(),
rewritten.path("tools").size(),
rewritten.path("stream").asBoolean());
// 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.
// Only Azure's unified v1 surface rejects the legacy SDK api-version.
// Keep explicit endpoint query values authoritative and avoid duplicating
// parameters already present there when copying SDK query parameters.
HttpUrl parsedEndpoint = HttpUrl.get(endpoint);
boolean legacyDeploymentsEndpoint = parsedEndpoint.encodedPath().contains("/deployments/");
String host = parsedEndpoint.host();
boolean azureUnifiedEndpoint =
(host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com"))
&& parsedEndpoint.encodedPath().startsWith("/openai/v1/");
HttpUrl.Builder url = parsedEndpoint.newBuilder();
if (azureUnifiedEndpoint) {
for (String name : parsedEndpoint.queryParameterNames()) {
if ("api-version".equalsIgnoreCase(name)) url.removeAllQueryParameters(name);
}
}
for (String name : original.url().queryParameterNames()) {
if ("api-version".equalsIgnoreCase(name) && !legacyDeploymentsEndpoint) continue;
if (azureUnifiedEndpoint && "api-version".equalsIgnoreCase(name)) continue;
if (parsedEndpoint.queryParameterNames().contains(name)) continue;
for (String value : original.url().queryParameterValues(name)) {
url.addQueryParameter(name, value);
}

View File

@ -0,0 +1,97 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import okhttp3.*;
import org.junit.jupiter.api.Test;
import vip.mate.llm.model.ModelProviderEntity;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class OpenAiAzureWirePolicyTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Test
void stripsOrphanToolOptionsAndEmptyToolsWithoutMutatingSource() throws Exception {
for (String tools : List.of("", ",\"tools\":[]")) {
ObjectNode source = (ObjectNode) JSON.readTree("{\"model\":\"gpt-4o\",\"messages\":[],"
+ "\"tool_choice\":\"none\",\"parallel_tool_calls\":false" + tools + "}");
var out = OpenAiRequestRewriter.rewrite(source, provider(), false);
assertFalse(out.has("tools"));
assertFalse(out.has("tool_choice"));
assertFalse(out.has("parallel_tool_calls"));
assertTrue(source.has("tool_choice"));
}
}
@Test
void preservesToolDisableGuardWhenToolsExist() throws Exception {
ObjectNode source = (ObjectNode) JSON.readTree("""
{"model":"gpt-4o","messages":[],"tools":[{"type":"function","function":{"name":"lookup"}}],
"tool_choice":"none","parallel_tool_calls":false}
""");
assertEquals(source, OpenAiRequestRewriter.rewrite(source, provider(), false));
}
@Test
void keepsOptionsForInjectedKimiSearchTool() throws Exception {
ObjectNode source = (ObjectNode) JSON.readTree("{\"model\":\"kimi-k2\",\"messages\":[],\"tool_choice\":\"none\"}");
var out = OpenAiRequestRewriter.rewrite(source, provider(), true);
assertEquals("none", out.path("tool_choice").asText());
assertEquals(1, out.path("tools").size());
}
@Test
void unifiedAzureDropsSdkVersionButPreservesOtherParameters() throws Exception {
var url = wireUrl("https://demo.openai.azure.com/openai/v1/chat/completions",
"https://demo.openai.azure.com/openai/deployments/gpt/chat/completions?api-version=2025-01-01&trace=a&trace=b");
assertNull(url.queryParameter("api-version"));
assertEquals(List.of("a", "b"), url.queryParameterValues("trace"));
}
@Test
void legacyAzureKeepsVersion() throws Exception {
var url = wireUrl("https://demo.openai.azure.com/openai/deployments/gpt/chat/completions",
"https://demo.openai.azure.com/openai/deployments/gpt/chat/completions?api-version=2025-01-01");
assertEquals("2025-01-01", url.queryParameter("api-version"));
}
@Test
void legacyConfiguredVersionWinsWithoutDuplicate() throws Exception {
var url = wireUrl("https://demo.openai.azure.com/openai/deployments/gpt/chat/completions?api-version=custom",
"https://demo.openai.azure.com/openai/deployments/gpt/chat/completions?api-version=sdk");
assertEquals(List.of("custom"), url.queryParameterValues("api-version"));
}
@Test
void nonAzureGatewayKeepsVersionParameter() throws Exception {
var url = wireUrl("https://gateway.example/v1/chat/completions",
"https://gateway.example/v1/chat/completions?api-version=custom");
assertEquals("custom", url.queryParameter("api-version"));
}
private static HttpUrl wireUrl(String endpoint, String original) throws Exception {
AtomicReference<HttpUrl> sent = new AtomicReference<>();
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new OpenAiCompatibleHttpInterceptor(provider(), endpoint, Map.of(), false, Duration.ofSeconds(3)))
.addInterceptor(chain -> {
sent.set(chain.request().url());
return new Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(200)
.message("OK").body(ResponseBody.create("{}", MediaType.get("application/json"))).build();
}).build();
Request request = new Request.Builder().url(original)
.post(RequestBody.create("{\"model\":\"gpt-4o\",\"messages\":[]}", MediaType.get("application/json"))).build();
try (Response ignored = client.newCall(request).execute()) { return sent.get(); }
}
private static ModelProviderEntity provider() {
var provider = new ModelProviderEntity();
provider.setProviderId("azure-openai");
return provider;
}
}