fix(sse): apply Azure request-param rules on the azure-ai wire path

Azure rejects several stock Chat Completions params on its newer deployments
and returns HTTP 400 rather than ignoring them:

  max_tokens       -> 'max_tokens' is not supported with this model.
                      Use 'max_completion_tokens' instead.
  reasoning_effort -> Function tools with reasoning_effort are not supported.

Those rules lived inline in AzureOpenAIExecutor, so they only covered the
azure-openai provider. azure-ai (Azure AI Foundry) had no executor entry and
fell through to the bare DefaultExecutor, so the SAME Azure deployment
succeeded on one connection and 400'd on the other. Every agentic client sends
tools on every turn, so azure-ai failed on the first request.

Extract the rules to open-sse/executors/azureParamRules.ts, add an
AzureAiExecutor that inherits DefaultExecutor's azure-ai URL/header/apiType
handling unchanged and applies the shared rules, and register it for azure-ai.

Also widen the deployment pattern to cover gpt-chat-latest: it is a moving
alias that resolves to a GPT-5-era model and rejects max_tokens, but carries no
version number for the token-boundary pattern to key on. Verified against the
base regex - gpt-chat-latest did not match, which is exactly the observed 400.

Regression guard: tests/unit/azure-param-rules.test.ts, including an assertion
that getExecutor("azure-ai") no longer resolves to a bare DefaultExecutor.
This commit is contained in:
Mihaly Bodo
2026-08-08 16:15:12 +02:00
committed by diegosouzapw
parent aae408f585
commit 37129db6af
5 changed files with 216 additions and 33 deletions

View File

@@ -0,0 +1,35 @@
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
import { applyAzureParamRules } from "./azureParamRules.ts";
/**
* Azure AI Foundry (`azure-ai`).
*
* URL building, auth headers and the `responses` vs `chat` apiType switch all
* live in `DefaultExecutor`, keyed on the `azure-ai` provider id — this subclass
* inherits them unchanged and adds only the Azure request-param rules.
*
* Before this existed, `azure-ai` fell through to the bare `DefaultExecutor`
* while `azure-openai` had the rules inline, so the same Azure deployment
* behaved differently depending on which connection served it: `azure-openai`
* succeeded and `azure-ai` returned HTTP 400 for `max_tokens` /
* `reasoning_effort`.
*/
export class AzureAiExecutor extends DefaultExecutor {
constructor() {
super("azure-ai");
}
override transformRequest(
model: string,
body: unknown,
stream: boolean,
credentials: ProviderCredentials
): unknown {
return applyAzureParamRules(
model,
body,
super.transformRequest(model, body, stream, credentials)
);
}
}

View File

@@ -1,9 +1,9 @@
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
import { applyAzureParamRules } from "./azureParamRules.ts";
const DEFAULT_API_VERSION = "2024-12-01-preview";
const GPT5_OR_REASONING_DEPLOYMENT = /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)/i;
function normalizeAzureBaseUrl(rawBaseUrl?: string | null): string {
const normalized = stripTrailingSlashes((rawBaseUrl || "").trim());
@@ -57,37 +57,10 @@ export class AzureOpenAIExecutor extends DefaultExecutor {
stream: boolean,
credentials: ProviderCredentials
): unknown {
const transformed = super.transformRequest(model, body, stream, credentials);
if (!GPT5_OR_REASONING_DEPLOYMENT.test(model)) return transformed;
if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) {
return transformed;
}
const original =
body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>)
: null;
const normalized = { ...(transformed as Record<string, unknown>) };
if (original?.max_completion_tokens !== undefined) {
normalized.max_completion_tokens = original.max_completion_tokens;
} else if (
normalized.max_completion_tokens === undefined &&
original?.max_tokens !== undefined
) {
normalized.max_completion_tokens = original.max_tokens;
}
delete normalized.max_tokens;
if (normalized.temperature !== undefined && normalized.temperature !== 1) {
delete normalized.temperature;
}
const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0;
if (hasTools || normalized.reasoning_effort === "none") {
delete normalized.reasoning_effort;
}
return normalized;
return applyAzureParamRules(
model,
body,
super.transformRequest(model, body, stream, credentials)
);
}
}

