diff --git a/CHANGELOG.md b/CHANGELOG.md index dafcf5c8a67..990c44f8a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -532,6 +532,7 @@ Docs: https://docs.openclaw.ai ### Changes - Docs/Contributing: require before/after screenshots for UI or visual PRs in the pre-PR checklist. (#32206) Thanks @hydro13. +- Models/OpenAI forward compat: add support for `openai/gpt-5.4`, `openai/gpt-5.4-pro`, and `openai-codex/gpt-5.4`, including direct OpenAI Responses `serviceTier` passthrough safeguards for valid values. (#36590) Thanks @dorukardahan. ### Fixes diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 58710d88ee7..aa38fbf52c5 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -41,15 +41,16 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `openai` - Auth: `OPENAI_API_KEY` - Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override) -- Example model: `openai/gpt-5.1-codex` +- Example models: `openai/gpt-5.4`, `openai/gpt-5.4-pro` - CLI: `openclaw onboard --auth-choice openai-api-key` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) +- OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier` ```json5 { - agents: { defaults: { model: { primary: "openai/gpt-5.1-codex" } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, } ``` @@ -73,7 +74,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `openai-codex` - Auth: OAuth (ChatGPT) -- Example model: `openai-codex/gpt-5.3-codex` +- Example model: `openai-codex/gpt-5.4` - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) @@ -81,7 +82,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** ```json5 { - agents: { defaults: { model: { primary: "openai-codex/gpt-5.3-codex" } } }, + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, } ``` diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 1c96302462a..fe3006bcd1a 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -31,7 +31,7 @@ openclaw agent --message "hi" --model claude-cli/opus-4.6 Codex CLI also works out of the box: ```bash -openclaw agent --message "hi" --model codex-cli/gpt-5.3-codex +openclaw agent --message "hi" --model codex-cli/gpt-5.4 ``` If your gateway runs under launchd/systemd and PATH is minimal, add just the diff --git a/docs/help/faq.md b/docs/help/faq.md index d7737bc31a5..2ae55caf0c3 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -767,7 +767,7 @@ Yes - via pi-ai's **Amazon Bedrock (Converse)** provider with **manual config**. ### How does Codex auth work -OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizard can run the OAuth flow and will set the default model to `openai-codex/gpt-5.3-codex` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard). +OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizard can run the OAuth flow and will set the default model to `openai-codex/gpt-5.4` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard). ### Do you support OpenAI subscription auth Codex OAuth @@ -2156,8 +2156,8 @@ Use `/model status` to confirm which auth profile is active. Yes. Set one as default and switch as needed: -- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.3-codex` for coding. -- **Default + switch:** set `agents.defaults.model.primary` to `openai/gpt-5.2`, then switch to `openai-codex/gpt-5.3-codex` when coding (or the other way around). +- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model openai-codex/gpt-5.4` for coding with Codex OAuth. +- **Default + switch:** set `agents.defaults.model.primary` to `openai/gpt-5.2`, then switch to `openai-codex/gpt-5.4` when coding (or the other way around). - **Sub-agents:** route coding tasks to sub-agents with a different default model. See [Models](/concepts/models) and [Slash commands](/tools/slash-commands). diff --git a/docs/help/testing.md b/docs/help/testing.md index efb889f1950..ba248dd5f88 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -222,7 +222,7 @@ OPENCLAW_LIVE_SETUP_TOKEN=1 OPENCLAW_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-to - Args: `["-p","--output-format","json","--permission-mode","bypassPermissions"]` - Overrides (optional): - `OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-6"` - - `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.3-codex"` + - `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.4"` - `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/claude"` - `OPENCLAW_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'` - `OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV='["ANTHROPIC_API_KEY","ANTHROPIC_API_KEY_OLD"]'` @@ -275,7 +275,7 @@ There is no fixed “CI model list” (live is opt-in), but these are the **reco This is the “common models” run we expect to keep working: - OpenAI (non-Codex): `openai/gpt-5.2` (optional: `openai/gpt-5.1`) -- OpenAI Codex: `openai-codex/gpt-5.3-codex` (optional: `openai-codex/gpt-5.3-codex-codex`) +- OpenAI Codex: `openai-codex/gpt-5.4` - Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`) - Google (Gemini API): `google/gemini-3-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models) - Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash` @@ -283,7 +283,7 @@ This is the “common models” run we expect to keep working: - MiniMax: `minimax/minimax-m2.5` Run gateway smoke with tools + image: -`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts` +`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.4,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts` ### Baseline: tool calling (Read + optional Exec) diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 378381b2454..4683f061546 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -30,10 +30,13 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY" ```json5 { env: { OPENAI_API_KEY: "sk-..." }, - agents: { defaults: { model: { primary: "openai/gpt-5.2" } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, } ``` +OpenAI's current API model docs list `gpt-5.4` and `gpt-5.4-pro` for direct +OpenAI API usage. OpenClaw forwards both through the `openai/*` Responses path. + ## Option B: OpenAI Code (Codex) subscription **Best for:** using ChatGPT/Codex subscription access instead of an API key. @@ -53,10 +56,13 @@ openclaw models auth login --provider openai-codex ```json5 { - agents: { defaults: { model: { primary: "openai-codex/gpt-5.3-codex" } } }, + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, } ``` +OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw +maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage. + ### Transport default OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and @@ -81,9 +87,9 @@ Related OpenAI docs: { agents: { defaults: { - model: { primary: "openai-codex/gpt-5.3-codex" }, + model: { primary: "openai-codex/gpt-5.4" }, models: { - "openai-codex/gpt-5.3-codex": { + "openai-codex/gpt-5.4": { params: { transport: "auto", }, @@ -106,7 +112,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for agents: { defaults: { models: { - "openai/gpt-5.2": { + "openai/gpt-5.4": { params: { openaiWsWarmup: false, }, @@ -124,7 +130,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for agents: { defaults: { models: { - "openai/gpt-5.2": { + "openai/gpt-5.4": { params: { openaiWsWarmup: true, }, @@ -135,6 +141,30 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for } ``` +### OpenAI priority processing + +OpenAI's API exposes priority processing via `service_tier=priority`. In +OpenClaw, set `agents.defaults.models["openai/"].params.serviceTier` to +pass that field through on direct `openai/*` Responses requests. + +```json5 +{ + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, +} +``` + +Supported values are `auto`, `default`, `flex`, and `priority`. + ### OpenAI Responses server-side compaction For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with @@ -157,7 +187,7 @@ Responses models (for example Azure OpenAI Responses): agents: { defaults: { models: { - "azure-openai-responses/gpt-5.2": { + "azure-openai-responses/gpt-5.4": { params: { responsesServerCompaction: true, }, @@ -175,7 +205,7 @@ Responses models (for example Azure OpenAI Responses): agents: { defaults: { models: { - "openai/gpt-5.2": { + "openai/gpt-5.4": { params: { responsesServerCompaction: true, responsesCompactThreshold: 120000, @@ -194,7 +224,7 @@ Responses models (for example Azure OpenAI Responses): agents: { defaults: { models: { - "openai/gpt-5.2": { + "openai/gpt-5.4": { params: { responsesServerCompaction: false, }, diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index df2149897a5..f9ff309be54 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -143,7 +143,7 @@ What you set: Browser flow; paste `code#state`. - Sets `agents.defaults.model` to `openai-codex/gpt-5.3-codex` when model is unset or `openai/*`. + Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`. diff --git a/docs/tools/llm-task.md b/docs/tools/llm-task.md index 16ae39e5e29..e6f574d078e 100644 --- a/docs/tools/llm-task.md +++ b/docs/tools/llm-task.md @@ -53,9 +53,9 @@ without writing custom OpenClaw code for each workflow. "enabled": true, "config": { "defaultProvider": "openai-codex", - "defaultModel": "gpt-5.2", + "defaultModel": "gpt-5.4", "defaultAuthProfileId": "main", - "allowedModels": ["openai-codex/gpt-5.3-codex"], + "allowedModels": ["openai-codex/gpt-5.4"], "maxTokens": 800, "timeoutMs": 30000 } diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index 398f7fdb80e..03de7d772cc 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -10,8 +10,9 @@ const ANTHROPIC_PREFIXES = [ "claude-sonnet-4-5", "claude-haiku-4-5", ]; -const OPENAI_MODELS = ["gpt-5.2", "gpt-5.0"]; +const OPENAI_MODELS = ["gpt-5.4", "gpt-5.2", "gpt-5.0"]; const CODEX_MODELS = [ + "gpt-5.4", "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index d5747bd7353..e2d9d09ab12 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -157,7 +157,7 @@ describe("getApiKeyForModel", () => { } catch (err) { error = err; } - expect(String(error)).toContain("openai-codex/gpt-5.3-codex"); + expect(String(error)).toContain("openai-codex/gpt-5.4"); }, ); } finally { diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 4867ed6d3bc..734cd7b2666 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -250,7 +250,7 @@ export async function resolveApiKeyForProvider(params: { const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; if (hasCodex) { throw new Error( - 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.3-codex (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.1-codex.', + 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.', ); } } diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index b7a72585337..5eec49f49b8 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -114,6 +114,59 @@ describe("loadModelCatalog", () => { expect(spark?.reasoning).toBe(true); }); + it("adds gpt-5.4 forward-compat catalog entries when template models exist", async () => { + mockPiDiscoveryModels([ + { + id: "gpt-5.2", + provider: "openai", + name: "GPT-5.2", + reasoning: true, + contextWindow: 1_050_000, + input: ["text", "image"], + }, + { + id: "gpt-5.2-pro", + provider: "openai", + name: "GPT-5.2 Pro", + reasoning: true, + contextWindow: 1_050_000, + input: ["text", "image"], + }, + { + id: "gpt-5.3-codex", + provider: "openai-codex", + name: "GPT-5.3 Codex", + reasoning: true, + contextWindow: 272000, + input: ["text", "image"], + }, + ]); + + const result = await loadModelCatalog({ config: {} as OpenClawConfig }); + + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4", + name: "gpt-5.4", + }), + ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4-pro", + name: "gpt-5.4-pro", + }), + ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai-codex", + id: "gpt-5.4", + name: "gpt-5.4", + }), + ); + }); + it("merges configured models for opted-in non-pi-native providers", async () => { mockSingleOpenAiCatalogModel(); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index a910a10a9f1..06423b0604b 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -33,33 +33,67 @@ const defaultImportPiSdk = () => import("./pi-model-discovery.js"); let importPiSdk = defaultImportPiSdk; const CODEX_PROVIDER = "openai-codex"; +const OPENAI_PROVIDER = "openai"; +const OPENAI_GPT54_MODEL_ID = "gpt-5.4"; +const OPENAI_GPT54_PRO_MODEL_ID = "gpt-5.4-pro"; const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex"; const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const OPENAI_CODEX_GPT54_MODEL_ID = "gpt-5.4"; const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); -function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void { - const hasSpark = models.some( - (entry) => - entry.provider === CODEX_PROVIDER && - entry.id.toLowerCase() === OPENAI_CODEX_GPT53_SPARK_MODEL_ID, - ); - if (hasSpark) { - return; - } +type SyntheticCatalogFallback = { + provider: string; + id: string; + templateIds: readonly string[]; +}; - const baseModel = models.find( - (entry) => - entry.provider === CODEX_PROVIDER && entry.id.toLowerCase() === OPENAI_CODEX_GPT53_MODEL_ID, - ); - if (!baseModel) { - return; - } - - models.push({ - ...baseModel, +const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [ + { + provider: OPENAI_PROVIDER, + id: OPENAI_GPT54_MODEL_ID, + templateIds: ["gpt-5.2"], + }, + { + provider: OPENAI_PROVIDER, + id: OPENAI_GPT54_PRO_MODEL_ID, + templateIds: ["gpt-5.2-pro", "gpt-5.2"], + }, + { + provider: CODEX_PROVIDER, + id: OPENAI_CODEX_GPT54_MODEL_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }, + { + provider: CODEX_PROVIDER, id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID, - name: OPENAI_CODEX_GPT53_SPARK_MODEL_ID, - }); + templateIds: [OPENAI_CODEX_GPT53_MODEL_ID], + }, +] as const; + +function applySyntheticCatalogFallbacks(models: ModelCatalogEntry[]): void { + const findCatalogEntry = (provider: string, id: string) => + models.find( + (entry) => + entry.provider.toLowerCase() === provider.toLowerCase() && + entry.id.toLowerCase() === id.toLowerCase(), + ); + + for (const fallback of SYNTHETIC_CATALOG_FALLBACKS) { + if (findCatalogEntry(fallback.provider, fallback.id)) { + continue; + } + const template = fallback.templateIds + .map((templateId) => findCatalogEntry(fallback.provider, templateId)) + .find((entry) => entry !== undefined); + if (!template) { + continue; + } + models.push({ + ...template, + id: fallback.id, + name: fallback.id, + }); + } } function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { @@ -218,7 +252,7 @@ export async function loadModelCatalog(params?: { models.push({ id, name, provider, contextWindow, reasoning, input }); } mergeConfiguredOptInProviderModels({ config: cfg, models }); - applyOpenAICodexSparkFallback(models); + applySyntheticCatalogFallbacks(models); if (models.length === 0) { // If we found nothing, don't cache this result so we can try again. diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 178552368ae..2fd5df0883c 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -37,6 +37,36 @@ function createTemplateModel(provider: string, id: string): Model { } as Model; } +function createOpenAITemplateModel(id: string): Model { + return { + id, + name: id, + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 32_768, + } as Model; +} + +function createOpenAICodexTemplateModel(id: string): Model { + return { + id, + name: id, + provider: "openai-codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text", "image"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, + } as Model; +} + function createRegistry(models: Record>): ModelRegistry { return { find(provider: string, modelId: string) { @@ -235,6 +265,12 @@ describe("normalizeModelCompat", () => { }); describe("isModernModelRef", () => { + it("includes OpenAI gpt-5.4 variants in modern selection", () => { + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(true); + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-pro" })).toBe(true); + expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4" })).toBe(true); + }); + it("excludes opencode minimax variants from modern selection", () => { expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); @@ -247,6 +283,57 @@ describe("isModernModelRef", () => { }); describe("resolveForwardCompatModel", () => { + it("resolves openai gpt-5.4 via gpt-5.2 template", () => { + const registry = createRegistry({ + "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), + }); + const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); + expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); + expect(model?.api).toBe("openai-responses"); + expect(model?.baseUrl).toBe("https://api.openai.com/v1"); + expect(model?.contextWindow).toBe(1_050_000); + expect(model?.maxTokens).toBe(128_000); + }); + + it("resolves openai gpt-5.4 without templates using normalized fallback defaults", () => { + const registry = createRegistry({}); + + const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); + + expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); + expect(model?.api).toBe("openai-responses"); + expect(model?.baseUrl).toBe("https://api.openai.com/v1"); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.reasoning).toBe(true); + expect(model?.contextWindow).toBe(1_050_000); + expect(model?.maxTokens).toBe(128_000); + expect(model?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); + + it("resolves openai gpt-5.4-pro via template fallback", () => { + const registry = createRegistry({ + "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), + }); + const model = resolveForwardCompatModel("openai", "gpt-5.4-pro", registry); + expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4-pro" }); + expect(model?.api).toBe("openai-responses"); + expect(model?.baseUrl).toBe("https://api.openai.com/v1"); + expect(model?.contextWindow).toBe(1_050_000); + expect(model?.maxTokens).toBe(128_000); + }); + + it("resolves openai-codex gpt-5.4 via codex template fallback", () => { + const registry = createRegistry({ + "openai-codex/gpt-5.2-codex": createOpenAICodexTemplateModel("gpt-5.2-codex"), + }); + const model = resolveForwardCompatModel("openai-codex", "gpt-5.4", registry); + expectResolvedForwardCompat(model, { provider: "openai-codex", id: "gpt-5.4" }); + expect(model?.api).toBe("openai-codex-responses"); + expect(model?.baseUrl).toBe("https://chatgpt.com/backend-api"); + expect(model?.contextWindow).toBe(272_000); + expect(model?.maxTokens).toBe(128_000); + }); + it("resolves anthropic opus 4.6 via 4.5 template", () => { const registry = createRegistry({ "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index d99dc8ca4b3..d19ab3d1a3f 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -4,6 +4,15 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; import { normalizeModelCompat } from "./model-compat.js"; import { normalizeProviderId } from "./model-selection.js"; +const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; +const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; + +const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const; const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; @@ -25,6 +34,58 @@ const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; +function resolveOpenAIGpt54ForwardCompatModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + const normalizedProvider = normalizeProviderId(provider); + if (normalizedProvider !== "openai") { + return undefined; + } + + const trimmedModelId = modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + let templateIds: readonly string[]; + if (lower === OPENAI_GPT_54_MODEL_ID) { + templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { + templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + } else { + return undefined; + } + + return ( + cloneFirstTemplateModel({ + normalizedProvider, + trimmedModelId, + templateIds: [...templateIds], + modelRegistry, + patch: { + api: "openai-responses", + provider: normalizedProvider, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }, + }) ?? + normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-responses", + provider: normalizedProvider, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + } as Model) + ); +} + function cloneFirstTemplateModel(params: { normalizedProvider: string; trimmedModelId: string; @@ -48,23 +109,35 @@ function cloneFirstTemplateModel(params: { return undefined; } +const CODEX_GPT54_ELIGIBLE_PROVIDERS = new Set(["openai-codex"]); const CODEX_GPT53_ELIGIBLE_PROVIDERS = new Set(["openai-codex", "github-copilot"]); -function resolveOpenAICodexGpt53FallbackModel( +function resolveOpenAICodexForwardCompatModel( provider: string, modelId: string, modelRegistry: ModelRegistry, ): Model | undefined { const normalizedProvider = normalizeProviderId(provider); const trimmedModelId = modelId.trim(); - if (!CODEX_GPT53_ELIGIBLE_PROVIDERS.has(normalizedProvider)) { - return undefined; - } - if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) { + const lower = trimmedModelId.toLowerCase(); + + let templateIds: readonly string[]; + let eligibleProviders: Set; + if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { + templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS; + eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS; + } else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) { + templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS; + eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS; + } else { return undefined; } - for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) { + if (!eligibleProviders.has(normalizedProvider)) { + return undefined; + } + + for (const templateId of templateIds) { const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; if (!template) { continue; @@ -248,7 +321,8 @@ export function resolveForwardCompatModel( modelRegistry: ModelRegistry, ): Model | undefined { return ( - resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ?? + resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ?? + resolveOpenAICodexForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 7eed9905914..574d3069741 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1,7 +1,8 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner.js"; +import { log } from "./pi-embedded-runner/logger.js"; describe("resolveExtraParams", () => { it("returns undefined with no model config", () => { @@ -755,6 +756,36 @@ describe("applyExtraParamsToAgent", () => { expect(calls[0]?.transport).toBe("websocket"); }); + it("passes configured websocket transport through stream options for openai-codex gpt-5.4", () => { + const { calls, agent } = createOptionsCaptureAgent(); + const cfg = { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.4": { + params: { + transport: "websocket", + }, + }, + }, + }, + }, + }; + + applyExtraParamsToAgent(agent, cfg, "openai-codex", "gpt-5.4"); + + const model = { + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.4", + } as Model<"openai-codex-responses">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(calls).toHaveLength(1); + expect(calls[0]?.transport).toBe("websocket"); + }); + it("defaults Codex transport to auto (WebSocket-first)", () => { const { calls, agent } = createOptionsCaptureAgent(); @@ -1155,6 +1186,179 @@ describe("applyExtraParamsToAgent", () => { expect(payload.store).toBe(true); }); + it("injects configured OpenAI service_tier into Responses payloads", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + }); + expect(payload.service_tier).toBe("priority"); + }); + + it("preserves caller-provided service_tier values", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + service_tier: "default", + }, + }); + expect(payload.service_tier).toBe("default"); + }); + + it("does not inject service_tier for non-openai providers", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "azure-openai-responses", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "azure-openai-responses/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "azure-openai-responses", + id: "gpt-5.4", + baseUrl: "https://example.openai.azure.com/openai/v1", + } as unknown as Model<"openai-responses">, + }); + expect(payload).not.toHaveProperty("service_tier"); + }); + + it("does not inject service_tier for proxied openai base URLs", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://proxy.example.com/v1", + } as unknown as Model<"openai-responses">, + }); + expect(payload).not.toHaveProperty("service_tier"); + }); + + it("does not inject service_tier for openai provider routed to Azure base URLs", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "priority", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://example.openai.azure.com/openai/v1", + } as unknown as Model<"openai-responses">, + }); + expect(payload).not.toHaveProperty("service_tier"); + }); + + it("warns and skips service_tier injection for invalid serviceTier values", () => { + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); + try { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "invalid", + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + }); + + expect(payload).not.toHaveProperty("service_tier"); + expect(warnSpy).toHaveBeenCalledWith("ignoring invalid OpenAI service tier param: invalid"); + } finally { + warnSpy.mockRestore(); + } + }); + it("does not force store for OpenAI Responses routed through non-OpenAI base URLs", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "openai", diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 33b8ab2c6ac..9f8380184f3 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -44,6 +44,7 @@ export function resolveExtraParams(params: { } type CacheRetention = "none" | "short" | "long"; +type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; type CacheRetentionStreamOptions = Partial & { cacheRetention?: CacheRetention; openaiWsWarmup?: boolean; @@ -208,6 +209,18 @@ function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean { } } +function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return false; + } + + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.openai.com"; + } catch { + return baseUrl.toLowerCase().includes("api.openai.com"); + } +} + function shouldForceResponsesStore(model: { api?: unknown; provider?: unknown; @@ -314,6 +327,63 @@ function createOpenAIResponsesContextManagementWrapper( }; } +function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if ( + normalized === "auto" || + normalized === "default" || + normalized === "flex" || + normalized === "priority" + ) { + return normalized; + } + return undefined; +} + +function resolveOpenAIServiceTier( + extraParams: Record | undefined, +): OpenAIServiceTier | undefined { + const raw = extraParams?.serviceTier ?? extraParams?.service_tier; + const normalized = normalizeOpenAIServiceTier(raw); + if (raw !== undefined && normalized === undefined) { + const rawSummary = typeof raw === "string" ? raw : typeof raw; + log.warn(`ignoring invalid OpenAI service tier param: ${rawSummary}`); + } + return normalized; +} + +function createOpenAIServiceTierWrapper( + baseStreamFn: StreamFn | undefined, + serviceTier: OpenAIServiceTier, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if ( + model.api !== "openai-responses" || + model.provider !== "openai" || + !isOpenAIPublicApiBaseUrl(model.baseUrl) + ) { + return underlying(model, context, options); + } + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + const payloadObj = payload as Record; + if (payloadObj.service_tier === undefined) { + payloadObj.service_tier = serviceTier; + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => @@ -1073,6 +1143,12 @@ export function applyExtraParamsToAgent( // upstream model-ID heuristics for Gemini 3.1 variants. agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); + const openAIServiceTier = resolveOpenAIServiceTier(merged); + if (openAIServiceTier) { + log.debug(`applying OpenAI service_tier=${openAIServiceTier} for ${provider}/${modelId}`); + agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier); + } + // Work around upstream pi-ai hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI Responses models and auto-enable // server-side compaction for compatible OpenAI Responses payloads. diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 07b96a1cae9..56fd4654e91 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -49,6 +49,14 @@ describe("pi embedded model e2e smoke", () => { expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex")); }); + it("builds an openai-codex forward-compat fallback for gpt-5.4", () => { + mockOpenAICodexTemplateModel(); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4")); + }); + it("keeps unknown-model errors for non-forward-compat IDs", () => { const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent"); expect(result.model).toBeUndefined(); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d473a4966b1..d23b68d32b6 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -23,7 +23,7 @@ function buildForwardCompatTemplate(params: { id: string; name: string; provider: string; - api: "anthropic-messages" | "google-gemini-cli" | "openai-completions"; + api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses"; baseUrl: string; input?: readonly ["text"] | readonly ["text", "image"]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; @@ -399,6 +399,53 @@ describe("resolveModel", () => { expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex")); }); + it("builds an openai-codex fallback for gpt-5.4", () => { + mockOpenAICodexTemplateModel(); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4")); + }); + + it("applies provider overrides to openai gpt-5.4 forward-compat models", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5.2", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5.2", + name: "GPT-5.2", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }), + }); + + const cfg = { + models: { + providers: { + openai: { + baseUrl: "https://proxy.example.com/v1", + headers: { "X-Proxy-Auth": "token-123" }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModel("openai", "gpt-5.4", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://proxy.example.com/v1", + }); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "X-Proxy-Auth": "token-123", + }); + }); + it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { mockDiscoveredModel({ provider: "anthropic", diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index eab1b732639..b846895d029 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -99,6 +99,96 @@ export function buildInlineProviderModels( }); } +export function resolveModelWithRegistry(params: { + provider: string; + modelId: string; + modelRegistry: ModelRegistry; + cfg?: OpenClawConfig; +}): Model | undefined { + const { provider, modelId, modelRegistry, cfg } = params; + const providerConfig = resolveConfiguredProviderConfig(cfg, provider); + const model = modelRegistry.find(provider, modelId) as Model | null; + + if (model) { + return normalizeModelCompat( + applyConfiguredProviderOverrides({ + discoveredModel: model, + providerConfig, + modelId, + }), + ); + } + + const providers = cfg?.models?.providers ?? {}; + const inlineModels = buildInlineProviderModels(providers); + const normalizedProvider = normalizeProviderId(provider); + const inlineMatch = inlineModels.find( + (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, + ); + if (inlineMatch) { + return normalizeModelCompat(inlineMatch as Model); + } + + // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. + // Otherwise, configured providers can default to a generic API and break specific transports. + const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); + if (forwardCompat) { + return normalizeModelCompat( + applyConfiguredProviderOverrides({ + discoveredModel: forwardCompat, + providerConfig, + modelId, + }), + ); + } + + // OpenRouter is a pass-through proxy - any model ID available on OpenRouter + // should work without being pre-registered in the local catalog. + if (normalizedProvider === "openrouter") { + return normalizeModelCompat({ + id: modelId, + name: modelId, + api: "openai-completions", + provider, + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts + maxTokens: 8192, + } as Model); + } + + const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + if (providerConfig || modelId.startsWith("mock-")) { + return normalizeModelCompat({ + id: modelId, + name: modelId, + api: providerConfig?.api ?? "openai-responses", + provider, + baseUrl: providerConfig?.baseUrl, + reasoning: configuredModel?.reasoning ?? false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: + configuredModel?.contextWindow ?? + providerConfig?.models?.[0]?.contextWindow ?? + DEFAULT_CONTEXT_TOKENS, + maxTokens: + configuredModel?.maxTokens ?? + providerConfig?.models?.[0]?.maxTokens ?? + DEFAULT_CONTEXT_TOKENS, + headers: + providerConfig?.headers || configuredModel?.headers + ? { ...providerConfig?.headers, ...configuredModel?.headers } + : undefined, + } as Model); + } + + return undefined; +} + export function resolveModel( provider: string, modelId: string, @@ -113,89 +203,13 @@ export function resolveModel( const resolvedAgentDir = agentDir ?? resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(resolvedAgentDir); const modelRegistry = discoverModels(authStorage, resolvedAgentDir); - const providerConfig = resolveConfiguredProviderConfig(cfg, provider); - const model = modelRegistry.find(provider, modelId) as Model | null; - - if (!model) { - const providers = cfg?.models?.providers ?? {}; - const inlineModels = buildInlineProviderModels(providers); - const normalizedProvider = normalizeProviderId(provider); - const inlineMatch = inlineModels.find( - (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, - ); - if (inlineMatch) { - const normalized = normalizeModelCompat(inlineMatch as Model); - return { - model: normalized, - authStorage, - modelRegistry, - }; - } - // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. - // Otherwise, configured providers can default to a generic API and break specific transports. - const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); - if (forwardCompat) { - return { model: forwardCompat, authStorage, modelRegistry }; - } - // OpenRouter is a pass-through proxy — any model ID available on OpenRouter - // should work without being pre-registered in the local catalog. - if (normalizedProvider === "openrouter") { - const fallbackModel: Model = normalizeModelCompat({ - id: modelId, - name: modelId, - api: "openai-completions", - provider, - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts - maxTokens: 8192, - } as Model); - return { model: fallbackModel, authStorage, modelRegistry }; - } - const providerCfg = providerConfig; - if (providerCfg || modelId.startsWith("mock-")) { - const configuredModel = providerCfg?.models?.find((candidate) => candidate.id === modelId); - const fallbackModel: Model = normalizeModelCompat({ - id: modelId, - name: modelId, - api: providerCfg?.api ?? "openai-responses", - provider, - baseUrl: providerCfg?.baseUrl, - reasoning: configuredModel?.reasoning ?? false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: - configuredModel?.contextWindow ?? - providerCfg?.models?.[0]?.contextWindow ?? - DEFAULT_CONTEXT_TOKENS, - maxTokens: - configuredModel?.maxTokens ?? - providerCfg?.models?.[0]?.maxTokens ?? - DEFAULT_CONTEXT_TOKENS, - headers: - providerCfg?.headers || configuredModel?.headers - ? { ...providerCfg?.headers, ...configuredModel?.headers } - : undefined, - } as Model); - return { model: fallbackModel, authStorage, modelRegistry }; - } - return { - error: buildUnknownModelError(provider, modelId), - authStorage, - modelRegistry, - }; + const model = resolveModelWithRegistry({ provider, modelId, modelRegistry, cfg }); + if (model) { + return { model, authStorage, modelRegistry }; } + return { - model: normalizeModelCompat( - applyConfiguredProviderOverrides({ - discoveredModel: model, - providerConfig, - modelId, - }), - ), + error: buildUnknownModelError(provider, modelId), authStorage, modelRegistry, }; diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index 913801e6dd6..f5cd484fba4 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -239,7 +239,7 @@ describe("directive behavior", () => { const unsupportedModelTexts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); expect(unsupportedModelTexts).toContain( - 'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', + 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 90aede76047..359082c2616 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -48,8 +48,14 @@ describe("listThinkingLevels", () => { expect(listThinkingLevels(undefined, "gpt-5.3-codex-spark")).toContain("xhigh"); }); - it("includes xhigh for openai gpt-5.2", () => { + it("includes xhigh for openai gpt-5.2 and gpt-5.4 variants", () => { expect(listThinkingLevels("openai", "gpt-5.2")).toContain("xhigh"); + expect(listThinkingLevels("openai", "gpt-5.4")).toContain("xhigh"); + expect(listThinkingLevels("openai", "gpt-5.4-pro")).toContain("xhigh"); + }); + + it("includes xhigh for openai-codex gpt-5.4", () => { + expect(listThinkingLevels("openai-codex", "gpt-5.4")).toContain("xhigh"); }); it("includes xhigh for github-copilot gpt-5.2 refs", () => { diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 0342e6fe7b2..0a0f87c16e7 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -22,7 +22,10 @@ export function isBinaryThinkingProvider(provider?: string | null): boolean { } export const XHIGH_MODEL_REFS = [ + "openai/gpt-5.4", + "openai/gpt-5.4-pro", "openai/gpt-5.2", + "openai-codex/gpt-5.4", "openai-codex/gpt-5.3-codex", "openai-codex/gpt-5.3-codex-spark", "openai-codex/gpt-5.2-codex", diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 396509f8a31..2b2e8612782 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => { const printModelTable = vi.fn(); return { loadConfig: vi.fn().mockReturnValue({ - agents: { defaults: { model: { primary: "openai-codex/gpt-5.3-codex" } } }, + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, models: { providers: {} }, }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), @@ -14,18 +14,19 @@ const mocks = vi.hoisted(() => { resolveConfiguredEntries: vi.fn().mockReturnValue({ entries: [ { - key: "openai-codex/gpt-5.3-codex", - ref: { provider: "openai-codex", model: "gpt-5.3-codex" }, + key: "openai-codex/gpt-5.4", + ref: { provider: "openai-codex", model: "gpt-5.4" }, tags: new Set(["configured"]), aliases: [], }, ], }), printModelTable, - resolveForwardCompatModel: vi.fn().mockReturnValue({ + listProfilesForProvider: vi.fn().mockReturnValue([]), + resolveModelWithRegistry: vi.fn().mockReturnValue({ provider: "openai-codex", - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", + id: "gpt-5.4", + name: "GPT-5.4", api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api", input: ["text"], @@ -45,7 +46,7 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { return { ...actual, ensureAuthProfileStore: mocks.ensureAuthProfileStore, - listProfilesForProvider: vi.fn().mockReturnValue([]), + listProfilesForProvider: mocks.listProfilesForProvider, }; }); @@ -65,11 +66,11 @@ vi.mock("./list.table.js", () => ({ printModelTable: mocks.printModelTable, })); -vi.mock("../../agents/model-forward-compat.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../agents/pi-embedded-runner/model.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - resolveForwardCompatModel: mocks.resolveForwardCompatModel, + resolveModelWithRegistry: mocks.resolveModelWithRegistry, }; }); @@ -88,9 +89,95 @@ describe("modelsListCommand forward-compat", () => { missing: boolean; }>; - const codex = rows.find((r) => r.key === "openai-codex/gpt-5.3-codex"); + const codex = rows.find((r) => r.key === "openai-codex/gpt-5.4"); expect(codex).toBeTruthy(); expect(codex?.missing).toBe(false); expect(codex?.tags).not.toContain("missing"); }); + + it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ + entries: [ + { + key: "openai/gpt-5.4", + ref: { provider: "openai", model: "gpt-5.4" }, + tags: new Set(["configured"]), + aliases: [], + }, + ], + }); + mocks.resolveModelWithRegistry.mockReturnValueOnce({ + provider: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + baseUrl: "http://localhost:4000/v1", + input: ["text", "image"], + contextWindow: 1_050_000, + maxTokens: 128_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ json: true, local: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>; + expect(rows).toEqual([ + expect.objectContaining({ + key: "openai/gpt-5.4", + }), + ]); + }); + + it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => { + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [], + availableKeys: new Set(), + registry: {}, + }); + mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) => + provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], + ); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; + + expect(rows).toContainEqual( + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ); + }); + + it("exits with an error when configured-mode listing has no model registry", async () => { + vi.clearAllMocks(); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [], + availableKeys: new Set(), + registry: undefined, + }); + const runtime = { log: vi.fn(), error: vi.fn() }; + let observedExitCode: number | undefined; + + try { + await modelsListCommand({ json: true }, runtime as never); + observedExitCode = process.exitCode; + } finally { + process.exitCode = previousExitCode; + } + + expect(runtime.error).toHaveBeenCalledWith("Model registry unavailable."); + expect(observedExitCode).toBe(1); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 43d5e5ef9b5..7e706469cea 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,7 +1,7 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js"; import { parseModelRef } from "../../agents/model-selection.js"; +import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; @@ -54,8 +54,7 @@ export async function modelsListCommand( `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`, ); } - - const modelByKey = new Map(models.map((model) => [modelKey(model.provider, model.id), model])); + const discoveredKeys = new Set(models.map((model) => modelKey(model.provider, model.id))); const { entries } = resolveConfiguredEntries(cfg); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); @@ -93,26 +92,22 @@ export async function modelsListCommand( ); } } else { + const registry = modelRegistry; + if (!registry) { + runtime.error("Model registry unavailable."); + process.exitCode = 1; + return; + } for (const entry of entries) { if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) { continue; } - let model = modelByKey.get(entry.key); - if (!model && modelRegistry) { - const forwardCompat = resolveForwardCompatModel( - entry.ref.provider, - entry.ref.model, - modelRegistry, - ); - if (forwardCompat) { - model = forwardCompat; - modelByKey.set(entry.key, forwardCompat); - } - } - if (!model) { - const { resolveModel } = await import("../../agents/pi-embedded-runner/model.js"); - model = resolveModel(entry.ref.provider, entry.ref.model, undefined, cfg).model; - } + const model = resolveModelWithRegistry({ + provider: entry.ref.provider, + modelId: entry.ref.model, + modelRegistry: registry, + cfg, + }); if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) { continue; } @@ -128,6 +123,9 @@ export async function modelsListCommand( availableKeys, cfg, authStore, + allowProviderAvailabilityFallback: model + ? !discoveredKeys.has(modelKey(model.provider, model.id)) + : false, }), ); } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 012b4eafb07..a4fd2cdf0f5 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -129,8 +129,18 @@ export function toModelRow(params: { availableKeys?: Set; cfg?: OpenClawConfig; authStore?: AuthProfileStore; + allowProviderAvailabilityFallback?: boolean; }): ModelRow { - const { model, key, tags, aliases = [], availableKeys, cfg, authStore } = params; + const { + model, + key, + tags, + aliases = [], + availableKeys, + cfg, + authStore, + allowProviderAvailabilityFallback = false, + } = params; if (!model) { return { key, @@ -146,14 +156,15 @@ export function toModelRow(params: { const input = model.input.join("+") || "text"; const local = isLocalBaseUrl(model.baseUrl); + const modelIsAvailable = availableKeys?.has(modelKey(model.provider, model.id)) ?? false; // Prefer model-level registry availability when present. - // Fall back to provider-level auth heuristics only if registry availability isn't available. + // Fall back to provider-level auth heuristics only if registry availability isn't available, + // or if the caller marks this as a synthetic/forward-compat model that won't appear in getAvailable(). const available = - availableKeys !== undefined - ? availableKeys.has(modelKey(model.provider, model.id)) - : cfg && authStore - ? hasAuthForProvider(model.provider, cfg, authStore) - : false; + availableKeys !== undefined && !allowProviderAvailabilityFallback + ? modelIsAvailable + : modelIsAvailable || + (cfg && authStore ? hasAuthForProvider(model.provider, cfg, authStore) : false); const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const mergedTags = new Set(tags); if (aliasTags.length > 0) { diff --git a/src/commands/openai-codex-model-default.ts b/src/commands/openai-codex-model-default.ts index b20b6feca7c..58d57ef0c39 100644 --- a/src/commands/openai-codex-model-default.ts +++ b/src/commands/openai-codex-model-default.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { AgentModelListConfig } from "../config/types.js"; -export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.3-codex"; +export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.4"; function shouldSetOpenAICodexModel(model?: string): boolean { const trimmed = model?.trim();