Fix stale openai-codex gpt-5.4 model resolution

This commit is contained in:
Rudi Cilibrasi 2026-03-17 22:49:12 -07:00
parent 598f1826d8
commit eff18880e1
2 changed files with 389 additions and 14 deletions

View File

@ -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<typeof discoverModels>);
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<typeof discoverModels>);
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<typeof discoverModels>);
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<typeof discoverModels>);
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<string, string> }).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<typeof discoverModels>);
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<typeof discoverModels>);
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<typeof discoverModels>);
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<typeof discoverModels>);
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<typeof discoverModels>);
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();

View File

@ -232,6 +232,88 @@ function resolveExplicitModelWithRegistry(params: {
return undefined;
}
function preferResolvedModel(
discoveredModel: Model<Api> | undefined,
dynamicModel: Model<Api> | undefined,
): Model<Api> | 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<string, readonly string[]> = {
"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<Api> | undefined;
dynamicModel: Model<Api> | undefined;
providerConfig?: InlineProviderConfig;
modelId: string;
}): Model<Api> | 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 };
}