View File

@@ -0,0 +1,76 @@
/**
* Azure Chat Completions param rules, shared by every Azure wire path.
*
* Azure's newer deployments reject a handful of stock OpenAI Chat Completions
* params and return HTTP 400 rather than ignoring them:
*
* - `max_tokens` -> "Unsupported parameter: 'max_tokens' is not supported
* with this model. Use 'max_completion_tokens' instead."
* - `temperature` -> only the default (1) is accepted.
* - `reasoning_effort` -> "Function tools with reasoning_effort are not
* supported ... Please use /v1/responses instead."
*
* This logic previously lived inline in `AzureOpenAIExecutor`, so it only
* covered the `azure-openai` provider. `azure-ai` (Azure AI Foundry) routes
* through `DefaultExecutor` and inherited none of it, which meant an identical
* deployment 400'd on one connection and succeeded on the other. Extracted here
* so both executors apply exactly the same rules.
*/
/**
* Deployments that require `max_completion_tokens` instead of `max_tokens`.
*
* Matches the GPT-5 family and the o1/o3/o4 reasoning series at a token
* boundary, so a deployment named `my-gpt-5-prod` matches while an unrelated
* `piston-o4-legacy`-style name does not match by accident. `gpt-chat-latest`
* is listed explicitly: it is a moving alias that currently resolves to a
* GPT-5-era model and rejects `max_tokens`, but carries no version number for
* the boundary pattern to key on.
*/
export const AZURE_COMPLETION_TOKEN_DEPLOYMENT =
/(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i;
/**
* Apply the Azure param rules to an already-translated Chat Completions body.
*
* `originalBody` is the pre-translation request, consulted only to recover a
* caller-supplied token budget that translation may have moved or dropped.
* Returns `transformed` untouched when the deployment is unaffected or the body
* is not a plain object, and never mutates either input.
*/
export function applyAzureParamRules(
model: string,
originalBody: unknown,
transformed: unknown
): unknown {
if (!AZURE_COMPLETION_TOKEN_DEPLOYMENT.test(model)) return transformed;
if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) {
return transformed;
}
const original =
originalBody && typeof originalBody === "object" && !Array.isArray(originalBody)
? (originalBody as Record<string, unknown>)
: null;
const normalized = { ...(transformed as Record<string, unknown>) };
if (original?.max_completion_tokens !== undefined) {
normalized.max_completion_tokens = original.max_completion_tokens;
} else if (normalized.max_completion_tokens === undefined && original?.max_tokens !== undefined) {
normalized.max_completion_tokens = original.max_tokens;
}
delete normalized.max_tokens;
if (normalized.temperature !== undefined && normalized.temperature !== 1) {
delete normalized.temperature;
}
// Azure 400s on reasoning_effort as soon as tools are present, which is every
// agentic client (Claude Code, Cursor agent) on every turn.
const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0;
if (hasTools || normalized.reasoning_effort === "none") {
delete normalized.reasoning_effort;
}
return normalized;
}

View File

