diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index c0c1d9cd6dc..888e84f0331 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -128,6 +128,420 @@ describe("legacy memory search config migrate", () => { ]); }); + it("merges disjoint model entries from legacy codex into canonical openai and preserves legacy baseUrl (#90047)", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-codex-responses" }], + }, + }, + }, + }); + + // Legacy codex model must be merged with legacy provider baseUrl stamped on it + // so it routes to https://chatgpt.com/backend-api, not https://api.openai.com/v1. + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expectMigrationChangesToIncludeFragments(res.changes, [ + 'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"', + "Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5", + ]); + }); + + it("skips already-present model ids when merging legacy codex into canonical openai", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + // gpt-5.5 already in canonical → skip; gpt-5.4 is new → merged with legacy provider baseUrl/api + expect((openai?.models as unknown[])?.length).toBe(2); + expect(openai?.models).toEqual( + expect.arrayContaining([ + { id: "gpt-5.5" }, + { + id: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ]), + ); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expectMigrationChangesToIncludeFragments(res.changes, [ + "Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.4", + ]); + }); + + it("keeps merged codex models when later canonical openai normalization runs", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + "openai-codex": { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }], + }, + openai: { + api: "openai-codex-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small", api: "openai-codex-responses" }], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai).toEqual({ + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { id: "text-embedding-3-small", api: "openai-chatgpt-responses" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ], + }); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expectMigrationChangesToIncludeFragments(res.changes, [ + 'Moved models.providers.openai.api "openai-codex-responses" → "openai-chatgpt-responses"', + 'Moved models.providers.openai.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"', + "Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5", + ]); + }); + + it("preserves model-scoped legacy provider defaults when merging codex models", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + contextWindow: 200000, + contextTokens: 180000, + maxTokens: 8192, + params: { store: false }, + agentRuntime: { id: "codex" }, + models: [{ id: "gpt-5.5" }], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + contextWindow: 200000, + contextTokens: 180000, + maxTokens: 8192, + params: { store: false }, + agentRuntime: { id: "codex" }, + }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expectMigrationChangesToIncludeFragments(res.changes, [ + "Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5", + ]); + }); + + it("merges legacy provider params into model params when merging codex models", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + params: { store: false, reasoning: { effort: "medium" } }, + models: [ + { + id: "gpt-5.5", + params: { reasoning: { effort: "high" }, verbosity: "low" }, + }, + ], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + params: { + store: false, + reasoning: { effort: "high" }, + verbosity: "low", + }, + }, + ]); + }); + + it("preserves legacy models-add metadata marker when merging codex models", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [ + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 400_000, + contextTokens: 272_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 400_000, + contextTokens: 272_000, + maxTokens: 128_000, + baseUrl: "https://chatgpt.com/backend-api", + metadataSource: "models-add", + }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + }); + + it("keeps legacy codex provider when existing openai defaults would leak into merged models", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "OPENAI_API_KEY", + baseUrl: "https://api.openai.com/v1", + params: { store: true }, + request: { retry: { maxAttempts: 1 } }, + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-codex-responses" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.openai).toEqual({ + api: "openai-responses", + apiKey: "OPENAI_API_KEY", + baseUrl: "https://api.openai.com/v1", + params: { store: true }, + request: { retry: { maxAttempts: 1 } }, + models: [{ id: "text-embedding-3-small" }], + }); + expect(res.config?.models?.providers?.["openai-codex"]).toEqual({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }], + }); + expectMigrationChangesToIncludeFragments(res.changes, [ + 'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses"', + 'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"', + "Skipped merging models.providers.openai-codex into models.providers.openai because provider-level defaults cannot be represented safely on merged models: models.providers.openai.apiKey, models.providers.openai.params, models.providers.openai.request", + ]); + expect(findLegacyConfigIssues(res.config).map((issue) => issue.path)).not.toContain( + "models.providers", + ); + }); + + it("keeps legacy codex provider when legacy auth or headers cannot be model-scoped", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + auth: "oauth", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + headers: { Authorization: "Bearer token" }, + models: [{ id: "gpt-5.5", api: "openai-codex-responses" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.openai).toEqual({ + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "text-embedding-3-small" }], + }); + expect(res.config?.models?.providers?.["openai-codex"]).toEqual({ + auth: "oauth", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + headers: { Authorization: "Bearer token" }, + models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }], + }); + expectMigrationChangesToIncludeFragments(res.changes, [ + 'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses"', + 'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"', + "Skipped merging models.providers.openai-codex into models.providers.openai because provider-level defaults cannot be represented safely on merged models: models.providers.openai-codex.auth, models.providers.openai-codex.headers", + ]); + }); + + it("does not report a fixable legacy issue after blocked codex merge normalization already ran", () => { + const raw = { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + params: { store: true }, + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }], + }, + }, + }, + }; + const res = migrateLegacyConfigForTest(raw); + + expect(res.config).toBeNull(); + expect(res.changes).toEqual([]); + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).not.toContain( + "models.providers", + ); + }); + + it("merges distinct legacy model ids even when display names collide", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5-platform", name: "GPT-5.5" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", name: "GPT-5.5" }], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + expect(openai?.models).toEqual([ + { id: "gpt-5.5-platform", name: "GPT-5.5" }, + { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expectMigrationChangesToIncludeFragments(res.changes, [ + "Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5", + ]); + }); + + it("removes openai-codex when all its models already exist in canonical openai", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }], + }, + "openai-codex": { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }], + }, + }, + }, + }); + + const openai = res.config?.models?.providers?.openai as Record | undefined; + // All legacy models are already present; canonical provider unchanged + expect(openai?.models).toEqual([{ id: "gpt-5.5" }, { id: "gpt-5.4" }]); + expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); + expect(res.changes).toContain( + "Removed models.providers.openai-codex because models.providers.openai already exists.", + ); + }); + it("rewrites top-level legacy auto provider after moving memorySearch into agent defaults", () => { const raw = { memorySearch: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts index 5a7da9b4f7c..3df07fdbb24 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts @@ -8,7 +8,8 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; -import { isModelThinkingFormat } from "../../../config/types.models.js"; +import { isModelThinkingFormat, type ModelDefinitionConfig } from "../../../config/types.models.js"; +import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js"; const STALE_CONTEXT_WINDOW_FIXES: Record = { "deepseek/deepseek-v4-flash": { stale: 200_000, correct: 1_000_000 }, @@ -925,6 +926,33 @@ const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex"; const LEGACY_OPENAI_CODEX_RESPONSES_API = "openai-codex-responses"; const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses"; +const MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS = [ + "apiKey", + "auth", + "request", + "timeoutSeconds", + "region", + "injectNumCtxForOpenAICompat", + "localService", + "headers", + "authHeader", +] as const; +const CANONICAL_PROVIDER_MODEL_LEAK_KEYS = [ + "apiKey", + "auth", + "contextWindow", + "contextTokens", + "maxTokens", + "timeoutSeconds", + "region", + "injectNumCtxForOpenAICompat", + "params", + "agentRuntime", + "localService", + "headers", + "authHeader", + "request", +] as const; function hasCanonicalOpenAIProvider(providers: Record): boolean { return Object.keys(providers).some( @@ -972,6 +1000,193 @@ function normalizeLegacyOpenAIResponsesApi( return { value: next, changed }; } +function hasOwnDefinedProperty(record: Record, key: string): boolean { + return Object.hasOwn(record, key) && record[key] !== undefined; +} + +function collectModelMergeBlockers(params: { + canonical: Record; + legacy: Record; +}): string[] { + const blockers: string[] = []; + for (const key of MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS) { + if (hasOwnDefinedProperty(params.legacy, key)) { + blockers.push(`models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID}.${key}`); + } + } + for (const key of CANONICAL_PROVIDER_MODEL_LEAK_KEYS) { + if (hasOwnDefinedProperty(params.canonical, key)) { + blockers.push(`models.providers.${OPENAI_PROVIDER_ID}.${key}`); + } + } + return blockers; +} + +function getCanonicalOpenAIProviderEntry( + providers: Record, +): { key: string; value: Record } | undefined { + const key = Object.keys(providers).find((k) => normalizeProviderId(k) === OPENAI_PROVIDER_ID); + const value = key ? getRecord(providers[key]) : undefined; + return key && value ? { key, value } : undefined; +} + +function getMergeableLegacyOpenAIModels(params: { + canonical: Record; + legacy: Record; +}): unknown[] { + const legacyModels: unknown[] = Array.isArray(params.legacy.models) + ? (params.legacy.models as unknown[]) + : []; + const canonicalModels: unknown[] = Array.isArray(params.canonical.models) + ? (params.canonical.models as unknown[]) + : []; + const canonicalModelIds = new Set(); + const canonicalModelNames = new Set(); + for (const m of canonicalModels) { + const mr = getRecord(m); + if (typeof mr?.id === "string" && mr.id) { + canonicalModelIds.add(mr.id); + } + if (typeof mr?.name === "string" && mr.name) { + canonicalModelNames.add(mr.name); + } + } + return legacyModels.filter((m) => { + const mr = getRecord(m); + if (!mr) { + return false; + } + const id = typeof mr.id === "string" ? mr.id : undefined; + const name = typeof mr.name === "string" ? mr.name : undefined; + if (!id && !name) { + return false; + } + return id ? !canonicalModelIds.has(id) : name ? !canonicalModelNames.has(name) : false; + }); +} + +function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boolean { + const providers = getRecord(providersValue); + if (!providers) { + return false; + } + const canonicalEntry = getCanonicalOpenAIProviderEntry(providers); + for (const [providerId, providerValue] of Object.entries(providers)) { + const provider = getRecord(providerValue); + if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) { + continue; + } + const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []); + if (normalized.changed || !canonicalEntry) { + return true; + } + const modelsToMerge = getMergeableLegacyOpenAIModels({ + canonical: canonicalEntry.value, + legacy: normalized.value, + }); + if (modelsToMerge.length === 0) { + return true; + } + const mergeBlockers = collectModelMergeBlockers({ + canonical: canonicalEntry.value, + legacy: normalized.value, + }); + if (mergeBlockers.length === 0) { + return true; + } + } + return false; +} + +export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): string[] { + const models = getRecord(getRecord(raw)?.models); + const providers = getRecord(models?.providers); + const canonicalEntry = providers ? getCanonicalOpenAIProviderEntry(providers) : undefined; + if (!providers || !canonicalEntry) { + return []; + } + + const warnings: string[] = []; + for (const [providerId, providerValue] of Object.entries(providers)) { + const provider = getRecord(providerValue); + if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) { + continue; + } + const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []); + if (normalized.changed) { + continue; + } + const modelsToMerge = getMergeableLegacyOpenAIModels({ + canonical: canonicalEntry.value, + legacy: normalized.value, + }); + if (modelsToMerge.length === 0) { + continue; + } + const mergeBlockers = collectModelMergeBlockers({ + canonical: canonicalEntry.value, + legacy: normalized.value, + }); + if (mergeBlockers.length === 0) { + continue; + } + warnings.push( + `models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}. Move the affected model/provider defaults manually before removing models.providers.${providerId}.`, + ); + } + return warnings; +} + +function buildMergedLegacyOpenAIModel( + model: unknown, + legacyProvider: Record, +): unknown { + const modelRecord = getRecord(model); + if (!modelRecord) { + return model; + } + + const patch: Record = {}; + const legacyBaseUrl = + typeof legacyProvider.baseUrl === "string" ? legacyProvider.baseUrl : undefined; + const legacyApi = typeof legacyProvider.api === "string" ? legacyProvider.api : undefined; + const legacyParams = getRecord(legacyProvider.params); + const legacyAgentRuntime = getRecord(legacyProvider.agentRuntime); + + if (legacyBaseUrl && !modelRecord.baseUrl) { + patch.baseUrl = legacyBaseUrl; + } + if (legacyApi && !modelRecord.api) { + patch.api = legacyApi; + } + for (const key of ["contextWindow", "contextTokens", "maxTokens"] as const) { + if (typeof legacyProvider[key] === "number" && modelRecord[key] === undefined) { + patch[key] = legacyProvider[key]; + } + } + if (legacyParams) { + const modelParams = getRecord(modelRecord.params); + if (modelParams) { + patch.params = { ...legacyParams, ...modelParams }; + } else if (modelRecord.params === undefined) { + patch.params = legacyParams; + } + } + if (legacyAgentRuntime && modelRecord.agentRuntime === undefined) { + patch.agentRuntime = legacyAgentRuntime; + } + if ( + modelRecord.metadataSource === undefined && + isLegacyModelsAddCodexMetadataModel({ + provider: LEGACY_OPENAI_CODEX_PROVIDER_ID, + model: modelRecord as Partial, + }) + ) { + patch.metadataSource = "models-add"; + } + return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model; +} + function migrateLegacyOpenAICodexProvider(raw: Record, changes: string[]): void { const models = getRecord(raw.models); const providers = getRecord(models?.providers); @@ -981,7 +1196,7 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: let providersChanged = false; for (const [providerId, providerValue] of Object.entries({ ...providers })) { - const provider = getRecord(providerValue); + const provider = getRecord(providers[providerId]) ?? getRecord(providerValue); if (!provider) { continue; } @@ -1001,9 +1216,58 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: `Moved models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} → models.providers.${OPENAI_PROVIDER_ID}.`, ); } else { - changes.push( - `Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`, - ); + // Canonical openai provider already exists. Merge non-conflicting model + // entries from the legacy provider so disjoint models (e.g. a chat model + // on the Codex OAuth path alongside an embeddings-only openai provider) + // are preserved instead of silently dropped. (#90047) + const canonicalEntry = getCanonicalOpenAIProviderEntry(providers); + const canonicalKey = canonicalEntry?.key ?? OPENAI_PROVIDER_ID; + const canonical = canonicalEntry?.value ?? {}; + const canonicalModels: unknown[] = Array.isArray(canonical.models) + ? (canonical.models as unknown[]) + : []; + const modelsToMerge = getMergeableLegacyOpenAIModels({ + canonical, + legacy: normalized.value, + }); + const mergeBlockers = + modelsToMerge.length > 0 + ? collectModelMergeBlockers({ canonical, legacy: normalized.value }) + : []; + if (mergeBlockers.length > 0) { + if (normalized.changed) { + providers[providerId] = normalized.value; + providersChanged = true; + changes.push( + `Skipped merging models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}.`, + ); + } + continue; + } + // Stamp model-scoped legacy provider defaults onto each merged model so it + // keeps the Codex endpoint and runtime metadata instead of inheriting the + // canonical provider's OpenAI platform defaults. + const stamped = modelsToMerge.map((m) => buildMergedLegacyOpenAIModel(m, normalized.value)); + if (stamped.length > 0) { + providers[canonicalKey] = { ...canonical, models: [...canonicalModels, ...stamped] }; + const mergedIds = stamped + .map((m) => { + const mr = getRecord(m); + return typeof mr?.id === "string" && mr.id + ? mr.id + : typeof mr?.name === "string" && mr.name + ? mr.name + : "unknown"; + }) + .join(", "); + changes.push( + `Merged ${stamped.length} model(s) from models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID}: ${mergedIds}.`, + ); + } else { + changes.push( + `Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`, + ); + } } delete providers[providerId]; providersChanged = true; @@ -1038,14 +1302,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[ path: ["models", "providers"], message: 'models.providers.openai-codex is legacy; run "openclaw doctor --fix" to move it to models.providers.openai.', - match: (value) => { - const providers = getRecord(value); - return providers - ? Object.keys(providers).some( - (providerId) => normalizeProviderId(providerId) === LEGACY_OPENAI_CODEX_PROVIDER_ID, - ) - : false; - }, + match: (value) => hasAutoFixableLegacyOpenAICodexProvider(value), }, { path: ["models", "providers"], diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 80fae4fe1c0..d4e3908b39c 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -262,7 +262,7 @@ describe("doctor preview warnings", () => { }, }, }, - } as OpenClawConfig, + } as unknown as OpenClawConfig, doctorFixCommand: "openclaw doctor --fix", env: { CODEX_HOME: codexHome, HOME: root }, }); @@ -297,6 +297,36 @@ describe("doctor preview warnings", () => { ).toBe(true); }); + it("warns when a normalized legacy Codex provider cannot be auto-merged", async () => { + const warnings = await collectDoctorPreviewWarnings({ + cfg: { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + params: { store: true }, + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }], + }, + }, + }, + } as unknown as OpenClawConfig, + doctorFixCommand: "openclaw doctor --fix", + }); + + const warning = expectSingleWarningContaining( + warnings, + "models.providers.openai-codex cannot be merged automatically", + ); + expect(warning).toContain("models.providers.openai.params"); + expect(warning).toContain("Move the affected model/provider defaults manually"); + }); + it("sanitizes empty-allowlist warning paths before returning preview output", async () => { const warnings = await collectDoctorPreviewWarnings({ cfg: { diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index a7bc8e487cc..736bdb632bc 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -779,6 +779,9 @@ export async function collectDoctorPreviewNotes(params: { warnings.push(...collectVisibleReplyToolPolicyWarnings(params.cfg)); warnings.push(...collectChannelBoundMessageToolPolicyWarnings(params.cfg)); warnings.push(...collectProfileConfiguredToolSectionWarnings(params.cfg)); + const { collectBlockedLegacyOpenAICodexProviderWarnings } = + await import("./legacy-config-migrations.runtime.models.js"); + warnings.push(...collectBlockedLegacyOpenAICodexProviderWarnings(params.cfg)); const { collectActiveToolSchemaProjectionWarnings } = await import("./active-tool-schema-warnings.js");