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