diff --git a/open-sse/executors/azure-ai.ts b/open-sse/executors/azure-ai.ts new file mode 100644 index 0000000000..438a4d1bc5 --- /dev/null +++ b/open-sse/executors/azure-ai.ts @@ -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) + ); + } +} diff --git a/open-sse/executors/azure-openai.ts b/open-sse/executors/azure-openai.ts index 9b910d5c95..3872757a56 100644 --- a/open-sse/executors/azure-openai.ts +++ b/open-sse/executors/azure-openai.ts @@ -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) - : null; - const normalized = { ...(transformed as Record) }; - - 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) + ); } } diff --git a/open-sse/executors/azureParamRules.ts b/open-sse/executors/azureParamRules.ts new file mode 100644 index 0000000000..4bd8eab22a --- /dev/null +++ b/open-sse/executors/azureParamRules.ts @@ -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) + : null; + const normalized = { ...(transformed as Record) }; + + 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; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b25f4d9555..6b9477338b 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/tests/unit/azure-param-rules.test.ts b/tests/unit/azure-param-rules.test.ts new file mode 100644 index 0000000000..4e46b0788f --- /dev/null +++ b/tests/unit/azure-param-rules.test.ts @@ -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/ 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; + + 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; + + 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; + 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" + ); +});