From a89f8c35c5c38473d2a82eb7a2408ae22c301b5c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 11:11:02 -0700 Subject: [PATCH 1/2] Send max_completion_tokens to OpenAI reasoning models First-party OpenAI reasoning models reject max_tokens, so the preset must send max_completion_tokens for those models. The requirement is declared per model on the catalog entry rather than inferred from name prefixes, and the quirk follows the first-party endpoint so relays serving the same model names keep max_tokens. --- packages/first-class-providers/src/index.ts | 2 + .../src/providers.test.ts | 16 +++++ .../first-class-providers/src/providers.ts | 23 +++++- packages/first-class-providers/src/types.ts | 14 ++++ src/config/index.ts | 2 + src/config/inference-sources.test.ts | 71 +++++++++++++++++++ src/config/inference-sources.ts | 28 +++++++- 7 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/first-class-providers/src/index.ts b/packages/first-class-providers/src/index.ts index 57eb74d60..fa26ceea8 100644 --- a/packages/first-class-providers/src/index.ts +++ b/packages/first-class-providers/src/index.ts @@ -1,5 +1,7 @@ export { FIRST_CLASS_PROVIDERS, + OPENAI_API_BASE_URL, + OPENAI_API_MAX_COMPLETION_TOKENS_MODELS, connectListProviders, firstClassPathAsProvider, firstClassProviderById, diff --git a/packages/first-class-providers/src/providers.test.ts b/packages/first-class-providers/src/providers.test.ts index ff410f6e7..085d53ef9 100644 --- a/packages/first-class-providers/src/providers.test.ts +++ b/packages/first-class-providers/src/providers.test.ts @@ -56,6 +56,22 @@ describe("FIRST_CLASS_PROVIDERS", () => { expect(api?.models).toContain(api?.defaultModel); }); + test("OpenAI API path declares max_completion_tokens models explicitly", () => { + const openai = firstClassProviderById("openai"); + const api = openai?.paths?.find((p) => p.id === "api"); + expect(api?.maxCompletionTokensModels).toEqual([ + "gpt-6-astra", + "gpt-5.4", + "gpt-5.4-mini", + "o3", + "o4-mini", + ]); + for (const model of api?.maxCompletionTokensModels ?? []) { + expect(api?.models).toContain(model); + } + expect(api?.maxCompletionTokensModels).not.toContain("gpt-4.1"); + }); + test("OpenAI API and Zen catalogs include gpt-6-astra without changing defaults", () => { const openai = firstClassProviderById("openai"); const api = openai?.paths?.find((p) => p.id === "api"); diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 3db0ebfec..29e37aabb 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -17,6 +17,23 @@ const OPENAI_API_MODELS = [ ] as const; const OPENAI_API_DEFAULT = "gpt-5.4"; +/** First-party OpenAI chat-completions endpoint for the API-key path below. */ +export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; + +/** + * Preset models whose first-party endpoint rejects `max_tokens` and requires + * `max_completion_tokens`. Explicit per-model list: adding a model here + * declares its own requirement, never inferred from name prefixes. gpt-4.1 + * is non-reasoning and stays on `max_tokens`. + */ +export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [ + "gpt-6-astra", + "gpt-5.4", + "gpt-5.4-mini", + "o3", + "o4-mini", +]; + /** * First-class providers shown in the models-surface Connect list. * Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom. @@ -38,11 +55,12 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ id: "api", label: "OpenAI API — API key", auth: "api-key", - baseURL: "https://api.openai.com/v1", + baseURL: OPENAI_API_BASE_URL, models: OPENAI_API_MODELS, defaultModel: OPENAI_API_DEFAULT, authHint: "Paste your OpenAI API key (sk-...)", providerId: "openai", + maxCompletionTokensModels: OPENAI_API_MAX_COMPLETION_TOKENS_MODELS, }, ], }, @@ -167,6 +185,9 @@ export function firstClassPathAsProvider( ? { defaultModel: path.defaultModel } : {}), ...(path.authHint !== undefined ? { authHint: path.authHint } : {}), + ...(path.maxCompletionTokensModels !== undefined + ? { maxCompletionTokensModels: path.maxCompletionTokensModels } + : {}), ...(def.anthropic === true ? { anthropic: true } : {}), ...(def.opencodeGo === true ? { opencodeGo: true } : {}), ...(def.billingProduct !== undefined diff --git a/packages/first-class-providers/src/types.ts b/packages/first-class-providers/src/types.ts index c7a73d6a4..f6e7e9602 100644 --- a/packages/first-class-providers/src/types.ts +++ b/packages/first-class-providers/src/types.ts @@ -28,6 +28,15 @@ export interface FirstClassProviderPath { * e.g. "codex" for ChatGPT OAuth, "openai" for API key. */ providerId?: string; + /** + * Models on this path whose endpoint rejects `max_tokens` and requires + * `max_completion_tokens` instead (first-party OpenAI reasoning models). + * An explicit per-model list: adding a model here declares its own + * requirement, never inferred from name prefixes. Relays serving the same + * model names through other endpoints are unaffected — the quirk follows + * this endpoint, not the bare model name. + */ + maxCompletionTokensModels?: readonly string[]; } export interface FirstClassProviderDef { @@ -57,4 +66,9 @@ export interface FirstClassProviderDef { * api-key flow runs against that path's fields / providerId. */ paths?: readonly FirstClassProviderPath[]; + /** + * Carried from a chooser path by firstClassPathAsProvider when the seeded + * def originates from a path (see FirstClassProviderPath for semantics). + */ + maxCompletionTokensModels?: readonly string[]; } diff --git a/src/config/index.ts b/src/config/index.ts index 7139406d1..c05f9199d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -209,6 +209,7 @@ export function buildOpenAISource(fields: { apiKey?: string; model: string; reasoningEffort?: ReasoningEffort; + quirks?: Record; }): InferenceSource { const overrides = fields.reasoningEffort !== undefined @@ -226,6 +227,7 @@ export function buildOpenAISource(fields: { : KEYLESS_API_KEY, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides }, + ...(fields.quirks !== undefined ? { quirks: fields.quirks } : {}), }; } diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index f2145ea4c..d2cdb9e70 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -12,6 +12,7 @@ import { setProviderContextWindowOverrides, } from "../provider/context-window.js"; import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js"; +import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js"; const WINDOW = 400_000; @@ -85,3 +86,73 @@ describe("contextWindow / maxTokens split (CL-7784)", () => { expect(contextWindowFor("fp:fp-large")).toBe(WINDOW); }); }); + +describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => { + const openaiApi = firstClassProviderById("openai")?.paths?.find( + (p) => p.id === "api", + ); + const presetModels = [...(openaiApi?.models ?? [])]; + const presetBaseURL = openaiApi?.baseURL ?? ""; + // A relay serving the same model names through the same adapter but taking + // max_tokens (per the vendor adapter comment) — the quirk must not follow + // the bare model name there. + const RELAY_BASE_URL = "https://opencode.ai/zen/v1"; + + function wireBody(model: string, baseURL: string): Record { + const entryCatalog: ProviderCatalogEntry[] = [ + { name: "openai", baseURL, apiKey: "test-key", models: [model] }, + ]; + const source = buildInferenceSourceForRef( + { provider: "openai", model }, + { sessionId: "sess-1", catalog: entryCatalog }, + undefined, + ); + // Mirror the harness: it resolves the adapter with source.quirks. + const adapter = createOpenAICompatibleAdapter( + source as unknown as Parameters[0], + source?.quirks, + ); + const messages = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ] as unknown as ConversationTurn[]; + const built = adapter.buildRequest(messages, model, { + maxTokens: source?.defaults?.maxTokens, + } as InferenceOptions); + return JSON.parse(built.body) as Record; + } + + test("shipped preset declares an explicit per-model requirement", () => { + expect(presetModels.length).toBeGreaterThan(0); + expect(openaiApi?.maxCompletionTokensModels?.length).toBeGreaterThan(0); + for (const model of openaiApi?.maxCompletionTokensModels ?? []) { + expect(presetModels).toContain(model); + } + }); + + test("reasoning preset models emit max_completion_tokens, never max_tokens", () => { + for (const model of openaiApi?.maxCompletionTokensModels ?? []) { + const body = wireBody(model, presetBaseURL); + expect(body["max_completion_tokens"]).toBe(SOURCE_MAX_TOKENS); + expect("max_tokens" in body).toBe(false); + } + }); + + test("non-reasoning preset models keep max_tokens", () => { + const declared = new Set(openaiApi?.maxCompletionTokensModels ?? []); + const rest = presetModels.filter((m) => !declared.has(m)); + expect(rest.length).toBeGreaterThan(0); + for (const model of rest) { + const body = wireBody(model, presetBaseURL); + expect(body["max_tokens"]).toBe(SOURCE_MAX_TOKENS); + expect("max_completion_tokens" in body).toBe(false); + } + }); + + test("relay endpoint keeps max_tokens for every preset model", () => { + for (const model of presetModels) { + const body = wireBody(model, RELAY_BASE_URL); + expect(body["max_tokens"]).toBe(SOURCE_MAX_TOKENS); + expect("max_completion_tokens" in body).toBe(false); + } + }); +}); diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index fa034ae68..09e140d32 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -9,7 +9,11 @@ import { buildXaiSource, type ProviderCatalogEntry, } from "./index.js"; -import type { Settings } from "./settings.js"; +import { + OPENAI_API_BASE_URL, + OPENAI_API_MAX_COMPLETION_TOKENS_MODELS, +} from "../../packages/first-class-providers/src/index.js"; +import { normalizeOpenAICompatibleBaseURL, type Settings } from "./settings.js"; import { resolveSessionEffort, type ReasoningEffort, @@ -36,6 +40,26 @@ function catalogEntry( return catalog.find((e) => e.name === provider); } +// First-party OpenAI reasoning models reject `max_tokens` and require +// `max_completion_tokens`. The requirement is declared per model on the +// first-class OpenAI API-key preset — never inferred from name prefixes — +// and the quirk attaches to the source actually in use: it follows the +// first-party endpoint, so relays serving the same model names through the +// same adapter keep `max_tokens`. +function openAISourceQuirks( + baseURL: string, + model: string, +): Record | undefined { + const normalized = normalizeOpenAICompatibleBaseURL(baseURL); + if (normalized !== normalizeOpenAICompatibleBaseURL(OPENAI_API_BASE_URL)) { + return undefined; + } + if (!OPENAI_API_MAX_COMPLETION_TOKENS_MODELS.includes(model)) { + return undefined; + } + return { maxTokensField: "max_completion_tokens" }; +} + export function buildInferenceSourceForRef( ref: ProviderRef, ctx: BuildSourceContext, @@ -124,6 +148,7 @@ export function buildInferenceSourceForRef( }); } + const quirks = openAISourceQuirks(baseURL, ref.model); return buildOpenAISource({ id: ref.provider, baseURL, @@ -134,6 +159,7 @@ export function buildInferenceSourceForRef( : {}), model: ref.model, ...(effort !== undefined ? { reasoningEffort: effort } : {}), + ...(quirks !== undefined ? { quirks } : {}), }); } From 14cc938889a0abd1807b20c123e0a602d3497847 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 11:19:57 -0700 Subject: [PATCH 2/2] Read max_completion_tokens list through the OpenAI preset entry openAISourceQuirks now reads the api path entry's maxCompletionTokensModels field instead of the static const, so the entry is the single source of truth. A new union test pins flagged plus explicit-exempt against the preset model list so an undecided model fails loudly. --- .../first-class-providers/src/providers.ts | 4 +++- src/config/inference-sources.test.ts | 15 ++++++++++++++ src/config/inference-sources.ts | 20 +++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 29e37aabb..9465bff07 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -24,7 +24,9 @@ export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; * Preset models whose first-party endpoint rejects `max_tokens` and requires * `max_completion_tokens`. Explicit per-model list: adding a model here * declares its own requirement, never inferred from name prefixes. gpt-4.1 - * is non-reasoning and stays on `max_tokens`. + * is non-reasoning and stays on `max_tokens`. This const is only the api + * path entry's initial value — runtime reads the entry's + * `maxCompletionTokensModels` field, so that field is the source of truth. */ export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [ "gpt-6-astra", diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index d2cdb9e70..34df84bfa 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -129,6 +129,21 @@ describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => { } }); + test("every preset model has an explicit quirk decision", () => { + const flagged = new Set(openaiApi?.maxCompletionTokensModels ?? []); + // Explicit max_tokens decision: non-reasoning preset models stay on + // max_tokens. Adding a preset model requires a decision here AND in the + // preset's maxCompletionTokensModels — the union below fails loudly + // otherwise instead of silently sending max_tokens. + const explicitMaxTokensModels = new Set(["gpt-4.1"]); + expect([...flagged, ...explicitMaxTokensModels].sort()).toEqual( + [...new Set(presetModels)].sort(), + ); + expect([...flagged].filter((m) => explicitMaxTokensModels.has(m))).toEqual( + [], + ); + }); + test("reasoning preset models emit max_completion_tokens, never max_tokens", () => { for (const model of openaiApi?.maxCompletionTokensModels ?? []) { const body = wireBody(model, presetBaseURL); diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 09e140d32..deb6524cd 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -11,7 +11,7 @@ import { } from "./index.js"; import { OPENAI_API_BASE_URL, - OPENAI_API_MAX_COMPLETION_TOKENS_MODELS, + firstClassProviderById, } from "../../packages/first-class-providers/src/index.js"; import { normalizeOpenAICompatibleBaseURL, type Settings } from "./settings.js"; import { @@ -42,10 +42,18 @@ function catalogEntry( // First-party OpenAI reasoning models reject `max_tokens` and require // `max_completion_tokens`. The requirement is declared per model on the -// first-class OpenAI API-key preset — never inferred from name prefixes — -// and the quirk attaches to the source actually in use: it follows the -// first-party endpoint, so relays serving the same model names through the -// same adapter keep `max_tokens`. +// first-class OpenAI API-key path's `maxCompletionTokensModels` field — never +// inferred from name prefixes — and read here through that entry, so the +// entry stays the single source of truth. The quirk attaches to the source +// actually in use: it follows the first-party endpoint, so relays serving +// the same model names through the same adapter keep `max_tokens`. +function openAIAPIPathMaxCompletionTokensModels(): readonly string[] { + return ( + firstClassProviderById("openai")?.paths?.find((p) => p.id === "api") + ?.maxCompletionTokensModels ?? [] + ); +} + function openAISourceQuirks( baseURL: string, model: string, @@ -54,7 +62,7 @@ function openAISourceQuirks( if (normalized !== normalizeOpenAICompatibleBaseURL(OPENAI_API_BASE_URL)) { return undefined; } - if (!OPENAI_API_MAX_COMPLETION_TOKENS_MODELS.includes(model)) { + if (!openAIAPIPathMaxCompletionTokensModels().includes(model)) { return undefined; } return { maxTokensField: "max_completion_tokens" };