mirror of https://github.com/openclaw/openclaw.git
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:
parent
8c85ad540a
commit
5d4b04040d
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,16 @@ OpenClaw ships with the pi‑ai 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 pi‑ai 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 pi‑ai 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" } } },
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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) ??
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue