Merge d0641c2e9e91e3474fcbf3e7899f3a16a26900dc into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Rudi Cilibrasi 2026-03-20 22:59:00 -07:00 committed by GitHub
commit 386260a3ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 360 additions and 14 deletions

View File

@ -124,3 +124,22 @@ export function mockDiscoveredModel(params: {
}),
} as unknown as ReturnType<typeof discoverModels>);
}
/**
* 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<string, unknown>): 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<typeof discoverModels>);
}

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 })),
@ -26,6 +27,7 @@ import {
makeModel,
mockDiscoveredModel,
mockOpenAICodexTemplateModel,
mockStaleCodexDiscovery,
resetMockDiscoverModels,
} from "./model.test-harness.js";
@ -666,6 +668,207 @@ describe("resolveModel", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
});
it("prefers the codex gpt-5.4 forward-compat model over stale discovered metadata", () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
contextWindow: 272000,
maxTokens: 128000,
});
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", () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
contextWindow: 272000,
maxTokens: 64000,
});
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 () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
contextWindow: 272000,
maxTokens: 128000,
});
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", () => {
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");
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", () => {
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");
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", () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
api: "openai-completions",
contextWindow: 272000,
maxTokens: 128000,
});
const cfg: OpenClawConfig = {
models: {
providers: {
"openai-codex": {
models: [{ id: "gpt-5.4", api: "openai-responses" }],
},
},
},
} 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"),
// 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,
});
});
it("preserves discovered input when dynamic gpt-5.4 wins", () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
});
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", () => {
mockStaleCodexDiscovery({
...buildOpenAICodexForwardCompatExpectation("gpt-5.4"),
maxTokens: 64_000,
contextWindow: 272000,
});
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"),
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 stays "openai-codex-responses" after plugin normalization
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,103 @@ 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,
});
// 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,
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,
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning ?? dynamicModel.reasoning,
cost: discoveredModel.cost ?? dynamicModel.cost,
headers: mergedHeaders,
};
}
export function resolveModelWithRegistry(params: {
provider: string;
modelId: string;
@ -243,7 +340,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 +360,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 +488,7 @@ export async function resolveModelAsync(
modelRegistry,
};
}
if (!explicitModel) {
const maybePrepareDynamicModel = async () => {
const providerPlugin = resolveProviderRuntimePlugin({
provider,
config: cfg,
@ -387,17 +507,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 };
}