diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 9894c753e0f..77299be60c0 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -342,6 +342,40 @@ describe("normalizeCompatibilityConfigValues", () => { ); }); + it("migrates legacy OpenAI provider api values to OpenAI completions", () => { + const res = normalizeCompatibilityConfigValues({ + models: { + providers: { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + api: "openai", + models: [ + { + id: "openai/gpt-4o-mini", + name: "OpenRouter GPT-4o Mini", + api: "openai", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig); + + expect(res.config.models?.providers?.openrouter?.api).toBe("openai-completions"); + expect(res.config.models?.providers?.openrouter?.models?.[0]?.api).toBe("openai-completions"); + expect(res.changes).toContain( + 'Moved models.providers.openrouter.api "openai" → "openai-completions".', + ); + expect(res.changes).toContain( + 'Moved models.providers.openrouter.models[0].api "openai" → "openai-completions".', + ); + }); + it("marks legacy untagged /models add OpenAI Codex metadata rows for doctor repair", () => { const res = normalizeCompatibilityConfigValues({ models: { diff --git a/src/commands/doctor/shared/legacy-config-compatibility-base.ts b/src/commands/doctor/shared/legacy-config-compatibility-base.ts index c0464f84bb4..c6cd288753d 100644 --- a/src/commands/doctor/shared/legacy-config-compatibility-base.ts +++ b/src/commands/doctor/shared/legacy-config-compatibility-base.ts @@ -4,6 +4,7 @@ import { normalizeLegacyCrossContextMessageConfig, normalizeLegacyMediaProviderOptions, normalizeLegacyMistralModelMaxTokens, + normalizeLegacyOpenAIModelProviderApi, normalizeLegacyRuntimeModelRefs, normalizeLegacyNanoBananaSkill, normalizeLegacyTalkConfig, @@ -37,6 +38,7 @@ export function normalizeBaseCompatibilityConfigValues( next = normalizeLegacyNanoBananaSkill(next, changes); next = normalizeLegacyTalkConfig(next, changes); + next = normalizeLegacyOpenAIModelProviderApi(next, changes); next = normalizeLegacyRuntimeModelRefs(next, changes); next = normalizeLegacyCrossContextMessageConfig(next, changes); next = normalizeLegacyMediaProviderOptions(next, changes); diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index a576e58d7f6..cc4b287ed81 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -390,9 +390,10 @@ export function normalizeLegacyOpenAICodexModelsAddMetadata( return cfg; } + const rawProviders: Record = rawModels.providers; let providersChanged = false; - const nextProviders = { ...rawModels.providers }; - for (const [providerId, rawProvider] of Object.entries(rawModels.providers)) { + const nextProviders: Record = { ...rawProviders }; + for (const [providerId, rawProvider] of Object.entries(rawProviders)) { if (normalizeProviderId(providerId) !== "openai-codex" || !isRecord(rawProvider)) { continue; } @@ -413,7 +414,7 @@ export function normalizeLegacyOpenAICodexModelsAddMetadata( ) { providerChanged = true; const safeProviderId = sanitizeForLog(providerId); - const safeModelId = sanitizeForLog(model.id); + const safeModelId = sanitizeForLog(normalizeOptionalString(model.id) ?? "unknown"); changes.push( `Marked models.providers.${safeProviderId}.models.${safeModelId} as /models add metadata so official OpenAI Codex metadata can override it.`, ); @@ -446,6 +447,77 @@ export function normalizeLegacyOpenAICodexModelsAddMetadata( }; } +export function normalizeLegacyOpenAIModelProviderApi( + cfg: OpenClawConfig, + changes: string[], +): OpenClawConfig { + const rawModels = cfg.models; + if (!isRecord(rawModels) || !isRecord(rawModels.providers)) { + return cfg; + } + + const rawProviders: Record = rawModels.providers; + let providersChanged = false; + const nextProviders: Record = { ...rawProviders }; + for (const [providerId, rawProvider] of Object.entries(rawProviders)) { + if (!isRecord(rawProvider)) { + continue; + } + + let providerChanged = false; + const nextProvider: Record = { ...rawProvider }; + if (nextProvider.api === "openai") { + nextProvider.api = "openai-completions"; + providerChanged = true; + changes.push( + `Moved models.providers.${sanitizeForLog(providerId)}.api "openai" → "openai-completions".`, + ); + } + + const rawProviderModels = rawProvider.models; + if (Array.isArray(rawProviderModels)) { + let modelsChanged = false; + const nextModels: unknown[] = []; + rawProviderModels.forEach((model, index) => { + if (!isRecord(model) || model.api !== "openai") { + nextModels.push(model); + return; + } + modelsChanged = true; + changes.push( + `Moved models.providers.${sanitizeForLog(providerId)}.models[${index}].api "openai" → "openai-completions".`, + ); + nextModels.push({ + ...model, + api: "openai-completions", + }); + }); + if (modelsChanged) { + nextProvider.models = nextModels; + providerChanged = true; + } + } + + if (!providerChanged) { + continue; + } + nextProviders[providerId] = nextProvider; + providersChanged = true; + } + + if (!providersChanged) { + return cfg; + } + + return { + ...cfg, + models: { + ...rawModels, + providers: nextProviders as NonNullable["providers"], + }, + }; +} + export function normalizeLegacyNanoBananaSkill( cfg: OpenClawConfig, changes: string[],