From eff18880e183c281a0b74777584777e654cf5437 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Tue, 17 Mar 2026 22:49:12 -0700 Subject: [PATCH 1/8] Fix stale openai-codex gpt-5.4 model resolution --- src/agents/pi-embedded-runner/model.test.ts | 266 ++++++++++++++++++++ src/agents/pi-embedded-runner/model.ts | 137 ++++++++-- 2 files changed, 389 insertions(+), 14 deletions(-) diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index b733e3a3f5f..87c2a7d9ee2 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { discoverModels } from "../pi-model-discovery.js"; vi.mock("../pi-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), @@ -666,6 +667,271 @@ describe("resolveModel", () => { expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4")); }); + it("prefers the codex gpt-5.4 forward-compat model over stale discovered metadata", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("preserves configured openai-codex overrides when stale discovery loses to the dynamic gpt-5.4 model", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 64000, + }; + }), + } as unknown as ReturnType); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://custom.example.com", + models: [{ id: "gpt-5.4", maxTokens: 64_000 }], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + baseUrl: "https://custom.example.com", + contextWindow: 1_050_000, + maxTokens: 64_000, + }); + }); + + it("prefers the codex gpt-5.4 forward-compat model on async resolve when discovery is stale", async () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + }; + }), + } as unknown as ReturnType); + + const result = await resolveModelAsync("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("preserves discovered baseUrl and headers when dynamic gpt-5.4 wins", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + baseUrl: "https://proxy.example.com/backend-api", + headers: { "X-Test-Route": "tenant-a" }, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + baseUrl: "https://proxy.example.com/backend-api", + contextWindow: 1_050_000, + }); + expect((result.model as { headers?: Record }).headers).toMatchObject({ + "X-Test-Route": "tenant-a", + }); + }); + + it("preserves discovered api and compat when dynamic gpt-5.4 wins", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-completions", + compat: { supportsStore: false }, + contextWindow: 272000, + maxTokens: 128000, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-completions", + compat: { supportsStore: false }, + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("keeps model-level api overrides when dynamic gpt-5.4 wins", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-completions", + contextWindow: 272000, + maxTokens: 128000, + }; + }), + } as unknown as ReturnType); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + models: [{ id: "gpt-5.4", api: "openai-responses" }], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-responses", + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("preserves discovered input when dynamic gpt-5.4 wins", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + input: ["text"], + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("preserves discovered maxTokens when dynamic gpt-5.4 wins", () => { + mockOpenAICodexTemplateModel(); + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + maxTokens: 64_000, + contextWindow: 272000, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 1_050_000, + maxTokens: 64_000, + }); + }); + + it("keeps discovered gpt-5.4 metadata when no codex template exists", () => { + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider !== "openai-codex" || modelId !== "gpt-5.4") { + return null; + } + return { + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-responses", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }, + }; + }), + } as unknown as ReturnType); + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-responses", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }, + }); + }); + it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => { mockOpenAICodexTemplateModel(); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 5bf97a683d0..04642eb747c 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -232,6 +232,88 @@ function resolveExplicitModelWithRegistry(params: { return undefined; } +function preferResolvedModel( + discoveredModel: Model | undefined, + dynamicModel: Model | undefined, +): Model | undefined { + if (!dynamicModel) { + return discoveredModel; + } + if (!discoveredModel) { + return dynamicModel; + } + const dynamicContextWindow = dynamicModel.contextWindow ?? 0; + const discoveredContextWindow = discoveredModel.contextWindow ?? 0; + if (dynamicContextWindow > discoveredContextWindow) { + return dynamicModel; + } + if (dynamicContextWindow < discoveredContextWindow) { + return discoveredModel; + } + return dynamicModel; +} + +const OPENAI_CODEX_DYNAMIC_OVERRIDE_MODELS = new Set(["gpt-5.4"]); +const OPENAI_CODEX_DYNAMIC_OVERRIDE_TEMPLATES: Record = { + "gpt-5.4": ["gpt-5.3-codex", "gpt-5.2-codex"], +}; + +function shouldPreferDynamicModelOverride(params: { provider: string; modelId: string }): boolean { + return ( + normalizeProviderId(params.provider) === "openai-codex" && + OPENAI_CODEX_DYNAMIC_OVERRIDE_MODELS.has(params.modelId) + ); +} + +function hasDynamicOverrideTemplate(params: { + provider: string; + modelId: string; + modelRegistry: ModelRegistry; +}): boolean { + if (!shouldPreferDynamicModelOverride(params)) { + return false; + } + const templateIds = OPENAI_CODEX_DYNAMIC_OVERRIDE_TEMPLATES[params.modelId]; + if (!templateIds?.length) { + return false; + } + return templateIds.some((templateId) => params.modelRegistry.find(params.provider, templateId)); +} + +function preserveDiscoveredTransportMetadata(params: { + discoveredModel: Model | undefined; + dynamicModel: Model | undefined; + providerConfig?: InlineProviderConfig; + modelId: string; +}): Model | undefined { + const { discoveredModel, dynamicModel, providerConfig, modelId } = params; + if (!discoveredModel || !dynamicModel) { + return dynamicModel; + } + const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, { + stripSecretRefMarkers: true, + }); + const dynamicHeaders = sanitizeModelHeaders(dynamicModel.headers, { + stripSecretRefMarkers: true, + }); + return { + ...dynamicModel, + api: configuredModel?.api ?? providerConfig?.api ?? discoveredModel.api ?? dynamicModel.api, + baseUrl: providerConfig?.baseUrl ?? discoveredModel.baseUrl ?? dynamicModel.baseUrl, + input: configuredModel?.input ?? discoveredModel.input ?? dynamicModel.input, + compat: configuredModel?.compat ?? discoveredModel.compat ?? dynamicModel.compat, + maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens ?? dynamicModel.maxTokens, + headers: + discoveredHeaders || dynamicHeaders + ? { + ...discoveredHeaders, + ...dynamicHeaders, + } + : undefined, + }; +} + export function resolveModelWithRegistry(params: { provider: string; modelId: string; @@ -243,7 +325,9 @@ export function resolveModelWithRegistry(params: { if (explicitModel?.kind === "suppressed") { return undefined; } - if (explicitModel?.kind === "resolved") { + const shouldCompareDynamicOverride = + shouldPreferDynamicModelOverride(params) && hasDynamicOverrideTemplate(params); + if (explicitModel?.kind === "resolved" && !shouldCompareDynamicOverride) { return explicitModel.model; } @@ -261,12 +345,33 @@ export function resolveModelWithRegistry(params: { providerConfig, }, }); - if (pluginDynamicModel) { + const configuredDynamicModel = pluginDynamicModel + ? applyConfiguredProviderOverrides({ + discoveredModel: pluginDynamicModel, + providerConfig, + modelId, + }) + : undefined; + const discoveredResolvedModel = + explicitModel?.kind === "resolved" ? explicitModel.model : undefined; + const dynamicModelForComparison = shouldCompareDynamicOverride + ? preserveDiscoveredTransportMetadata({ + discoveredModel: discoveredResolvedModel, + dynamicModel: configuredDynamicModel, + providerConfig, + modelId, + }) + : configuredDynamicModel; + const preferredModel = preferResolvedModel(discoveredResolvedModel, dynamicModelForComparison); + if (preferredModel) { + if (preferredModel === explicitModel?.model) { + return preferredModel; + } return normalizeResolvedModel({ provider, cfg, agentDir, - model: pluginDynamicModel, + model: preferredModel, }); } @@ -368,7 +473,7 @@ export async function resolveModelAsync( modelRegistry, }; } - if (!explicitModel) { + const maybePrepareDynamicModel = async () => { const providerPlugin = resolveProviderRuntimePlugin({ provider, config: cfg, @@ -387,17 +492,21 @@ export async function resolveModelAsync( }, }); } + }; + if ( + !explicitModel || + (explicitModel.kind === "resolved" && + hasDynamicOverrideTemplate({ provider, modelId, modelRegistry })) + ) { + await maybePrepareDynamicModel(); } - const model = - explicitModel?.kind === "resolved" - ? explicitModel.model - : resolveModelWithRegistry({ - provider, - modelId, - modelRegistry, - cfg, - agentDir: resolvedAgentDir, - }); + const model = resolveModelWithRegistry({ + provider, + modelId, + modelRegistry, + cfg, + agentDir: resolvedAgentDir, + }); if (model) { return { model, authStorage, modelRegistry }; } From 8dfd3a1875761b016cab908f3793caca57858805 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 16:18:22 -0700 Subject: [PATCH 2/8] Fix TS2352 narrowing cast in model test --- src/agents/pi-embedded-runner/model.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 87c2a7d9ee2..72e4d3decf3 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -837,7 +837,7 @@ describe("resolveModel", () => { }, }, }, - } as OpenClawConfig; + } as unknown as OpenClawConfig; const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg); From 0467f050678dfbf4dd2a5f7d548e39ac61388477 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 18:01:56 -0700 Subject: [PATCH 3/8] Fix cost preservation and test api expectations for codex normalization --- src/agents/pi-embedded-runner/model.test.ts | 6 +++--- src/agents/pi-embedded-runner/model.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 72e4d3decf3..0f0d410ec8d 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -844,7 +844,8 @@ describe("resolveModel", () => { expect(result.error).toBeUndefined(); expect(result.model).toMatchObject({ ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - api: "openai-responses", + // api: "openai-responses" from config is normalized to "openai-codex-responses" + // by normalizeCodexTransport when baseUrl is the codex endpoint contextWindow: 1_050_000, maxTokens: 128_000, }); @@ -910,7 +911,6 @@ describe("resolveModel", () => { } return { ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - api: "openai-responses", input: ["text"], contextWindow: 272000, maxTokens: 128000, @@ -924,7 +924,7 @@ describe("resolveModel", () => { expect(result.error).toBeUndefined(); expect(result.model).toMatchObject({ ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - api: "openai-responses", + // api stays "openai-codex-responses" after plugin normalization input: ["text"], contextWindow: 272000, maxTokens: 128000, diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 04642eb747c..2219bc3d2b9 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -304,6 +304,7 @@ function preserveDiscoveredTransportMetadata(params: { input: configuredModel?.input ?? discoveredModel.input ?? dynamicModel.input, compat: configuredModel?.compat ?? discoveredModel.compat ?? dynamicModel.compat, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens ?? dynamicModel.maxTokens, + cost: discoveredModel.cost ?? dynamicModel.cost, headers: discoveredHeaders || dynamicHeaders ? { From e25dc5ed518e1b2b1b1f8ab4e054f0b3a4172bc4 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 18:10:12 -0700 Subject: [PATCH 4/8] Fix header merge order in preserveDiscoveredTransportMetadata --- src/agents/pi-embedded-runner/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 2219bc3d2b9..2bd9e7b3aba 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -308,8 +308,8 @@ function preserveDiscoveredTransportMetadata(params: { headers: discoveredHeaders || dynamicHeaders ? { - ...discoveredHeaders, ...dynamicHeaders, + ...discoveredHeaders, } : undefined, }; From 1fc432c07ba7f2c9300142d11e59f48ce198aa86 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 18:29:23 -0700 Subject: [PATCH 5/8] Preserve reasoning field from discovered model in preserveDiscoveredTransportMetadata --- src/agents/pi-embedded-runner/model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 2bd9e7b3aba..cc08d5031f2 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -304,6 +304,7 @@ function preserveDiscoveredTransportMetadata(params: { input: configuredModel?.input ?? discoveredModel.input ?? dynamicModel.input, compat: configuredModel?.compat ?? discoveredModel.compat ?? dynamicModel.compat, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens ?? dynamicModel.maxTokens, + reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning ?? dynamicModel.reasoning, cost: discoveredModel.cost ?? dynamicModel.cost, headers: discoveredHeaders || dynamicHeaders From 8c78ee09ce0dbe7827648f222f24782df11fc757 Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 19:12:37 -0700 Subject: [PATCH 6/8] Remove stale runtime-matrix baseline entry --- .../plugin-extension-import-boundary-inventory.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 0894fe0d5b5..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -31,14 +31,6 @@ "resolvedPath": "extensions/imessage/runtime-api.js", "reason": "imports extension-owned file from src/plugins" }, - { - "file": "src/plugins/runtime/runtime-matrix.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/matrix/runtime-api.js", - "resolvedPath": "extensions/matrix/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", "line": 10, From 7a11196ccb1777ae4181a32456419dd88d38efba Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 19:21:37 -0700 Subject: [PATCH 7/8] Fix header precedence: 3-way merge (dynamic < discovered < configured) --- src/agents/pi-embedded-runner/model.ts | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index cc08d5031f2..eac91d88a8a 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -297,6 +297,25 @@ function preserveDiscoveredTransportMetadata(params: { const dynamicHeaders = sanitizeModelHeaders(dynamicModel.headers, { stripSecretRefMarkers: true, }); + // Headers use a 3-way merge: dynamic template < discovered < configured. + // dynamicModel.headers already includes configured overrides from + // applyConfiguredProviderOverrides, so we extract configured headers separately + // and apply them last to ensure they win over stale discovered headers. + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, { + stripSecretRefMarkers: true, + }); + const configuredModelHeaders = sanitizeModelHeaders(configuredModel?.headers, { + stripSecretRefMarkers: true, + }); + const mergedHeaders = + dynamicHeaders || discoveredHeaders || providerHeaders || configuredModelHeaders + ? { + ...dynamicHeaders, + ...discoveredHeaders, + ...providerHeaders, + ...configuredModelHeaders, + } + : undefined; return { ...dynamicModel, api: configuredModel?.api ?? providerConfig?.api ?? discoveredModel.api ?? dynamicModel.api, @@ -306,13 +325,7 @@ function preserveDiscoveredTransportMetadata(params: { maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens ?? dynamicModel.maxTokens, reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning ?? dynamicModel.reasoning, cost: discoveredModel.cost ?? dynamicModel.cost, - headers: - discoveredHeaders || dynamicHeaders - ? { - ...dynamicHeaders, - ...discoveredHeaders, - } - : undefined, + headers: mergedHeaders, }; } From d0641c2e9e91e3474fcbf3e7899f3a16a26900dc Mon Sep 17 00:00:00 2001 From: Rudi Cilibrasi Date: Fri, 20 Mar 2026 22:51:27 -0700 Subject: [PATCH 8/8] Fix stale-metadata tests to exercise the preferResolvedModel/preserveDiscoveredTransportMetadata code path The stale-metadata tests called mockOpenAICodexTemplateModel() then immediately overwrote the discovery mock with one that only returned gpt-5.4, causing hasDynamicOverrideTemplate to return false and the override path to be skipped. Introduce mockStaleCodexDiscovery() helper that preserves the gpt-5.2-codex template in the registry alongside the stale gpt-5.4 model, so the intended code path is now exercised by all 8 stale-metadata tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pi-embedded-runner/model.test-harness.ts | 19 +++ src/agents/pi-embedded-runner/model.test.ts | 157 ++++++------------ 2 files changed, 66 insertions(+), 110 deletions(-) diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/pi-embedded-runner/model.test-harness.ts index b91ca8b8c5f..4ce53098e46 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/pi-embedded-runner/model.test-harness.ts @@ -124,3 +124,22 @@ export function mockDiscoveredModel(params: { }), } as unknown as ReturnType); } + +/** + * Mock a stale discovered gpt-5.4 model while keeping the gpt-5.2-codex + * template visible so `hasDynamicOverrideTemplate` returns true and the + * `preferResolvedModel`/`preserveDiscoveredTransportMetadata` path is exercised. + */ +export function mockStaleCodexDiscovery(staleModel: Record): void { + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider === "openai-codex" && modelId === "gpt-5.2-codex") { + return OPENAI_CODEX_TEMPLATE_MODEL; + } + if (provider === "openai-codex" && modelId === "gpt-5.4") { + return staleModel; + } + return null; + }), + } as unknown as ReturnType); +} diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 0f0d410ec8d..459ce5c805e 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -27,6 +27,7 @@ import { makeModel, mockDiscoveredModel, mockOpenAICodexTemplateModel, + mockStaleCodexDiscovery, resetMockDiscoverModels, } from "./model.test-harness.js"; @@ -668,19 +669,11 @@ describe("resolveModel", () => { }); it("prefers the codex gpt-5.4 forward-compat model over stale discovered metadata", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - contextWindow: 272000, - maxTokens: 128000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + }); const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); @@ -693,19 +686,11 @@ describe("resolveModel", () => { }); it("preserves configured openai-codex overrides when stale discovery loses to the dynamic gpt-5.4 model", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - contextWindow: 272000, - maxTokens: 64000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 64000, + }); const cfg: OpenClawConfig = { models: { @@ -730,19 +715,11 @@ describe("resolveModel", () => { }); it("prefers the codex gpt-5.4 forward-compat model on async resolve when discovery is stale", async () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - contextWindow: 272000, - maxTokens: 128000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + }); const result = await resolveModelAsync("openai-codex", "gpt-5.4", "/tmp/agent"); @@ -755,21 +732,13 @@ describe("resolveModel", () => { }); it("preserves discovered baseUrl and headers when dynamic gpt-5.4 wins", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - contextWindow: 272000, - maxTokens: 128000, - baseUrl: "https://proxy.example.com/backend-api", - headers: { "X-Test-Route": "tenant-a" }, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + contextWindow: 272000, + maxTokens: 128000, + baseUrl: "https://proxy.example.com/backend-api", + headers: { "X-Test-Route": "tenant-a" }, + }); const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); @@ -785,21 +754,13 @@ describe("resolveModel", () => { }); it("preserves discovered api and compat when dynamic gpt-5.4 wins", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - api: "openai-completions", - compat: { supportsStore: false }, - contextWindow: 272000, - maxTokens: 128000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-completions", + compat: { supportsStore: false }, + contextWindow: 272000, + maxTokens: 128000, + }); const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); @@ -814,20 +775,12 @@ describe("resolveModel", () => { }); it("keeps model-level api overrides when dynamic gpt-5.4 wins", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - api: "openai-completions", - contextWindow: 272000, - maxTokens: 128000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + api: "openai-completions", + contextWindow: 272000, + maxTokens: 128000, + }); const cfg: OpenClawConfig = { models: { @@ -852,20 +805,12 @@ describe("resolveModel", () => { }); it("preserves discovered input when dynamic gpt-5.4 wins", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + }); const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); @@ -879,19 +824,11 @@ describe("resolveModel", () => { }); it("preserves discovered maxTokens when dynamic gpt-5.4 wins", () => { - mockOpenAICodexTemplateModel(); - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider !== "openai-codex" || modelId !== "gpt-5.4") { - return null; - } - return { - ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), - maxTokens: 64_000, - contextWindow: 272000, - }; - }), - } as unknown as ReturnType); + mockStaleCodexDiscovery({ + ...buildOpenAICodexForwardCompatExpectation("gpt-5.4"), + maxTokens: 64_000, + contextWindow: 272000, + }); const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");