Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/first-class-providers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export {
FIRST_CLASS_PROVIDERS,
OPENAI_API_BASE_URL,
OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
connectListProviders,
firstClassPathAsProvider,
firstClassProviderById,
Expand Down
16 changes: 16 additions & 0 deletions packages/first-class-providers/src/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 24 additions & 1 deletion packages/first-class-providers/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ 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`. 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",
"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.
Expand All @@ -38,11 +57,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,
},
],
},
Expand Down Expand Up @@ -167,6 +187,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
Expand Down
14 changes: 14 additions & 0 deletions packages/first-class-providers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[];
}
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export function buildOpenAISource(fields: {
apiKey?: string;
model: string;
reasoningEffort?: ReasoningEffort;
quirks?: Record<string, unknown>;
}): InferenceSource {
const overrides =
fields.reasoningEffort !== undefined
Expand All @@ -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 } : {}),
};
}

Expand Down
86 changes: 86 additions & 0 deletions src/config/inference-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -85,3 +86,88 @@ 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<string, unknown> {
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<typeof createOpenAICompatibleAdapter>[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<string, unknown>;
}

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("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);
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);
}
});
});
36 changes: 35 additions & 1 deletion src/config/inference-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
buildXaiSource,
type ProviderCatalogEntry,
} from "./index.js";
import type { Settings } from "./settings.js";
import {
OPENAI_API_BASE_URL,
firstClassProviderById,
} from "../../packages/first-class-providers/src/index.js";
import { normalizeOpenAICompatibleBaseURL, type Settings } from "./settings.js";
import {
resolveSessionEffort,
type ReasoningEffort,
Expand All @@ -36,6 +40,34 @@ 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 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,
): Record<string, unknown> | undefined {
const normalized = normalizeOpenAICompatibleBaseURL(baseURL);
if (normalized !== normalizeOpenAICompatibleBaseURL(OPENAI_API_BASE_URL)) {
return undefined;
}
if (!openAIAPIPathMaxCompletionTokensModels().includes(model)) {
return undefined;
}
return { maxTokensField: "max_completion_tokens" };
}

export function buildInferenceSourceForRef(
ref: ProviderRef,
ctx: BuildSourceContext,
Expand Down Expand Up @@ -124,6 +156,7 @@ export function buildInferenceSourceForRef(
});
}

const quirks = openAISourceQuirks(baseURL, ref.model);
return buildOpenAISource({
id: ref.provider,
baseURL,
Expand All @@ -134,6 +167,7 @@ export function buildInferenceSourceForRef(
: {}),
model: ref.model,
...(effort !== undefined ? { reasoningEffort: effort } : {}),
...(quirks !== undefined ? { quirks } : {}),
});
}

Expand Down
Loading