Skip to content

Commit 4c2787a

Browse files
Send max_completion_tokens to OpenAI reasoning models (#951)
* 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. * 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.
1 parent fcc150e commit 4c2787a

7 files changed

Lines changed: 179 additions & 2 deletions

File tree

packages/first-class-providers/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export {
22
FIRST_CLASS_PROVIDERS,
3+
OPENAI_API_BASE_URL,
4+
OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
35
connectListProviders,
46
firstClassPathAsProvider,
57
firstClassProviderById,

packages/first-class-providers/src/providers.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ describe("FIRST_CLASS_PROVIDERS", () => {
5656
expect(api?.models).toContain(api?.defaultModel);
5757
});
5858

59+
test("OpenAI API path declares max_completion_tokens models explicitly", () => {
60+
const openai = firstClassProviderById("openai");
61+
const api = openai?.paths?.find((p) => p.id === "api");
62+
expect(api?.maxCompletionTokensModels).toEqual([
63+
"gpt-6-astra",
64+
"gpt-5.4",
65+
"gpt-5.4-mini",
66+
"o3",
67+
"o4-mini",
68+
]);
69+
for (const model of api?.maxCompletionTokensModels ?? []) {
70+
expect(api?.models).toContain(model);
71+
}
72+
expect(api?.maxCompletionTokensModels).not.toContain("gpt-4.1");
73+
});
74+
5975
test("OpenAI API and Zen catalogs include gpt-6-astra without changing defaults", () => {
6076
const openai = firstClassProviderById("openai");
6177
const api = openai?.paths?.find((p) => p.id === "api");

packages/first-class-providers/src/providers.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ const OPENAI_API_MODELS = [
1717
] as const;
1818
const OPENAI_API_DEFAULT = "gpt-5.4";
1919

20+
/** First-party OpenAI chat-completions endpoint for the API-key path below. */
21+
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
22+
23+
/**
24+
* Preset models whose first-party endpoint rejects `max_tokens` and requires
25+
* `max_completion_tokens`. Explicit per-model list: adding a model here
26+
* declares its own requirement, never inferred from name prefixes. gpt-4.1
27+
* is non-reasoning and stays on `max_tokens`. This const is only the api
28+
* path entry's initial value — runtime reads the entry's
29+
* `maxCompletionTokensModels` field, so that field is the source of truth.
30+
*/
31+
export const OPENAI_API_MAX_COMPLETION_TOKENS_MODELS: readonly string[] = [
32+
"gpt-6-astra",
33+
"gpt-5.4",
34+
"gpt-5.4-mini",
35+
"o3",
36+
"o4-mini",
37+
];
38+
2039
/**
2140
* First-class providers shown in the models-surface Connect list.
2241
* Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom.
@@ -38,11 +57,12 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
3857
id: "api",
3958
label: "OpenAI API — API key",
4059
auth: "api-key",
41-
baseURL: "https://api.openai.com/v1",
60+
baseURL: OPENAI_API_BASE_URL,
4261
models: OPENAI_API_MODELS,
4362
defaultModel: OPENAI_API_DEFAULT,
4463
authHint: "Paste your OpenAI API key (sk-...)",
4564
providerId: "openai",
65+
maxCompletionTokensModels: OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
4666
},
4767
],
4868
},
@@ -167,6 +187,9 @@ export function firstClassPathAsProvider(
167187
? { defaultModel: path.defaultModel }
168188
: {}),
169189
...(path.authHint !== undefined ? { authHint: path.authHint } : {}),
190+
...(path.maxCompletionTokensModels !== undefined
191+
? { maxCompletionTokensModels: path.maxCompletionTokensModels }
192+
: {}),
170193
...(def.anthropic === true ? { anthropic: true } : {}),
171194
...(def.opencodeGo === true ? { opencodeGo: true } : {}),
172195
...(def.billingProduct !== undefined

packages/first-class-providers/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ export interface FirstClassProviderPath {
2828
* e.g. "codex" for ChatGPT OAuth, "openai" for API key.
2929
*/
3030
providerId?: string;
31+
/**
32+
* Models on this path whose endpoint rejects `max_tokens` and requires
33+
* `max_completion_tokens` instead (first-party OpenAI reasoning models).
34+
* An explicit per-model list: adding a model here declares its own
35+
* requirement, never inferred from name prefixes. Relays serving the same
36+
* model names through other endpoints are unaffected — the quirk follows
37+
* this endpoint, not the bare model name.
38+
*/
39+
maxCompletionTokensModels?: readonly string[];
3140
}
3241

3342
export interface FirstClassProviderDef {
@@ -57,4 +66,9 @@ export interface FirstClassProviderDef {
5766
* api-key flow runs against that path's fields / providerId.
5867
*/
5968
paths?: readonly FirstClassProviderPath[];
69+
/**
70+
* Carried from a chooser path by firstClassPathAsProvider when the seeded
71+
* def originates from a path (see FirstClassProviderPath for semantics).
72+
*/
73+
maxCompletionTokensModels?: readonly string[];
6074
}

src/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export function buildOpenAISource(fields: {
209209
apiKey?: string;
210210
model: string;
211211
reasoningEffort?: ReasoningEffort;
212+
quirks?: Record<string, unknown>;
212213
}): InferenceSource {
213214
const overrides =
214215
fields.reasoningEffort !== undefined
@@ -226,6 +227,7 @@ export function buildOpenAISource(fields: {
226227
: KEYLESS_API_KEY,
227228
model: fields.model,
228229
defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides },
230+
...(fields.quirks !== undefined ? { quirks: fields.quirks } : {}),
229231
};
230232
}
231233

src/config/inference-sources.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
setProviderContextWindowOverrides,
1313
} from "../provider/context-window.js";
1414
import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js";
15+
import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js";
1516

1617
const WINDOW = 400_000;
1718

@@ -85,3 +86,88 @@ describe("contextWindow / maxTokens split (CL-7784)", () => {
8586
expect(contextWindowFor("fp:fp-large")).toBe(WINDOW);
8687
});
8788
});
89+
90+
describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => {
91+
const openaiApi = firstClassProviderById("openai")?.paths?.find(
92+
(p) => p.id === "api",
93+
);
94+
const presetModels = [...(openaiApi?.models ?? [])];
95+
const presetBaseURL = openaiApi?.baseURL ?? "";
96+
// A relay serving the same model names through the same adapter but taking
97+
// max_tokens (per the vendor adapter comment) — the quirk must not follow
98+
// the bare model name there.
99+
const RELAY_BASE_URL = "https://opencode.ai/zen/v1";
100+
101+
function wireBody(model: string, baseURL: string): Record<string, unknown> {
102+
const entryCatalog: ProviderCatalogEntry[] = [
103+
{ name: "openai", baseURL, apiKey: "test-key", models: [model] },
104+
];
105+
const source = buildInferenceSourceForRef(
106+
{ provider: "openai", model },
107+
{ sessionId: "sess-1", catalog: entryCatalog },
108+
undefined,
109+
);
110+
// Mirror the harness: it resolves the adapter with source.quirks.
111+
const adapter = createOpenAICompatibleAdapter(
112+
source as unknown as Parameters<typeof createOpenAICompatibleAdapter>[0],
113+
source?.quirks,
114+
);
115+
const messages = [
116+
{ role: "user", content: [{ type: "text", text: "hi" }] },
117+
] as unknown as ConversationTurn[];
118+
const built = adapter.buildRequest(messages, model, {
119+
maxTokens: source?.defaults?.maxTokens,
120+
} as InferenceOptions);
121+
return JSON.parse(built.body) as Record<string, unknown>;
122+
}
123+
124+
test("shipped preset declares an explicit per-model requirement", () => {
125+
expect(presetModels.length).toBeGreaterThan(0);
126+
expect(openaiApi?.maxCompletionTokensModels?.length).toBeGreaterThan(0);
127+
for (const model of openaiApi?.maxCompletionTokensModels ?? []) {
128+
expect(presetModels).toContain(model);
129+
}
130+
});
131+
132+
test("every preset model has an explicit quirk decision", () => {
133+
const flagged = new Set(openaiApi?.maxCompletionTokensModels ?? []);
134+
// Explicit max_tokens decision: non-reasoning preset models stay on
135+
// max_tokens. Adding a preset model requires a decision here AND in the
136+
// preset's maxCompletionTokensModels — the union below fails loudly
137+
// otherwise instead of silently sending max_tokens.
138+
const explicitMaxTokensModels = new Set(["gpt-4.1"]);
139+
expect([...flagged, ...explicitMaxTokensModels].sort()).toEqual(
140+
[...new Set(presetModels)].sort(),
141+
);
142+
expect([...flagged].filter((m) => explicitMaxTokensModels.has(m))).toEqual(
143+
[],
144+
);
145+
});
146+
147+
test("reasoning preset models emit max_completion_tokens, never max_tokens", () => {
148+
for (const model of openaiApi?.maxCompletionTokensModels ?? []) {
149+
const body = wireBody(model, presetBaseURL);
150+
expect(body["max_completion_tokens"]).toBe(SOURCE_MAX_TOKENS);
151+
expect("max_tokens" in body).toBe(false);
152+
}
153+
});
154+
155+
test("non-reasoning preset models keep max_tokens", () => {
156+
const declared = new Set(openaiApi?.maxCompletionTokensModels ?? []);
157+
const rest = presetModels.filter((m) => !declared.has(m));
158+
expect(rest.length).toBeGreaterThan(0);
159+
for (const model of rest) {
160+
const body = wireBody(model, presetBaseURL);
161+
expect(body["max_tokens"]).toBe(SOURCE_MAX_TOKENS);
162+
expect("max_completion_tokens" in body).toBe(false);
163+
}
164+
});
165+
166+
test("relay endpoint keeps max_tokens for every preset model", () => {
167+
for (const model of presetModels) {
168+
const body = wireBody(model, RELAY_BASE_URL);
169+
expect(body["max_tokens"]).toBe(SOURCE_MAX_TOKENS);
170+
expect("max_completion_tokens" in body).toBe(false);
171+
}
172+
});
173+
});

