diff --git a/src/lib/llm-providers.test.ts b/src/lib/llm-providers.test.ts index f1bb2085a..c08b9bc92 100644 --- a/src/lib/llm-providers.test.ts +++ b/src/lib/llm-providers.test.ts @@ -518,6 +518,53 @@ describe("Azure OpenAI provider", () => { expect(body.temperature).toBeUndefined() }) + it("auto-detects GPT-5 from an Azure deployment name that does not start with the model id", () => { + const cfg = getProviderConfig(mkConfig({ + provider: "azure", + model: "my-gpt-5-chat", + customEndpoint: "https://example-resource.openai.azure.com", + })) + const body = cfg.buildBody( + [{ role: "user", content: "hi" }], + { max_tokens: 2048 }, + ) as Record + + expect(body.max_tokens).toBeUndefined() + expect(body.max_completion_tokens).toBe(2048) + }) + + it("auto-detects the dashless gpt5 spelling and o-series names in Azure deployments", () => { + for (const model of ["prod-gpt5", "o3-reasoning"]) { + const cfg = getProviderConfig(mkConfig({ + provider: "azure", + model, + customEndpoint: "https://example-resource.openai.azure.com", + })) + const body = cfg.buildBody( + [{ role: "user", content: "hi" }], + { max_tokens: 1000 }, + ) as Record + + expect(body.max_tokens, model).toBeUndefined() + expect(body.max_completion_tokens, model).toBe(1000) + } + }) + + it("keeps max_tokens for non-GPT-5 Azure deployments", () => { + const cfg = getProviderConfig(mkConfig({ + provider: "azure", + model: "gpt-4o-chat", + customEndpoint: "https://example-resource.openai.azure.com", + })) + const body = cfg.buildBody( + [{ role: "user", content: "hi" }], + { max_tokens: 3000 }, + ) as Record + + expect(body.max_tokens).toBe(3000) + expect(body.max_completion_tokens).toBeUndefined() + }) + it("applies explicit Azure model family to custom Azure endpoints", () => { const cfg = getProviderConfig(mkConfig({ provider: "custom", diff --git a/src/lib/llm-providers.ts b/src/lib/llm-providers.ts index ad7805259..c30b16694 100644 --- a/src/lib/llm-providers.ts +++ b/src/lib/llm-providers.ts @@ -376,13 +376,33 @@ function isGlmVisionModel(model: string): boolean { || /(?:^|[-_.])glm[-_.]4v(?:[-_.]|$)/i.test(normalized) } +// GPT-5 / o-series (o1, o3, …) reasoning models require +// `max_completion_tokens` instead of `max_tokens`. Azure deployment names are +// user-chosen and frequently don't *start* with the underlying model id +// (e.g. "prod-gpt5", "my-gpt-5-chat", "o3-reasoning"), so match a family token +// anywhere in the name using word boundaries. `gpt-?5` also covers the common +// dashless "gpt5" spelling. See issue #610. +function deploymentNameSuggestsStrictCompletion(name: string): boolean { + const n = name.trim().toLowerCase() + return /(?:^|[-_.])gpt-?5(?:[-_.]|$)/.test(n) || /(?:^|[-_.])o\d+(?:[-_.]|$)/.test(n) +} + function isOpenAiStrictCompletionModel(config: LlmConfig): boolean { - if ((config.provider === "azure" || (config.provider === "custom" && isAzureOpenAiEndpoint(config.customEndpoint))) - && config.azureModelFamily === "gpt5") { - return true - } + const isAzureLike = + config.provider === "azure" || + (config.provider === "custom" && isAzureOpenAiEndpoint(config.customEndpoint)) + + // Explicit family override always wins. + if (isAzureLike && config.azureModelFamily === "gpt5") return true const model = config.model.trim().toLowerCase() + + // When the Azure family is left on "auto" (the default), infer it from the + // deployment name with a boundary-aware match anywhere in the string so + // GPT-5 / o-series deployments get `max_completion_tokens` without requiring + // the user to manually set the family. See issue #610. + if (isAzureLike && deploymentNameSuggestsStrictCompletion(model)) return true + const strictModel = /^gpt-5(?:[.\-_]|$)/.test(model) || /^o\d+(?:[.\-_]|$)/.test(model) if (!strictModel) return false