@@ -25,6 +25,7 @@ import { ChatGptWebExecutor } from "./chatgpt-web.ts";
import { BlackboxWebExecutor } from "./blackbox-web.ts";
import { MuseSparkWebExecutor } from "./muse-spark-web.ts";
import { AzureOpenAIExecutor } from "./azure-openai.ts";
import { AzureAiExecutor } from "./azure-ai.ts";
import { CommandCodeExecutor } from "./commandCode.ts";
import { GitlabExecutor } from "./gitlab.ts";
import { NlpCloudExecutor } from "./nlpcloud.ts";
@@ -89,6 +90,7 @@ const executors = {
glmt: new GlmExecutor("glmt"),
cu: new CursorExecutor(), // Alias for cursor
"azure-openai": new AzureOpenAIExecutor(),
"azure-ai": new AzureAiExecutor(),
"command-code": new CommandCodeExecutor(),
cmd: new CommandCodeExecutor(), // Alias
gitlab: new GitlabExecutor(),
@@ -263,6 +265,7 @@ export { ChatGptWebExecutor } from "./chatgpt-web.ts";
export { BlackboxWebExecutor } from "./blackbox-web.ts";
export { MuseSparkWebExecutor } from "./muse-spark-web.ts";
export { AzureOpenAIExecutor } from "./azure-openai.ts";
export { AzureAiExecutor } from "./azure-ai.ts";
export { CommandCodeExecutor } from "./commandCode.ts";
export { GitlabExecutor } from "./gitlab.ts";
export { NlpCloudExecutor } from "./nlpcloud.ts";

View File

@@ -0,0 +1,96 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
applyAzureParamRules,
AZURE_COMPLETION_TOKEN_DEPLOYMENT,
} from "../../open-sse/executors/azureParamRules.ts";
import { getExecutor, AzureAiExecutor } from "../../open-sse/executors/index.ts";
/**
* Regression guards for two Azure 400s observed against a live Azure AI Foundry
* resource:
*
* azure-ai/gpt-chat-latest
* -> 400 "Unsupported parameter: 'max_tokens' is not supported with this
* model. Use 'max_completion_tokens' instead."
* azure-ai/<any gpt-5 deployment> with tools
* -> 400 "Function tools with reasoning_effort are not supported ...
* Please use /v1/responses instead."
*
* Both rules already existed inline in AzureOpenAIExecutor, so the identical
* deployment succeeded on the `azure-openai` connection and failed on
* `azure-ai`, which routed through the bare DefaultExecutor.
*/
test("gpt-chat-latest converts max_tokens to max_completion_tokens", () => {
const out = applyAzureParamRules(
"gpt-chat-latest",
{ max_tokens: 4096 },
{ max_tokens: 4096, messages: [] }
) as Record<string, unknown>;
assert.equal(out.max_tokens, undefined);
assert.equal(out.max_completion_tokens, 4096);
});
test("gpt-5 family converts max_tokens too", () => {
for (const model of ["gpt-5.1", "gpt-5.4-nano", "my-gpt-5-prod", "o3", "o4-mini"]) {
const out = applyAzureParamRules(model, { max_tokens: 100 }, { max_tokens: 100 }) as Record<
string,
unknown
>;
assert.equal(out.max_tokens, undefined, `${model} should drop max_tokens`);
assert.equal(out.max_completion_tokens, 100, `${model} should set max_completion_tokens`);
}
});
test("reasoning_effort is dropped when tools are present", () => {
const out = applyAzureParamRules(
"gpt-5.1",
{},
{ reasoning_effort: "high", tools: [{ name: "read_file" }] }
) as Record<string, unknown>;
assert.equal(out.reasoning_effort, undefined);
assert.equal((out.tools as unknown[]).length, 1);
});
test("reasoning_effort survives when there are no tools", () => {
const out = applyAzureParamRules("gpt-5.1", {}, { reasoning_effort: "high" }) as Record<
string,
unknown
>;
assert.equal(out.reasoning_effort, "high");
});
test("non-default temperature is dropped, temperature=1 kept", () => {
const dropped = applyAzureParamRules("gpt-5.1", {}, { temperature: 0.7 }) as Record<
string,
unknown
>;
assert.equal(dropped.temperature, undefined);
const kept = applyAzureParamRules("gpt-5.1", {}, { temperature: 1 }) as Record<string, unknown>;
assert.equal(kept.temperature, 1);
});
test("unaffected deployments pass through untouched", () => {
const body = { max_tokens: 500, temperature: 0.2, reasoning_effort: "low" };
const out = applyAzureParamRules("Phi-4", {}, body);
assert.deepEqual(out, body);
});
test("the regex does not match unrelated names by accident", () => {
assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("gpt-4o-mini"), false);
assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("DeepSeek-V4-Flash"), false);
assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("Kimi-K2.7-Code"), false);
});
test("azure-ai resolves to AzureAiExecutor, not the bare DefaultExecutor", () => {
const executor = getExecutor("azure-ai");
assert.ok(
executor instanceof AzureAiExecutor,
"azure-ai must have its own executor so it inherits the Azure param rules"
);
});