src/config/inference-sources.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
buildXaiSource,
1010
type ProviderCatalogEntry,
1111
} from "./index.js";
12-
import type { Settings } from "./settings.js";
12+
import {
13+
OPENAI_API_BASE_URL,
14+
firstClassProviderById,
15+
} from "../../packages/first-class-providers/src/index.js";
16+
import { normalizeOpenAICompatibleBaseURL, type Settings } from "./settings.js";
1317
import {
1418
resolveSessionEffort,
1519
type ReasoningEffort,
@@ -36,6 +40,34 @@ function catalogEntry(
3640
return catalog.find((e) => e.name === provider);
3741
}
3842

43+
// First-party OpenAI reasoning models reject `max_tokens` and require
44+
// `max_completion_tokens`. The requirement is declared per model on the
45+
// first-class OpenAI API-key path's `maxCompletionTokensModels` field — never
46+
// inferred from name prefixes — and read here through that entry, so the
47+
// entry stays the single source of truth. The quirk attaches to the source
48+
// actually in use: it follows the first-party endpoint, so relays serving
49+
// the same model names through the same adapter keep `max_tokens`.
50+
function openAIAPIPathMaxCompletionTokensModels(): readonly string[] {
51+
return (
52+
firstClassProviderById("openai")?.paths?.find((p) => p.id === "api")
53+
?.maxCompletionTokensModels ?? []
54+
);
55+
}
56+
57+
function openAISourceQuirks(
58+
baseURL: string,
59+
model: string,
60+
): Record<string, unknown> | undefined {
61+
const normalized = normalizeOpenAICompatibleBaseURL(baseURL);
62+
if (normalized !== normalizeOpenAICompatibleBaseURL(OPENAI_API_BASE_URL)) {
63+
return undefined;
64+
}
65+
if (!openAIAPIPathMaxCompletionTokensModels().includes(model)) {
66+
return undefined;
67+
}
68+
return { maxTokensField: "max_completion_tokens" };
69+
}
70+
3971
export function buildInferenceSourceForRef(
4072
ref: ProviderRef,
4173
ctx: BuildSourceContext,
@@ -124,6 +156,7 @@ export function buildInferenceSourceForRef(
124156
});
125157
}
126158

159+
const quirks = openAISourceQuirks(baseURL, ref.model);
127160
return buildOpenAISource({
128161
id: ref.provider,
129162
baseURL,
@@ -134,6 +167,7 @@ export function buildInferenceSourceForRef(
134167
: {}),
135168
model: ref.model,
136169
...(effort !== undefined ? { reasoningEffort: effort } : {}),
170+
...(quirks !== undefined ? { quirks } : {}),
137171
});
138172
}
139173

0 commit comments

Comments
 (0)