fix(doctor): merge legacy codex models safely

Merge disjoint legacy openai-codex model entries into the canonical openai provider without losing safe per-model metadata, params, or models-add markers.

Unsafe provider-level defaults, auth/header/request state, and blocked normalized legacy providers are now preserved for manual cleanup with doctor preview warnings instead of being silently copied into models or repeatedly reported as auto-fixable.

Fixes #90047

Co-authored-by: openperf <16864032@qq.com>
This commit is contained in:
Chunyue Wang
2026-06-07 16:59:31 +08:00
committed by GitHub
parent 3e4b10fa1c
commit e06f6ffc3e
4 changed files with 718 additions and 14 deletions

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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: {

View File

@@ -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<string, { stale: number; correct: number }> = {
"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<string, unknown>): boolean {
return Object.keys(providers).some(
@@ -972,6 +1000,193 @@ function normalizeLegacyOpenAIResponsesApi(
return { value: next, changed };
}
function hasOwnDefinedProperty(record: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(record, key) && record[key] !== undefined;
}
function collectModelMergeBlockers(params: {
canonical: Record<string, unknown>;
legacy: Record<string, unknown>;
}): 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<string, unknown>,
): { key: string; value: Record<string, unknown> } | 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<string, unknown>;
legacy: Record<string, unknown>;
}): 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<string>();
const canonicalModelNames = new Set<string>();
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<string, unknown>,
): unknown {
const modelRecord = getRecord(model);
if (!modelRecord) {
return model;
}
const patch: Record<string, unknown> = {};
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<ModelDefinitionConfig>,
})
) {
patch.metadataSource = "models-add";
}
return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model;
}
function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes: string[]): void {
const models = getRecord(raw.models);
const providers = getRecord(models?.providers);
@@ -981,7 +1196,7 @@ function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, 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<string, unknown>, 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"],

View File

@@ -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: {

View File

@@ -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");