diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 2a15b7bc9..446727a9f 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -104,7 +104,7 @@ import { transformMessages } from "./transform-messages"; export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; reasoningSummary?: "auto" | "concise" | "detailed" | null; - /** `reasoning.context` replay scope; defaults to `all_turns` for every Codex request when unset. */ + /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; include?: string[]; diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index b627fcbfa..77882a60e 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -1,4 +1,5 @@ import type { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { supportsAllTurnsReasoningContext } from "@oh-my-pi/pi-catalog/identity"; import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking"; import type { Api, Model } from "../../types"; @@ -14,7 +15,7 @@ export interface ReasoningConfig { export interface CodexRequestOptions { reasoningEffort?: ReasoningConfig["effort"]; reasoningSummary?: ReasoningConfig["summary"] | null; - /** Explicit `reasoning.context` override; defaults to `all_turns` for every Codex request when unset. */ + /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; include?: string[]; @@ -254,9 +255,21 @@ export async function transformRequestBody( ...body.reasoning, ...reasoningConfig, }; - // Default reasoning replay to `all_turns` for every Codex request, - // mirroring codex-rs; an explicit `reasoningContext` overrides it. - body.reasoning.context = options.reasoningContext ?? "all_turns"; + // Default reasoning replay to `all_turns`, mirroring codex-rs; an + // explicit `reasoningContext` overrides the default. The `all_turns` + // value is only accepted from gpt-5.4 onward — earlier Codex ids + // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with + // "Unsupported value: 'all_turns' is not supported with this model". + // For those, drop `context` so the server applies its `current_turn` + // default. The version gate is authoritative: even an explicit + // `all_turns` override is suppressed on unsupported models, while + // `current_turn`/`auto` (universally supported) always pass through. + const context = options.reasoningContext ?? "all_turns"; + if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { + delete body.reasoning.context; + } else { + body.reasoning.context = context; + } } else { delete body.reasoning; } diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 575c3cdd3..c6aee594f 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -83,21 +83,21 @@ function createCodexFetchMock(sse: string, onRequest: (captured: CapturedCodexRe } describe("openai-codex reasoning.context", () => { - it("forwards an explicit reasoning.context and defaults to all_turns", async () => { - const model = createCodexModel("gpt-5.1-codex"); + it("defaults to all_turns on gpt-5.4+ models and forwards explicit overrides", async () => { + const model = createCodexModel("gpt-5.4"); + + const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); + expect(defaulted.reasoning?.context).toBe("all_turns"); const explicit = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningContext: "current_turn", }); expect(explicit.reasoning?.context).toBe("current_turn"); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.context).toBe("all_turns"); }); - it("defaults reasoning.context to all_turns under Responses Lite unless overridden", async () => { - const model = createCodexModel("gpt-5.1-codex"); + it("keeps the all_turns default for the lite transport on supported models", async () => { + const model = createCodexModel("gpt-5.5"); const lite = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", @@ -112,6 +112,39 @@ describe("openai-codex reasoning.context", () => { }); expect(overridden.reasoning?.context).toBe("auto"); }); + + // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `all_turns` + // ("Unsupported value: 'all_turns' is not supported with this model"). + it.each([ + "gpt-5.1-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + ])("omits the all_turns default for pre-5.4 model %s", async modelId => { + const model = createCodexModel(modelId); + + const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); + expect(defaulted.reasoning).toBeDefined(); + expect(defaulted.reasoning?.context).toBeUndefined(); + expect("context" in (defaulted.reasoning ?? {})).toBe(false); + + // A supported override (current_turn/auto) is still honored. + const overridden = await transformRequestBody({ model: model.id }, model, { + reasoningEffort: "medium", + reasoningContext: "current_turn", + }); + expect(overridden.reasoning?.context).toBe("current_turn"); + }); + + it("suppresses an explicit all_turns override on a pre-5.4 model", async () => { + const model = createCodexModel("gpt-5.3-codex-spark"); + + const forced = await transformRequestBody({ model: model.id }, model, { + reasoningEffort: "medium", + reasoningContext: "all_turns", + }); + expect(forced.reasoning).toBeDefined(); + expect(forced.reasoning?.context).toBeUndefined(); + }); }); describe("openai-codex Responses Lite input shaping", () => { diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index c48d91f35..031a77c31 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -13,6 +13,7 @@ import { parseAnthropicModel, parseGlmModel, parseKnownModel, + parseOpenAIModel, semverGte, } from "./classify"; @@ -121,6 +122,22 @@ export const isOpenAIModelId = memo((modelId: string): boolean => { return /(^|\/)(gpt|o1|o3|o4)[-.]/i.test(modelId) || modelId.toLowerCase().includes("openai/"); }); +/** + * OpenAI Codex models that honor `reasoning.context: "all_turns"` (full + * cross-turn reasoning replay). The `reasoning.context` field itself exists for + * the whole gpt-5/o-series family, but the `all_turns` value is only accepted + * from gpt-5.4 onward; earlier ids (`gpt-5.1-codex`, `gpt-5.3-codex`, and + * `gpt-5.3-codex-spark`) reject it with + * `Unsupported value: 'all_turns' is not supported with this model`. Version + * floor (not an allowlist) so 5.6/6.x inherit support automatically. Callers + * fall back to omitting `context`, letting the server default to `current_turn`. + */ +export const supportsAllTurnsReasoningContext = memo((modelId: string): boolean => { + const parsed = parseOpenAIModel(bareModelId(modelId)); + if (!parsed) return false; + return semverGte(parsed.version, "5.4"); +}); + /** * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` / * `-turbo` lines. Excludes the vision (`
v`) shape, the non-reasoning