Skip to content

Commit 2226e28

Browse files
committed
fix(doctor): scope Codex provider budgets to models
1 parent 32b9bbb commit 2226e28

2 files changed

Lines changed: 142 additions & 40 deletions

File tree

src/commands/doctor/shared/legacy-config-migrate.test.ts

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ describe("legacy memory search config migrate", () => {
730730
]);
731731
});
732732

733-
it("keeps canonical OpenAI model-level context budgets authoritative over legacy provider budgets", () => {
733+
it("keeps legacy provider-level Codex budgets when no model target can be identified", () => {
734734
const res = migrateLegacyConfigForTest({
735735
models: {
736736
providers: {
@@ -754,22 +754,24 @@ describe("legacy memory search config migrate", () => {
754754

755755
expect(res.config?.models?.providers?.openai).toEqual({
756756
api: "openai-chatgpt-responses",
757-
maxTokens: 16_384,
758757
models: [
759758
{
760759
id: "openai/gpt-5.5",
761760
contextWindow: 272_000,
762761
},
763762
],
764763
});
765-
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
764+
expect(res.config?.models?.providers?.["openai-codex"]).toEqual({
765+
contextTokens: 1_050_000,
766+
contextWindow: 1_050_000,
767+
maxTokens: 16_384,
768+
});
766769
expectMigrationChangesToIncludeFragments(res.changes, [
767-
"Copied models.providers.openai-codex maxTokens metadata to models.providers.openai.",
768-
"Removed models.providers.openai-codex because models.providers.openai already exists.",
770+
"Skipped removing models.providers.openai-codex because provider-level context metadata cannot be mapped safely to a canonical OpenAI model row.",
769771
]);
770772
});
771773

772-
it("keeps canonical OpenAI model-level context budgets authoritative over legacy provider budgets", () => {
774+
it("copies legacy provider-level Codex budgets onto matching canonical model rows", () => {
773775
const res = migrateLegacyConfigForTest({
774776
models: {
775777
providers: {
@@ -786,24 +788,29 @@ describe("legacy memory search config migrate", () => {
786788
contextTokens: 1_050_000,
787789
contextWindow: 1_050_000,
788790
maxTokens: 16_384,
791+
models: [
792+
{
793+
id: "openai-codex/gpt-5.5",
794+
},
795+
],
789796
},
790797
},
791798
},
792799
});
793800

794801
expect(res.config?.models?.providers?.openai).toEqual({
795802
api: "openai-chatgpt-responses",
796-
maxTokens: 16_384,
797803
models: [
798804
{
799-
id: "openai/gpt-5.5",
805+
id: "gpt-5.5",
800806
contextWindow: 272_000,
807+
maxTokens: 16_384,
801808
},
802809
],
803810
});
804811
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
805812
expectMigrationChangesToIncludeFragments(res.changes, [
806-
"Copied models.providers.openai-codex maxTokens metadata to models.providers.openai.",
813+
"Normalized models.providers.openai.models[0].id to gpt-5.5 and copied maxTokens metadata.",
807814
"Removed models.providers.openai-codex because models.providers.openai already exists.",
808815
]);
809816
});
@@ -852,6 +859,61 @@ describe("legacy memory search config migrate", () => {
852859
]);
853860
});
854861

862+
it("does not promote legacy provider-level Codex budgets to unrelated canonical OpenAI models", () => {
863+
const res = migrateLegacyConfigForTest({
864+
models: {
865+
providers: {
866+
openai: {
867+
api: "openai-chatgpt-responses",
868+
models: [
869+
{
870+
id: "text-embedding-3-small",
871+
name: "OpenAI embeddings",
872+
},
873+
],
874+
},
875+
"openai-codex": {
876+
api: "openai-codex-responses",
877+
baseUrl: "https://chatgpt.com/backend-api/codex",
878+
contextTokens: 1_050_000,
879+
contextWindow: 1_050_000,
880+
maxTokens: 16_384,
881+
models: [
882+
{
883+
id: "gpt-5.5",
884+
name: "Legacy Codex GPT-5.5",
885+
},
886+
],
887+
},
888+
},
889+
},
890+
});
891+
892+
expect(res.config?.models?.providers?.openai).toEqual({
893+
api: "openai-chatgpt-responses",
894+
models: [
895+
{
896+
id: "text-embedding-3-small",
897+
name: "OpenAI embeddings",
898+
},
899+
{
900+
id: "gpt-5.5",
901+
name: "Legacy Codex GPT-5.5",
902+
baseUrl: "https://chatgpt.com/backend-api/codex",
903+
api: "openai-chatgpt-responses",
904+
contextWindow: 1_050_000,
905+
contextTokens: 1_050_000,
906+
maxTokens: 16_384,
907+
},
908+
],
909+
});
910+
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
911+
expectMigrationChangesToIncludeFragments(res.changes, [
912+
'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses".',
913+
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5.",
914+
]);
915+
});
916+
855917
it("preserves legacy provider-level Codex context budgets on matching canonical rows while merging disjoint models", () => {
856918
const res = migrateLegacyConfigForTest({
857919
models: {

src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,14 +1043,8 @@ function hasProviderLevelContextBudget(provider: Record<string, unknown>): boole
10431043
return hasContextBudget(provider);
10441044
}
10451045

1046-
function hasModelLevelContextBudget(provider: Record<string, unknown>): boolean {
1047-
return (
1048-
Array.isArray(provider.models) &&
1049-
provider.models.some((model) => {
1050-
const modelRecord = getRecord(model);
1051-
return Boolean(modelRecord && hasContextBudget(modelRecord));
1052-
})
1053-
);
1046+
function hasProviderLevelContextMetadata(provider: Record<string, unknown>): boolean {
1047+
return OPENAI_CODEX_CONTEXT_METADATA_KEYS.some((key) => hasUsableContextMetadata(provider[key]));
10541048
}
10551049

10561050
function normalizeOpenAIModelIdForComparison(value: unknown): string | undefined {
@@ -1126,26 +1120,44 @@ function normalizeOpenAIModelEntryId(model: Record<string, unknown>, modelId: st
11261120
return true;
11271121
}
11281122

1123+
function hasMatchingLegacyOpenAIModelEntry(params: {
1124+
legacyProvider: Record<string, unknown>;
1125+
canonicalProvider: Record<string, unknown>;
1126+
}): boolean {
1127+
const canonicalModels = Array.isArray(params.canonicalProvider.models)
1128+
? params.canonicalProvider.models
1129+
: [];
1130+
const legacyModels = Array.isArray(params.legacyProvider.models)
1131+
? params.legacyProvider.models
1132+
: [];
1133+
return legacyModels.some((legacyModelValue) => {
1134+
const legacyModel = getRecord(legacyModelValue);
1135+
const modelId = canonicalOpenAIModelId(legacyModel?.id);
1136+
return Boolean(modelId && findOpenAIModelEntry(canonicalModels, modelId));
1137+
});
1138+
}
1139+
1140+
function hasUnmappedLegacyProviderContextMetadata(params: {
1141+
legacyProvider: Record<string, unknown>;
1142+
canonicalProvider: Record<string, unknown>;
1143+
modelsToMerge: unknown[];
1144+
}): boolean {
1145+
return (
1146+
hasProviderLevelContextMetadata(params.legacyProvider) &&
1147+
params.modelsToMerge.length === 0 &&
1148+
!hasMatchingLegacyOpenAIModelEntry({
1149+
legacyProvider: params.legacyProvider,
1150+
canonicalProvider: params.canonicalProvider,
1151+
})
1152+
);
1153+
}
1154+
11291155
function copyLegacyOpenAICodexContextMetadataToExistingCanonicalRows(params: {
11301156
legacyProvider: Record<string, unknown>;
11311157
canonicalProvider: Record<string, unknown>;
11321158
changes: string[];
1133-
skipProviderLevelMetadata?: boolean;
11341159
}): boolean {
11351160
let changed = false;
1136-
if (!params.skipProviderLevelMetadata) {
1137-
const providerKeys = copyMissingContextMetadata({
1138-
source: params.legacyProvider,
1139-
target: params.canonicalProvider,
1140-
blockContextBudget: hasModelLevelContextBudget(params.canonicalProvider),
1141-
});
1142-
if (providerKeys.length > 0) {
1143-
params.changes.push(
1144-
`Copied models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} ${providerKeys.join(", ")} metadata to models.providers.${OPENAI_PROVIDER_ID}.`,
1145-
);
1146-
changed = true;
1147-
}
1148-
}
11491161

11501162
const canonicalModels = Array.isArray(params.canonicalProvider.models)
11511163
? params.canonicalProvider.models
@@ -1169,13 +1181,11 @@ function copyLegacyOpenAICodexContextMetadataToExistingCanonicalRows(params: {
11691181
target: existing.model,
11701182
blockContextBudget: hasProviderLevelContextBudget(params.canonicalProvider),
11711183
});
1172-
const copiedProviderKeys = params.skipProviderLevelMetadata
1173-
? copyMissingContextMetadata({
1174-
source: params.legacyProvider,
1175-
target: existing.model,
1176-
blockContextBudget: hasProviderLevelContextBudget(params.canonicalProvider),
1177-
})
1178-
: [];
1184+
const copiedProviderKeys = copyMissingContextMetadata({
1185+
source: params.legacyProvider,
1186+
target: existing.model,
1187+
blockContextBudget: hasProviderLevelContextBudget(params.canonicalProvider),
1188+
});
11791189
const copiedKeys = [...copiedModelKeys, ...copiedProviderKeys];
11801190
if (copiedKeys.length === 0 && !normalizedId) {
11811191
continue;
@@ -1288,7 +1298,11 @@ function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boole
12881298
legacy: normalized.value,
12891299
});
12901300
if (modelsToMerge.length === 0) {
1291-
return true;
1301+
return !hasUnmappedLegacyProviderContextMetadata({
1302+
canonicalProvider: canonicalEntry.value,
1303+
legacyProvider: normalized.value,
1304+
modelsToMerge,
1305+
});
12921306
}
12931307
const mergeBlockers = collectModelMergeBlockers({
12941308
canonical: canonicalEntry.value,
@@ -1323,6 +1337,18 @@ export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): s
13231337
canonical: canonicalEntry.value,
13241338
legacy: normalized.value,
13251339
});
1340+
if (
1341+
hasUnmappedLegacyProviderContextMetadata({
1342+
canonicalProvider: canonicalEntry.value,
1343+
legacyProvider: normalized.value,
1344+
modelsToMerge,
1345+
})
1346+
) {
1347+
warnings.push(
1348+
`models.providers.${providerId} cannot be removed automatically into models.providers.${canonicalEntry.key} because provider-level context metadata cannot be mapped safely to a canonical OpenAI model row. Add an explicit models.providers.${canonicalEntry.key}.models[] row for the affected Codex model or move the affected defaults manually before removing models.providers.${providerId}.`,
1349+
);
1350+
continue;
1351+
}
13261352
if (modelsToMerge.length === 0) {
13271353
continue;
13281354
}
@@ -1434,11 +1460,25 @@ function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes:
14341460
canonical,
14351461
legacy: normalized.value,
14361462
});
1463+
const providerContextMetadataNeedsManualMapping = hasUnmappedLegacyProviderContextMetadata({
1464+
legacyProvider: normalized.value,
1465+
canonicalProvider: canonical,
1466+
modelsToMerge,
1467+
});
1468+
if (providerContextMetadataNeedsManualMapping) {
1469+
if (normalized.changed) {
1470+
providers[providerId] = normalized.value;
1471+
providersChanged = true;
1472+
}
1473+
changes.push(
1474+
`Skipped removing models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because provider-level context metadata cannot be mapped safely to a canonical OpenAI model row.`,
1475+
);
1476+
continue;
1477+
}
14371478
copyLegacyOpenAICodexContextMetadataToExistingCanonicalRows({
14381479
legacyProvider: normalized.value,
14391480
canonicalProvider: canonical,
14401481
changes,
1441-
skipProviderLevelMetadata: modelsToMerge.length > 0,
14421482
});
14431483
const mergeBlockers =
14441484
modelsToMerge.length > 0

0 commit comments

Comments
 (0)