feat(openai): add gpt-5.4 support for API and Codex OAuth (#36590)

* feat(openai): add gpt-5.4 support and priority processing

* feat(openai-codex): add gpt-5.4 oauth support

* fix(openai): preserve provider overrides in gpt-5.4 fallback

* fix(openai-codex): keep xhigh for gpt-5.4 default

* fix(models): preserve configured overrides in list output

* fix(models): close gpt-5.4 integration gaps

* fix(openai): scope service tier to public api

* fix(openai): complete prep followups for gpt-5.4 support (#36590) (thanks @dorukardahan)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
dorukardahan 2026-03-06 08:01:37 +03:00 committed by GitHub
parent 8c85ad540a
commit 5d4b04040d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 913 additions and 178 deletions

View File

@ -532,6 +532,7 @@ Docs: https://docs.openclaw.ai
### Changes ### Changes
- Docs/Contributing: require before/after screenshots for UI or visual PRs in the pre-PR checklist. (#32206) Thanks @hydro13. - 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 ### Fixes

View File

@ -41,15 +41,16 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `openai` - Provider: `openai`
- Auth: `OPENAI_API_KEY` - Auth: `OPENAI_API_KEY`
- Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override) - 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` - CLI: `openclaw onboard --auth-choice openai-api-key`
- Default transport is `auto` (WebSocket-first, SSE fallback) - Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`)
- OpenAI priority processing can be enabled via `agents.defaults.models["openai/<model>"].params.serviceTier`
```json5 ```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 piai catalog. These providers require **no**
- Provider: `openai-codex` - Provider: `openai-codex`
- Auth: OAuth (ChatGPT) - 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` - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- Default transport is `auto` (WebSocket-first, SSE fallback) - Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
@ -81,7 +82,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
```json5 ```json5
{ {
agents: { defaults: { model: { primary: "openai-codex/gpt-5.3-codex" } } }, agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
} }
``` ```

View File

@ -31,7 +31,7 @@ openclaw agent --message "hi" --model claude-cli/opus-4.6
Codex CLI also works out of the box: Codex CLI also works out of the box:
```bash ```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 If your gateway runs under launchd/systemd and PATH is minimal, add just the

View File

@ -767,7 +767,7 @@ Yes - via pi-ai's **Amazon Bedrock (Converse)** provider with **manual config**.
### How does Codex auth work ### 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 ### 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: 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. - **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.3-codex` when coding (or the other way around). - **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. - **Sub-agents:** route coding tasks to sub-agents with a different default model.
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands). See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).

View File

@ -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"]` - Args: `["-p","--output-format","json","--permission-mode","bypassPermissions"]`
- Overrides (optional): - Overrides (optional):
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-6"` - `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_COMMAND="/full/path/to/claude"`
- `OPENCLAW_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'` - `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"]'` - `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: This is the “common models” run we expect to keep working:
- OpenAI (non-Codex): `openai/gpt-5.2` (optional: `openai/gpt-5.1`) - 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`) - 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 (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` - 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` - MiniMax: `minimax/minimax-m2.5`
Run gateway smoke with tools + image: 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) ### Baseline: tool calling (Read + optional Exec)

View File

@ -30,10 +30,13 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY"
```json5 ```json5
{ {
env: { OPENAI_API_KEY: "sk-..." }, 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 ## Option B: OpenAI Code (Codex) subscription
**Best for:** using ChatGPT/Codex subscription access instead of an API key. **Best for:** using ChatGPT/Codex subscription access instead of an API key.
@ -53,10 +56,13 @@ openclaw models auth login --provider openai-codex
```json5 ```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 ### Transport default
OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and
@ -81,9 +87,9 @@ Related OpenAI docs:
{ {
agents: { agents: {
defaults: { defaults: {
model: { primary: "openai-codex/gpt-5.3-codex" }, model: { primary: "openai-codex/gpt-5.4" },
models: { models: {
"openai-codex/gpt-5.3-codex": { "openai-codex/gpt-5.4": {
params: { params: {
transport: "auto", transport: "auto",
}, },
@ -106,7 +112,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-5.2": { "openai/gpt-5.4": {
params: { params: {
openaiWsWarmup: false, openaiWsWarmup: false,
}, },
@ -124,7 +130,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-5.2": { "openai/gpt-5.4": {
params: { params: {
openaiWsWarmup: true, 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/<model>"].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 ### OpenAI Responses server-side compaction
For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with
@ -157,7 +187,7 @@ Responses models (for example Azure OpenAI Responses):
agents: { agents: {
defaults: { defaults: {
models: { models: {
"azure-openai-responses/gpt-5.2": { "azure-openai-responses/gpt-5.4": {
params: { params: {
responsesServerCompaction: true, responsesServerCompaction: true,
}, },
@ -175,7 +205,7 @@ Responses models (for example Azure OpenAI Responses):
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-5.2": { "openai/gpt-5.4": {
params: { params: {
responsesServerCompaction: true, responsesServerCompaction: true,
responsesCompactThreshold: 120000, responsesCompactThreshold: 120000,
@ -194,7 +224,7 @@ Responses models (for example Azure OpenAI Responses):
agents: { agents: {
defaults: { defaults: {
models: { models: {
"openai/gpt-5.2": { "openai/gpt-5.4": {
params: { params: {
responsesServerCompaction: false, responsesServerCompaction: false,
}, },

View File

@ -143,7 +143,7 @@ What you set:
<Accordion title="OpenAI Code subscription (OAuth)"> <Accordion title="OpenAI Code subscription (OAuth)">
Browser flow; paste `code#state`. 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/*`.
</Accordion> </Accordion>
<Accordion title="OpenAI API key"> <Accordion title="OpenAI API key">

View File

@ -53,9 +53,9 @@ without writing custom OpenClaw code for each workflow.
"enabled": true, "enabled": true,
"config": { "config": {
"defaultProvider": "openai-codex", "defaultProvider": "openai-codex",
"defaultModel": "gpt-5.2", "defaultModel": "gpt-5.4",
"defaultAuthProfileId": "main", "defaultAuthProfileId": "main",
"allowedModels": ["openai-codex/gpt-5.3-codex"], "allowedModels": ["openai-codex/gpt-5.4"],
"maxTokens": 800, "maxTokens": 800,
"timeoutMs": 30000 "timeoutMs": 30000
} }

View File

@ -10,8 +10,9 @@ const ANTHROPIC_PREFIXES = [
"claude-sonnet-4-5", "claude-sonnet-4-5",
"claude-haiku-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 = [ const CODEX_MODELS = [
"gpt-5.4",
"gpt-5.2", "gpt-5.2",
"gpt-5.2-codex", "gpt-5.2-codex",
"gpt-5.3-codex", "gpt-5.3-codex",

View File

@ -157,7 +157,7 @@ describe("getApiKeyForModel", () => {
} catch (err) { } catch (err) {
error = err; error = err;
} }
expect(String(error)).toContain("openai-codex/gpt-5.3-codex"); expect(String(error)).toContain("openai-codex/gpt-5.4");
}, },
); );
} finally { } finally {

View File

@ -250,7 +250,7 @@ export async function resolveApiKeyForProvider(params: {
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) { if (hasCodex) {
throw new Error( 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.',
); );
} }
} }

View File

@ -114,6 +114,59 @@ describe("loadModelCatalog", () => {
expect(spark?.reasoning).toBe(true); 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 () => { it("merges configured models for opted-in non-pi-native providers", async () => {
mockSingleOpenAiCatalogModel(); mockSingleOpenAiCatalogModel();

View File

@ -33,33 +33,67 @@ const defaultImportPiSdk = () => import("./pi-model-discovery.js");
let importPiSdk = defaultImportPiSdk; let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex"; 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_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; 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"]); const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]);
function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void { type SyntheticCatalogFallback = {
const hasSpark = models.some( provider: string;
(entry) => id: string;
entry.provider === CODEX_PROVIDER && templateIds: readonly string[];
entry.id.toLowerCase() === OPENAI_CODEX_GPT53_SPARK_MODEL_ID, };
);
if (hasSpark) {
return;
}
const baseModel = models.find( const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [
(entry) => {
entry.provider === CODEX_PROVIDER && entry.id.toLowerCase() === OPENAI_CODEX_GPT53_MODEL_ID, provider: OPENAI_PROVIDER,
); id: OPENAI_GPT54_MODEL_ID,
if (!baseModel) { templateIds: ["gpt-5.2"],
return; },
} {
provider: OPENAI_PROVIDER,
models.push({ id: OPENAI_GPT54_PRO_MODEL_ID,
...baseModel, 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, 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 { function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined {
@ -218,7 +252,7 @@ export async function loadModelCatalog(params?: {
models.push({ id, name, provider, contextWindow, reasoning, input }); models.push({ id, name, provider, contextWindow, reasoning, input });
} }
mergeConfiguredOptInProviderModels({ config: cfg, models }); mergeConfiguredOptInProviderModels({ config: cfg, models });
applyOpenAICodexSparkFallback(models); applySyntheticCatalogFallbacks(models);
if (models.length === 0) { if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again. // If we found nothing, don't cache this result so we can try again.

View File

@ -37,6 +37,36 @@ function createTemplateModel(provider: string, id: string): Model<Api> {
} as Model<Api>; } as Model<Api>;
} }
function createOpenAITemplateModel(id: string): Model<Api> {
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<Api>;
}
function createOpenAICodexTemplateModel(id: string): Model<Api> {
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<Api>;
}
function createRegistry(models: Record<string, Model<Api>>): ModelRegistry { function createRegistry(models: Record<string, Model<Api>>): ModelRegistry {
return { return {
find(provider: string, modelId: string) { find(provider: string, modelId: string) {
@ -235,6 +265,12 @@ describe("normalizeModelCompat", () => {
}); });
describe("isModernModelRef", () => { 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", () => { 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);
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", () => { 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", () => { it("resolves anthropic opus 4.6 via 4.5 template", () => {
const registry = createRegistry({ const registry = createRegistry({
"anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"),

View File

@ -4,6 +4,15 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { normalizeModelCompat } from "./model-compat.js"; import { normalizeModelCompat } from "./model-compat.js";
import { normalizeProviderId } from "./model-selection.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_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; 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_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const;
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-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<Api> | 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<Api>)
);
}
function cloneFirstTemplateModel(params: { function cloneFirstTemplateModel(params: {
normalizedProvider: string; normalizedProvider: string;
trimmedModelId: string; trimmedModelId: string;
@ -48,23 +109,35 @@ function cloneFirstTemplateModel(params: {
return undefined; return undefined;
} }
const CODEX_GPT54_ELIGIBLE_PROVIDERS = new Set(["openai-codex"]);
const CODEX_GPT53_ELIGIBLE_PROVIDERS = new Set(["openai-codex", "github-copilot"]); const CODEX_GPT53_ELIGIBLE_PROVIDERS = new Set(["openai-codex", "github-copilot"]);
function resolveOpenAICodexGpt53FallbackModel( function resolveOpenAICodexForwardCompatModel(
provider: string, provider: string,
modelId: string, modelId: string,
modelRegistry: ModelRegistry, modelRegistry: ModelRegistry,
): Model<Api> | undefined { ): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider); const normalizedProvider = normalizeProviderId(provider);
const trimmedModelId = modelId.trim(); const trimmedModelId = modelId.trim();
if (!CODEX_GPT53_ELIGIBLE_PROVIDERS.has(normalizedProvider)) { const lower = trimmedModelId.toLowerCase();
return undefined;
} let templateIds: readonly string[];
if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) { let eligibleProviders: Set<string>;
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; 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<Api> | null; const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) { if (!template) {
continue; continue;
@ -248,7 +321,8 @@ export function resolveForwardCompatModel(
modelRegistry: ModelRegistry, modelRegistry: ModelRegistry,
): Model<Api> | undefined { ): Model<Api> | undefined {
return ( return (
resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ?? resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveOpenAICodexForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ??

View File

@ -1,7 +1,8 @@
import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; 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 { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner.js";
import { log } from "./pi-embedded-runner/logger.js";
describe("resolveExtraParams", () => { describe("resolveExtraParams", () => {
it("returns undefined with no model config", () => { it("returns undefined with no model config", () => {
@ -755,6 +756,36 @@ describe("applyExtraParamsToAgent", () => {
expect(calls[0]?.transport).toBe("websocket"); 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)", () => { it("defaults Codex transport to auto (WebSocket-first)", () => {
const { calls, agent } = createOptionsCaptureAgent(); const { calls, agent } = createOptionsCaptureAgent();
@ -1155,6 +1186,179 @@ describe("applyExtraParamsToAgent", () => {
expect(payload.store).toBe(true); 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", () => { it("does not force store for OpenAI Responses routed through non-OpenAI base URLs", () => {
const payload = runResponsesPayloadMutationCase({ const payload = runResponsesPayloadMutationCase({
applyProvider: "openai", applyProvider: "openai",

View File

@ -44,6 +44,7 @@ export function resolveExtraParams(params: {
} }
type CacheRetention = "none" | "short" | "long"; type CacheRetention = "none" | "short" | "long";
type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & { type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
cacheRetention?: CacheRetention; cacheRetention?: CacheRetention;
openaiWsWarmup?: boolean; 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: { function shouldForceResponsesStore(model: {
api?: unknown; api?: unknown;
provider?: 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<string, unknown> | 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<string, unknown>;
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
}
originalOnPayload?.(payload);
},
});
};
}
function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple; const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => return (model, context, options) =>
@ -1073,6 +1143,12 @@ export function applyExtraParamsToAgent(
// upstream model-ID heuristics for Gemini 3.1 variants. // upstream model-ID heuristics for Gemini 3.1 variants.
agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); 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. // Work around upstream pi-ai hardcoding `store: false` for Responses API.
// Force `store=true` for direct OpenAI Responses models and auto-enable // Force `store=true` for direct OpenAI Responses models and auto-enable
// server-side compaction for compatible OpenAI Responses payloads. // server-side compaction for compatible OpenAI Responses payloads.

View File

@ -49,6 +49,14 @@ describe("pi embedded model e2e smoke", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex")); 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", () => { it("keeps unknown-model errors for non-forward-compat IDs", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent"); const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined(); expect(result.model).toBeUndefined();

View File

@ -23,7 +23,7 @@ function buildForwardCompatTemplate(params: {
id: string; id: string;
name: string; name: string;
provider: string; provider: string;
api: "anthropic-messages" | "google-gemini-cli" | "openai-completions"; api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses";
baseUrl: string; baseUrl: string;
input?: readonly ["text"] | readonly ["text", "image"]; input?: readonly ["text"] | readonly ["text", "image"];
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
@ -399,6 +399,53 @@ describe("resolveModel", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex")); 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<string, string> }).headers).toEqual({
"X-Proxy-Auth": "token-123",
});
});
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
mockDiscoveredModel({ mockDiscoveredModel({
provider: "anthropic", provider: "anthropic",

View File

@ -99,6 +99,96 @@ export function buildInlineProviderModels(
}); });
} }
export function resolveModelWithRegistry(params: {
provider: string;
modelId: string;
modelRegistry: ModelRegistry;
cfg?: OpenClawConfig;
}): Model<Api> | undefined {
const { provider, modelId, modelRegistry, cfg } = params;
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const model = modelRegistry.find(provider, modelId) as Model<Api> | 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<Api>);
}
// 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<Api>);
}
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<Api>);
}
return undefined;
}
export function resolveModel( export function resolveModel(
provider: string, provider: string,
modelId: string, modelId: string,
@ -113,89 +203,13 @@ export function resolveModel(
const resolvedAgentDir = agentDir ?? resolveOpenClawAgentDir(); const resolvedAgentDir = agentDir ?? resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(resolvedAgentDir); const authStorage = discoverAuthStorage(resolvedAgentDir);
const modelRegistry = discoverModels(authStorage, resolvedAgentDir); const modelRegistry = discoverModels(authStorage, resolvedAgentDir);
const providerConfig = resolveConfiguredProviderConfig(cfg, provider); const model = resolveModelWithRegistry({ provider, modelId, modelRegistry, cfg });
const model = modelRegistry.find(provider, modelId) as Model<Api> | null; if (model) {
return { model, authStorage, modelRegistry };
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<Api>);
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<Api> = 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<Api>);
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<Api> = 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<Api>);
return { model: fallbackModel, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError(provider, modelId),
authStorage,
modelRegistry,
};
} }
return { return {
model: normalizeModelCompat( error: buildUnknownModelError(provider, modelId),
applyConfiguredProviderOverrides({
discoveredModel: model,
providerConfig,
modelId,
}),
),
authStorage, authStorage,
modelRegistry, modelRegistry,
}; };

View File

@ -239,7 +239,7 @@ describe("directive behavior", () => {
const unsupportedModelTexts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); const unsupportedModelTexts = await runThinkingDirective(home, "openai/gpt-4.1-mini");
expect(unsupportedModelTexts).toContain( 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(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });

View File

@ -48,8 +48,14 @@ describe("listThinkingLevels", () => {
expect(listThinkingLevels(undefined, "gpt-5.3-codex-spark")).toContain("xhigh"); 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.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", () => { it("includes xhigh for github-copilot gpt-5.2 refs", () => {

View File

@ -22,7 +22,10 @@ export function isBinaryThinkingProvider(provider?: string | null): boolean {
} }
export const XHIGH_MODEL_REFS = [ export const XHIGH_MODEL_REFS = [
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
"openai/gpt-5.2", "openai/gpt-5.2",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.3-codex", "openai-codex/gpt-5.3-codex",
"openai-codex/gpt-5.3-codex-spark", "openai-codex/gpt-5.3-codex-spark",
"openai-codex/gpt-5.2-codex", "openai-codex/gpt-5.2-codex",

View File

@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => {
const printModelTable = vi.fn(); const printModelTable = vi.fn();
return { return {
loadConfig: vi.fn().mockReturnValue({ 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: {} }, models: { providers: {} },
}), }),
ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }),
@ -14,18 +14,19 @@ const mocks = vi.hoisted(() => {
resolveConfiguredEntries: vi.fn().mockReturnValue({ resolveConfiguredEntries: vi.fn().mockReturnValue({
entries: [ entries: [
{ {
key: "openai-codex/gpt-5.3-codex", key: "openai-codex/gpt-5.4",
ref: { provider: "openai-codex", model: "gpt-5.3-codex" }, ref: { provider: "openai-codex", model: "gpt-5.4" },
tags: new Set(["configured"]), tags: new Set(["configured"]),
aliases: [], aliases: [],
}, },
], ],
}), }),
printModelTable, printModelTable,
resolveForwardCompatModel: vi.fn().mockReturnValue({ listProfilesForProvider: vi.fn().mockReturnValue([]),
resolveModelWithRegistry: vi.fn().mockReturnValue({
provider: "openai-codex", provider: "openai-codex",
id: "gpt-5.3-codex", id: "gpt-5.4",
name: "GPT-5.3 Codex", name: "GPT-5.4",
api: "openai-codex-responses", api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api", baseUrl: "https://chatgpt.com/backend-api",
input: ["text"], input: ["text"],
@ -45,7 +46,7 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
return { return {
...actual, ...actual,
ensureAuthProfileStore: mocks.ensureAuthProfileStore, ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: vi.fn().mockReturnValue([]), listProfilesForProvider: mocks.listProfilesForProvider,
}; };
}); });
@ -65,11 +66,11 @@ vi.mock("./list.table.js", () => ({
printModelTable: mocks.printModelTable, printModelTable: mocks.printModelTable,
})); }));
vi.mock("../../agents/model-forward-compat.js", async (importOriginal) => { vi.mock("../../agents/pi-embedded-runner/model.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/model-forward-compat.js")>(); const actual = await importOriginal<typeof import("../../agents/pi-embedded-runner/model.js")>();
return { return {
...actual, ...actual,
resolveForwardCompatModel: mocks.resolveForwardCompatModel, resolveModelWithRegistry: mocks.resolveModelWithRegistry,
}; };
}); });
@ -88,9 +89,95 @@ describe("modelsListCommand forward-compat", () => {
missing: boolean; 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).toBeTruthy();
expect(codex?.missing).toBe(false); expect(codex?.missing).toBe(false);
expect(codex?.tags).not.toContain("missing"); 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<Record<string, unknown>>) : [],
);
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<string>(),
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();
});
}); });

View File

@ -1,7 +1,7 @@
import type { Api, Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js";
import { parseModelRef } from "../../agents/model-selection.js"; import { parseModelRef } from "../../agents/model-selection.js";
import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { resolveConfiguredEntries } from "./list.configured.js"; import { resolveConfiguredEntries } from "./list.configured.js";
import { formatErrorWithStack } from "./list.errors.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}`, `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
); );
} }
const discoveredKeys = new Set(models.map((model) => modelKey(model.provider, model.id)));
const modelByKey = new Map(models.map((model) => [modelKey(model.provider, model.id), model]));
const { entries } = resolveConfiguredEntries(cfg); const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
@ -93,26 +92,22 @@ export async function modelsListCommand(
); );
} }
} else { } else {
const registry = modelRegistry;
if (!registry) {
runtime.error("Model registry unavailable.");
process.exitCode = 1;
return;
}
for (const entry of entries) { for (const entry of entries) {
if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) { if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) {
continue; continue;
} }
let model = modelByKey.get(entry.key); const model = resolveModelWithRegistry({
if (!model && modelRegistry) { provider: entry.ref.provider,
const forwardCompat = resolveForwardCompatModel( modelId: entry.ref.model,
entry.ref.provider, modelRegistry: registry,
entry.ref.model, cfg,
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;
}
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) { if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) {
continue; continue;
} }
@ -128,6 +123,9 @@ export async function modelsListCommand(
availableKeys, availableKeys,
cfg, cfg,
authStore, authStore,
allowProviderAvailabilityFallback: model
? !discoveredKeys.has(modelKey(model.provider, model.id))
: false,
}), }),
); );
} }

View File

@ -129,8 +129,18 @@ export function toModelRow(params: {
availableKeys?: Set<string>; availableKeys?: Set<string>;
cfg?: OpenClawConfig; cfg?: OpenClawConfig;
authStore?: AuthProfileStore; authStore?: AuthProfileStore;
allowProviderAvailabilityFallback?: boolean;
}): ModelRow { }): ModelRow {
const { model, key, tags, aliases = [], availableKeys, cfg, authStore } = params; const {
model,
key,
tags,
aliases = [],
availableKeys,
cfg,
authStore,
allowProviderAvailabilityFallback = false,
} = params;
if (!model) { if (!model) {
return { return {
key, key,
@ -146,14 +156,15 @@ export function toModelRow(params: {
const input = model.input.join("+") || "text"; const input = model.input.join("+") || "text";
const local = isLocalBaseUrl(model.baseUrl); const local = isLocalBaseUrl(model.baseUrl);
const modelIsAvailable = availableKeys?.has(modelKey(model.provider, model.id)) ?? false;
// Prefer model-level registry availability when present. // 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 = const available =
availableKeys !== undefined availableKeys !== undefined && !allowProviderAvailabilityFallback
? availableKeys.has(modelKey(model.provider, model.id)) ? modelIsAvailable
: cfg && authStore : modelIsAvailable ||
? hasAuthForProvider(model.provider, cfg, authStore) (cfg && authStore ? hasAuthForProvider(model.provider, cfg, authStore) : false);
: false;
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
const mergedTags = new Set(tags); const mergedTags = new Set(tags);
if (aliasTags.length > 0) { if (aliasTags.length > 0) {

View File

@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.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 { function shouldSetOpenAICodexModel(model?: string): boolean {
const trimmed = model?.trim(); const trimmed = model?.trim();