refactor(doctor): split legacy model migrations (#113717)

This commit is contained in:
Peter Steinberger
2026-07-25 08:52:43 -07:00
committed by GitHub
parent 1e93e78ee1
commit 1bfd207a54
6 changed files with 1972 additions and 1974 deletions
-1
View File
@@ -660,7 +660,6 @@ src/commands/doctor/shared/codex-route-warnings.test.ts
src/commands/doctor/shared/legacy-config-core-normalizers.ts
src/commands/doctor/shared/legacy-config-migrate.test.ts
src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts
src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts
src/commands/doctor/shared/missing-configured-plugin-install.test.ts
src/commands/doctor/shared/missing-configured-plugin-install.ts
src/commands/doctor/shared/preview-warnings.test.ts
@@ -0,0 +1,298 @@
import { isDeepStrictEqual } from "node:util";
import type {
ModelCatalog,
NormalizedModelCatalogRow,
} from "@openclaw/model-catalog-core/model-catalog-types";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import {
modelTransportRoutesMatch,
resolveUniqueCatalogModelRoute,
} from "../../../agents/model-compat-catalog.js";
import { getRecord, type LegacyConfigRule } from "../../../config/legacy.shared.js";
import { isModelThinkingFormat } from "../../../config/types.models.js";
import { planManifestModelCatalogRows } from "../../../model-catalog/manifest-planner.js";
import { listOpenClawPluginManifestMetadata } from "../../../plugins/manifest-metadata-scan.js";
const STALE_CONTEXT_WINDOW_FIXES: Record<string, { stale: number; correct: number }> = {
"deepseek/deepseek-v4-flash": { stale: 200_000, correct: 1_000_000 },
"xai/grok-4.20-0309-reasoning": { stale: 2_000_000, correct: 1_000_000 },
"xai/grok-4.20-0309-non-reasoning": { stale: 2_000_000, correct: 1_000_000 },
"xai/grok-4.20-beta-latest-reasoning": { stale: 2_000_000, correct: 1_000_000 },
"xai/grok-4.20-beta-latest-non-reasoning": { stale: 2_000_000, correct: 1_000_000 },
"xai/grok-4.20-experimental-beta-0304-reasoning": {
stale: 2_000_000,
correct: 1_000_000,
},
"xai/grok-4.20-experimental-beta-0304-non-reasoning": {
stale: 2_000_000,
correct: 1_000_000,
},
"xai/grok-4.20-reasoning": { stale: 2_000_000, correct: 1_000_000 },
"xai/grok-4.20-non-reasoning": { stale: 2_000_000, correct: 1_000_000 },
} as const;
const DEAD_MODEL_COMPAT_KEYS = ["nativeWebSearchTool", "requiresMistralToolIds"] as const;
type ModelCompatOverrideState = { dead: number; divergent: number; matching: number };
function normalizedCatalogModelKey(provider: string, modelId: string): string {
// Keep doctor identity aligned with runtime catalog lookup and merge keys,
// which intentionally treat provider/model ids case-insensitively.
const normalizedProvider = normalizeProviderId(provider);
const normalizedId = modelId.trim().toLowerCase();
const providerPrefix = `${normalizedProvider}/`;
return `${normalizedProvider}::${normalizedId.startsWith(providerPrefix) ? normalizedId.slice(providerPrefix.length) : normalizedId}`;
}
// Manifest metadata is process-stable; plugin installs/reloads restart the owning process.
const modelCompatCatalogRowsByProvider = new Map<string, readonly NormalizedModelCatalogRow[]>();
let modelCompatCatalogPlugins:
| Array<{ id: string; modelCatalog: ModelCatalog; providers: string[] }>
| undefined;
function getModelCompatCatalogPlugins() {
modelCompatCatalogPlugins ??= listOpenClawPluginManifestMetadata().flatMap(({ manifest }) => {
const id = typeof manifest.id === "string" ? manifest.id.trim() : "";
const modelCatalog = getRecord(manifest.modelCatalog);
if (!id || !modelCatalog) {
return [];
}
return [
{
id,
providers: Array.isArray(manifest.providers)
? manifest.providers.filter((value): value is string => typeof value === "string")
: [],
modelCatalog: modelCatalog as ModelCatalog,
},
];
});
return modelCompatCatalogPlugins;
}
function buildConfiguredProviderCatalogRows(
providers: Record<string, unknown>,
): Map<string, NormalizedModelCatalogRow[]> {
const rows = new Map<string, NormalizedModelCatalogRow[]>();
for (const providerId of Object.keys(providers)) {
const normalizedProviderId = normalizeProviderId(providerId);
let providerRows = modelCompatCatalogRowsByProvider.get(normalizedProviderId);
if (!providerRows) {
providerRows = planManifestModelCatalogRows({
registry: { plugins: getModelCompatCatalogPlugins() },
providerFilter: normalizedProviderId,
}).rows;
modelCompatCatalogRowsByProvider.set(normalizedProviderId, providerRows);
}
for (const row of providerRows) {
const key = normalizedCatalogModelKey(row.provider, row.id);
const variants = rows.get(key) ?? [];
variants.push(row);
rows.set(key, variants);
}
}
return rows;
}
function inspectModelCompatOverrides(
providersValue: unknown,
onEntry?: (params: {
catalogRow?: NormalizedModelCatalogRow;
compat: Record<string, unknown>;
model: Record<string, unknown>;
modelIndex: number;
provider: Record<string, unknown>;
providerId: string;
state: ModelCompatOverrideState;
}) => void,
): ModelCompatOverrideState {
const providers = getRecord(providersValue);
const total = { dead: 0, divergent: 0, matching: 0 };
if (!providers) {
return total;
}
const hasCompat = Object.values(providers).some((providerValue) => {
const models = getRecord(providerValue)?.models;
return (
Array.isArray(models) &&
models.some((modelValue) => Boolean(getRecord(getRecord(modelValue)?.compat)))
);
});
if (!hasCompat) {
return total;
}
const catalogRows = buildConfiguredProviderCatalogRows(providers);
for (const [providerId, providerValue] of Object.entries(providers)) {
const provider = getRecord(providerValue);
const models = provider?.models;
if (!provider || !Array.isArray(models)) {
continue;
}
for (const [modelIndex, modelValue] of models.entries()) {
const model = getRecord(modelValue);
const compat = getRecord(model?.compat);
const modelId = typeof model?.id === "string" ? model.id : "";
if (!model || !compat || !modelId) {
continue;
}
const state = { dead: 0, divergent: 0, matching: 0 };
for (const key of DEAD_MODEL_COMPAT_KEYS) {
if (Object.hasOwn(compat, key)) {
state.dead += 1;
}
}
const configuredRoute = {
api: model.api ?? provider.api,
baseUrl: model.baseUrl ?? provider.baseUrl,
};
const catalogRow = resolveUniqueCatalogModelRoute(
catalogRows.get(normalizedCatalogModelKey(providerId, modelId)),
configuredRoute,
);
const catalogRouteMatches = catalogRow !== undefined;
if (catalogRouteMatches) {
const catalogCompat = catalogRow.compat ?? {};
for (const [key, value] of Object.entries(compat)) {
if ((DEAD_MODEL_COMPAT_KEYS as readonly string[]).includes(key)) {
continue;
}
if (isDeepStrictEqual(value, catalogCompat[key as keyof typeof catalogCompat])) {
state.matching += 1;
} else {
state.divergent += 1;
}
}
}
total.dead += state.dead;
total.divergent += state.divergent;
total.matching += state.matching;
onEntry?.({ catalogRow, compat, model, modelIndex, provider, providerId, state });
}
}
return total;
}
export const MODEL_COMPAT_CATALOG_RULES: LegacyConfigRule[] = [
{
path: ["models", "providers"],
message:
'nativeWebSearchTool and requiresMistralToolIds are unused and retired; run "openclaw doctor --fix" to remove them.',
match: (value) => inspectModelCompatOverrides(value).dead > 0,
},
{
path: ["models", "providers"],
message:
'Catalog-known model compat values are provider-owned; run "openclaw doctor --fix" to remove matching config overrides.',
match: (value) => inspectModelCompatOverrides(value).matching > 0,
},
{
path: ["models", "providers"],
message:
"Catalog-known model compat differs from the provider catalog and was preserved for review. Use a distinct custom route when the endpoint really has different capabilities.",
match: (value) => inspectModelCompatOverrides(value).divergent > 0,
},
];
export function migrateModelCompatCatalogOwnership(
raw: Record<string, unknown>,
changes: string[],
): void {
const providers = getRecord(getRecord(raw.models)?.providers);
inspectModelCompatOverrides(
providers,
({ catalogRow, compat, model, modelIndex, provider, providerId }) => {
const removed: string[] = [];
for (const key of DEAD_MODEL_COMPAT_KEYS) {
if (Object.hasOwn(compat, key)) {
delete compat[key];
removed.push(key);
}
}
if (
catalogRow &&
modelTransportRoutesMatch(catalogRow, {
api: model.api ?? provider.api ?? catalogRow.api,
baseUrl: model.baseUrl ?? provider.baseUrl ?? catalogRow.baseUrl,
})
) {
const catalogCompat = catalogRow.compat ?? {};
for (const [key, value] of Object.entries(compat)) {
if (isDeepStrictEqual(value, catalogCompat[key as keyof typeof catalogCompat])) {
delete compat[key];
removed.push(key);
}
}
}
if (removed.length === 0) {
return;
}
if (Object.keys(compat).length === 0) {
delete model.compat;
}
changes.push(
`Removed models.providers.${providerId}.models.${modelIndex}.compat catalog/dead overrides: ${removed.toSorted().join(", ")}.`,
);
},
);
}
export function resolveStaleContextWindowFix(params: {
providerId: string;
modelId: string;
contextWindow: number;
}): { stale: number; correct: number } | undefined {
const providerId = params.providerId.trim().toLowerCase();
const modelId = params.modelId.trim().toLowerCase();
const providerPrefix = `${providerId}/`;
const unprefixedModelId = modelId.startsWith(providerPrefix)
? modelId.slice(providerPrefix.length)
: modelId;
const scopedModelId = `${providerId}/${unprefixedModelId}`;
const fix = STALE_CONTEXT_WINDOW_FIXES[scopedModelId];
return fix && params.contextWindow === fix.stale ? fix : undefined;
}
export function hasStaleContextWindowValue(providers: unknown): boolean {
const providersRecord = getRecord(providers);
if (!providersRecord) {
return false;
}
for (const [providerId, provider] of Object.entries(providersRecord)) {
const models = getRecord(provider)?.models;
if (!Array.isArray(models)) {
continue;
}
for (const model of models) {
const modelRecord = getRecord(model);
const modelId = typeof modelRecord?.id === "string" ? modelRecord.id : undefined;
const contextWindow = modelRecord?.contextWindow;
if (!modelId || typeof contextWindow !== "number" || !Number.isFinite(contextWindow)) {
continue;
}
if (resolveStaleContextWindowFix({ providerId, modelId, contextWindow })) {
return true;
}
}
}
return false;
}
export function hasInvalidThinkingFormat(providers: unknown): boolean {
const providersRecord = getRecord(providers);
if (!providersRecord) {
return false;
}
for (const provider of Object.values(providersRecord)) {
const models = getRecord(provider)?.models;
if (!Array.isArray(models)) {
continue;
}
for (const model of models) {
const compat = getRecord(getRecord(model)?.compat);
const thinkingFormat = compat?.thinkingFormat;
if (typeof thinkingFormat === "string" && !isModelThinkingFormat(thinkingFormat)) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,607 @@
import { isDeepStrictEqual } from "node:util";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalAgentRuntimeId } from "../../../agents/agent-runtime-id.js";
import { getRecord, type LegacyConfigRule } from "../../../config/legacy.shared.js";
import type { ModelDefinitionConfig } from "../../../config/types.models.js";
import {
isLegacyCodexProviderId,
legacyCodexProviderIdentityKey,
type LegacyCodexModelIdentity,
} from "./codex-route-model-ref.js";
import {
RETIRED_MODEL_REF_MESSAGE,
hasOwnDefinedProperty,
scanKnownModelRefs,
} from "./legacy-config-migrations.runtime.models.refs.js";
import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js";
export 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(
(providerId) => normalizeProviderId(providerId) === OPENAI_PROVIDER_ID,
);
}
function normalizeLegacyOpenAIResponsesApi(
providerId: string,
provider: Record<string, unknown>,
changes: string[],
): { value: Record<string, unknown>; changed: boolean } {
let changed = false;
const next: Record<string, unknown> = { ...provider };
if (next.api === LEGACY_OPENAI_CODEX_RESPONSES_API) {
next.api = OPENAI_CHATGPT_RESPONSES_API;
changes.push(
`Moved models.providers.${providerId}.api "${LEGACY_OPENAI_CODEX_RESPONSES_API}" → "${OPENAI_CHATGPT_RESPONSES_API}".`,
);
changed = true;
}
if (Array.isArray(provider.models)) {
let modelsChanged = false;
const nextModels = provider.models.map((model, index) => {
const modelRecord = getRecord(model);
if (!modelRecord || modelRecord.api !== LEGACY_OPENAI_CODEX_RESPONSES_API) {
return model;
}
modelsChanged = true;
changes.push(
`Moved models.providers.${providerId}.models[${index}].api "${LEGACY_OPENAI_CODEX_RESPONSES_API}" → "${OPENAI_CHATGPT_RESPONSES_API}".`,
);
return {
...modelRecord,
api: OPENAI_CHATGPT_RESPONSES_API,
};
});
if (modelsChanged) {
next.models = nextModels;
changed = true;
}
}
return { value: next, changed };
}
function collectModelMergeBlockers(params: {
canonical: Record<string, unknown>;
legacy: Record<string, unknown>;
legacyProviderId: string;
}): string[] {
const blockers: string[] = [];
for (const key of MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS) {
if (hasOwnDefinedProperty(params.legacy, key)) {
blockers.push(`models.providers.${params.legacyProviderId}.${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 collectLegacyModelPolicyWildcardPaths(raw: unknown): Map<string, string[]> {
const pathsByProvider = new Map<string, string[]>();
const agents = getRecord(getRecord(raw)?.agents);
const scopes: Array<{ value: unknown; path: string }> = [
{ value: getRecord(agents?.defaults)?.modelPolicy, path: "agents.defaults.modelPolicy" },
];
const list = Array.isArray(agents?.list) ? agents.list : [];
for (const [index, agent] of list.entries()) {
scopes.push({
value: getRecord(agent)?.modelPolicy,
path: `agents.list.${index}.modelPolicy`,
});
}
for (const scope of scopes) {
const allow = getRecord(scope.value)?.allow;
if (!Array.isArray(allow)) {
continue;
}
for (const [index, entry] of allow.entries()) {
if (typeof entry !== "string" || !entry.trim().endsWith("/*")) {
continue;
}
const provider = normalizeProviderId(entry.trim().slice(0, -2));
if (!isLegacyCodexProviderId(provider)) {
continue;
}
const paths = pathsByProvider.get(provider) ?? [];
paths.push(`${scope.path}.allow.${index}`);
pathsByProvider.set(provider, paths);
}
}
return pathsByProvider;
}
export function hasAutoFixableLegacyOpenAICodexProvider(
providersValue: unknown,
root?: Record<string, unknown>,
): boolean {
const providers = getRecord(providersValue);
if (!providers) {
return false;
}
const wildcardPaths = collectLegacyModelPolicyWildcardPaths(root);
const canonicalEntry = getCanonicalOpenAIProviderEntry(providers);
for (const [providerId, providerValue] of Object.entries(providers)) {
const provider = getRecord(providerValue);
if (!provider || !isLegacyCodexProviderId(providerId)) {
continue;
}
if (wildcardPaths.has(normalizeProviderId(providerId))) {
continue;
}
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []);
if (normalized.changed || !canonicalEntry) {
return true;
}
const modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({
canonical: canonicalEntry.value,
legacy: normalized.value,
legacyProviderId: providerId,
});
if (modelCollisions.length > 0) {
continue;
}
const modelsToMerge = getMergeableLegacyOpenAIModels({
canonical: canonicalEntry.value,
legacy: normalized.value,
});
if (modelsToMerge.length === 0) {
return true;
}
const mergeBlockers = collectModelMergeBlockers({
canonical: canonicalEntry.value,
legacy: normalized.value,
legacyProviderId: providerId,
});
if (mergeBlockers.length === 0) {
return true;
}
}
return false;
}
export type BlockedLegacyOpenAICodexProviderPlan = {
blockedModelIdentities: LegacyCodexModelIdentity[];
warning?: string;
};
/** Compute the provider-merge blockers once so every doctor state repair shares the decision. */
export function collectBlockedLegacyOpenAICodexProviderPlan(
raw: unknown,
): BlockedLegacyOpenAICodexProviderPlan {
const models = getRecord(getRecord(raw)?.models);
const providers = getRecord(models?.providers);
const canonicalEntry = providers ? getCanonicalOpenAIProviderEntry(providers) : undefined;
const blockedModelIdentities = new Set<LegacyCodexModelIdentity>();
const warningLines: string[] = [];
for (const [providerId, paths] of collectLegacyModelPolicyWildcardPaths(raw)) {
const identity = legacyCodexProviderIdentityKey(providerId);
if (identity) {
blockedModelIdentities.add(identity);
}
warningLines.push(
`- ${paths.join(", ")} cannot migrate automatically because ${providerId}/* would become openai/* and authorize unrelated OpenAI models.`,
);
}
if (!providers || !canonicalEntry) {
return buildBlockedLegacyOpenAICodexProviderPlan(blockedModelIdentities, warningLines);
}
for (const [providerId, providerValue] of Object.entries(providers)) {
const provider = getRecord(providerValue);
if (!provider || !isLegacyCodexProviderId(providerId)) {
continue;
}
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []);
const modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({
canonical: canonicalEntry.value,
legacy: normalized.value,
legacyProviderId: providerId,
});
if (modelCollisions.length > 0) {
const identity = legacyCodexProviderIdentityKey(providerId);
if (identity) {
blockedModelIdentities.add(identity);
}
warningLines.push(
`- models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because colliding model definitions differ for: ${modelCollisions.join(", ")}.`,
);
continue;
}
const modelsToMerge = getMergeableLegacyOpenAIModels({
canonical: canonicalEntry.value,
legacy: normalized.value,
});
if (modelsToMerge.length === 0) {
continue;
}
const mergeBlockers = collectModelMergeBlockers({
canonical: canonicalEntry.value,
legacy: normalized.value,
legacyProviderId: providerId,
});
if (mergeBlockers.length === 0) {
continue;
}
const identity = legacyCodexProviderIdentityKey(providerId);
if (identity) {
blockedModelIdentities.add(identity);
}
warningLines.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(", ")}.`,
);
}
// Intentionally fail closed: retained legacy refs are NOT executable until
// reconciled (the live codex provider is gone, and a hidden resolver/auth
// shim is forbidden by policy). Only hand-authored models.providers.codex
// definitions can reach this state; the warning names the exact repair.
return buildBlockedLegacyOpenAICodexProviderPlan(blockedModelIdentities, warningLines);
}
function buildBlockedLegacyOpenAICodexProviderPlan(
blockedModelIdentities: ReadonlySet<LegacyCodexModelIdentity>,
warningLines: string[],
): BlockedLegacyOpenAICodexProviderPlan {
return {
blockedModelIdentities: [...blockedModelIdentities],
...(warningLines.length > 0
? {
warning: [
"Legacy Codex provider routes require manual reconciliation before matching refs can migrate.",
...warningLines,
"- Doctor retained matching legacy refs in config, sessions, and cron. These refs will not execute until reconciled: fix the model route/auth metadata, remove the legacy provider entry, then rerun `openclaw doctor --fix`.",
].join("\n"),
}
: {}),
};
}
function resolveMovedCodexModelRuntime(params: {
legacyProviderId: string;
legacyProvider: Record<string, unknown>;
model: Record<string, unknown>;
}): Record<string, unknown> | undefined {
if (normalizeProviderId(params.legacyProviderId) !== "codex") {
return undefined;
}
const modelRuntime = getRecord(params.model.agentRuntime);
const modelRuntimeId = normalizeOptionalAgentRuntimeId(modelRuntime?.id);
if (modelRuntimeId && modelRuntimeId !== "auto") {
return undefined;
}
if (modelRuntimeId === "auto") {
return { ...modelRuntime, id: "codex" };
}
const providerRuntime = getRecord(params.legacyProvider.agentRuntime);
const providerRuntimeId = normalizeOptionalAgentRuntimeId(providerRuntime?.id);
// Converting provider-level auto must keep its sibling policy fields
// (e.g. fallback: "none"), matching the model-level branch above.
return providerRuntimeId && providerRuntimeId !== "auto"
? (providerRuntime ?? undefined)
: { ...providerRuntime, id: "codex" };
}
function buildMergedLegacyOpenAIModel(
model: unknown,
legacyProvider: Record<string, unknown>,
legacyProviderId: string,
): 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);
const movedCodexRuntime = resolveMovedCodexModelRuntime({
legacyProviderId,
legacyProvider,
model: modelRecord,
});
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 (movedCodexRuntime) {
patch.agentRuntime = movedCodexRuntime;
} else if (legacyAgentRuntime && modelRecord.agentRuntime === undefined) {
patch.agentRuntime = legacyAgentRuntime;
}
if (
modelRecord.metadataSource === undefined &&
isLegacyModelsAddCodexMetadataModel({
provider: legacyProviderId,
model: modelRecord as Partial<ModelDefinitionConfig>,
})
) {
patch.metadataSource = "models-add";
}
return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model;
}
function collectNonEquivalentLegacyOpenAIModelCollisions(params: {
canonical: Record<string, unknown>;
legacy: Record<string, unknown>;
legacyProviderId: string;
}): string[] {
const canonicalModels = Array.isArray(params.canonical.models) ? params.canonical.models : [];
const legacyModels = Array.isArray(params.legacy.models) ? params.legacy.models : [];
const conflicts = new Set<string>();
for (const legacyModel of legacyModels) {
const legacyRecord = getRecord(legacyModel);
const legacyId = typeof legacyRecord?.id === "string" ? legacyRecord.id : undefined;
const legacyName = typeof legacyRecord?.name === "string" ? legacyRecord.name : undefined;
if (!legacyRecord || (!legacyId && !legacyName)) {
continue;
}
const collisions = canonicalModels.filter((canonicalModel) => {
const canonicalRecord = getRecord(canonicalModel);
return legacyId ? canonicalRecord?.id === legacyId : canonicalRecord?.name === legacyName;
});
if (collisions.length === 0) {
continue;
}
const legacyEffective = buildMergedLegacyOpenAIModel(
legacyModel,
params.legacy,
params.legacyProviderId,
);
const definitionsMatch = collisions.every((canonicalModel) => {
const canonicalEffective = buildMergedLegacyOpenAIModel(
canonicalModel,
params.canonical,
OPENAI_PROVIDER_ID,
);
if (!isDeepStrictEqual(canonicalEffective, legacyEffective)) {
return false;
}
return MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS.every((key) =>
isDeepStrictEqual(params.canonical[key], params.legacy[key]),
);
});
if (!definitionsMatch) {
conflicts.add(legacyId ?? legacyName ?? "unknown");
}
}
return [...conflicts];
}
function prepareLegacyCodexProviderForCanonicalMove(
providerId: string,
provider: Record<string, unknown>,
): Record<string, unknown> {
if (normalizeProviderId(providerId) !== "codex" || !Array.isArray(provider.models)) {
return provider;
}
return {
...provider,
models: provider.models.map((model) => {
const record = getRecord(model);
if (!record) {
return model;
}
const agentRuntime = resolveMovedCodexModelRuntime({
legacyProviderId: providerId,
legacyProvider: provider,
model: record,
});
return agentRuntime ? { ...record, agentRuntime } : model;
}),
};
}
export function migrateLegacyOpenAICodexProvider(
raw: Record<string, unknown>,
changes: string[],
): void {
const models = getRecord(raw.models);
const providers = getRecord(models?.providers);
if (!models || !providers) {
return;
}
let providersChanged = false;
const wildcardPaths = collectLegacyModelPolicyWildcardPaths(raw);
for (const [providerId, providerValue] of Object.entries({ ...providers })) {
const provider = getRecord(providers[providerId]) ?? getRecord(providerValue);
if (!provider) {
continue;
}
if (isLegacyCodexProviderId(providerId) && wildcardPaths.has(normalizeProviderId(providerId))) {
continue;
}
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, changes);
if (!isLegacyCodexProviderId(providerId)) {
if (normalized.changed) {
providers[providerId] = normalized.value;
providersChanged = true;
}
continue;
}
if (!hasCanonicalOpenAIProvider(providers)) {
providers[OPENAI_PROVIDER_ID] = prepareLegacyCodexProviderForCanonicalMove(
providerId,
normalized.value,
);
changes.push(
`Moved models.providers.${providerId} → models.providers.${OPENAI_PROVIDER_ID}.`,
);
} else {
// 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 modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({
canonical,
legacy: normalized.value,
legacyProviderId: providerId,
});
const modelsToMerge = getMergeableLegacyOpenAIModels({
canonical,
legacy: normalized.value,
});
const mergeBlockers =
modelCollisions.length === 0 && modelsToMerge.length > 0
? collectModelMergeBlockers({
canonical,
legacy: normalized.value,
legacyProviderId: providerId,
})
: [];
if (modelCollisions.length > 0 || mergeBlockers.length > 0) {
if (normalized.changed) {
providers[providerId] = normalized.value;
providersChanged = true;
changes.push(
modelCollisions.length > 0
? `Skipped merging models.providers.${providerId} into models.providers.${OPENAI_PROVIDER_ID} because colliding model definitions differ for: ${modelCollisions.join(", ")}.`
: `Skipped merging models.providers.${providerId} 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, providerId),
);
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.${providerId} into models.providers.${OPENAI_PROVIDER_ID}: ${mergedIds}.`,
);
} else {
changes.push(
`Removed models.providers.${providerId} because models.providers.${OPENAI_PROVIDER_ID} already exists.`,
);
}
}
delete providers[providerId];
providersChanged = true;
}
if (providersChanged) {
models.providers = providers;
}
}
export const RETIRED_MODEL_REF_RULES: LegacyConfigRule[] = [
"agents",
"plugins",
"messages",
"tools",
"hooks",
"channels",
"models",
].map((section) => ({
path: [section],
message: RETIRED_MODEL_REF_MESSAGE,
match: (value) => scanKnownModelRefs(value),
}));
@@ -0,0 +1,601 @@
import { isDeepStrictEqual } from "node:util";
import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js";
import { ensureRecord, getRecord } from "../../../config/legacy.shared.js";
import {
computeModelPolicyAllowlist,
hasModelPolicyAllowlistMigrationMarker,
MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER,
} from "../../../config/model-policy-allowlist-migration.js";
import { isBlockedObjectKey } from "../../../infra/prototype-keys.js";
export function hasOwnDefinedProperty(record: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(record, key) && record[key] !== undefined;
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function preferredClaudeSeparator(provider: string | undefined): "." | "-" {
return provider === "github-copilot" || provider === "copilot-proxy" ? "." : "-";
}
function claudeTargetModelId(
family: "opus" | "sonnet",
separator: "." | "-",
provider?: string,
): string {
const version =
family === "opus" && provider !== "venice" && provider !== "vercel-ai-gateway" ? "4.7" : "4.6";
return `claude-${family}-${separator === "." ? version : version.replace(".", "-")}`;
}
function shouldUpgradeClaudeProvider(provider: string | undefined): boolean {
return (
!provider ||
provider === "anthropic" ||
provider === "github-copilot" ||
provider === "copilot-proxy" ||
provider === "venice" ||
provider === "vercel-ai-gateway"
);
}
function upgradeRetiredGroqModelId(model: string): string | null {
const normalized = normalizeString(model);
switch (normalized) {
case "deepseek-r1-distill-llama-70b":
return "llama-3.3-70b-versatile";
case "gemma2-9b-it":
case "llama3-8b-8192":
return "llama-3.1-8b-instant";
case "llama3-70b-8192":
return "llama-3.3-70b-versatile";
case "meta-llama/llama-4-maverick-17b-128e-instruct":
case "moonshotai/kimi-k2-instruct":
case "moonshotai/kimi-k2-instruct-0905":
return "openai/gpt-oss-120b";
case "mistral-saba-24b":
case "qwen-qwq-32b":
return "qwen/qwen3-32b";
default:
return null;
}
}
function upgradeRetiredXaiModelId(model: string): string | null {
const normalized = normalizeString(model);
switch (normalized) {
case "grok-code-fast":
case "grok-code-fast-1":
case "grok-code-fast-1-0825":
return "grok-build-0.1";
case "grok-4-fast-reasoning":
case "grok-4-1-fast-reasoning":
case "grok-4-0709":
return "grok-4.3";
case "grok-imagine-image-pro":
return "grok-imagine-image-quality";
default:
return null;
}
}
function upgradeRetiredOpenAiModelId(model: string, provider?: string): string | null {
const normalized = normalizeString(model);
const codexProvider = provider === "openai-codex";
if (codexProvider && normalized === "gpt-5.2") {
return "gpt-5.5";
}
if (
normalized === "gpt-5.2-codex" ||
normalized === "gpt-5.1-codex" ||
normalized === "gpt-5-codex"
) {
return codexProvider ? "gpt-5.5" : "gpt-5.3-codex";
}
if (normalized === "gpt-5-pro" || normalized === "gpt-5.2-pro") {
return "gpt-5.5-pro";
}
if (normalized === "gpt-4.1-nano" || normalized === "gpt-5-nano") {
if (codexProvider) {
return "gpt-5.4-mini";
}
return "gpt-5.4-nano";
}
if (
normalized === "gpt-4.1-mini" ||
normalized === "gpt-4o-mini" ||
normalized === "gpt-5.1-codex-mini" ||
normalized === "gpt-5-mini"
) {
return "gpt-5.4-mini";
}
if (
normalized === "gpt-4" ||
normalized === "gpt-4-turbo" ||
normalized === "gpt-4.1" ||
normalized === "gpt-4o" ||
normalized === "gpt-4o-2024-05-13" ||
normalized === "gpt-4o-2024-08-06" ||
normalized === "gpt-4o-2024-11-20" ||
normalized === "gpt-5" ||
normalized === "gpt-5-chat-latest" ||
normalized === "gpt-5.1" ||
normalized === "gpt-5.1-chat-latest" ||
normalized === "gpt-5.1-codex-max" ||
normalized === "gpt-5.2" ||
normalized === "gpt-5.2-chat-latest"
) {
return "gpt-5.5";
}
return null;
}
function hasRetiredVersionPrefix(normalized: string, prefix: string): boolean {
if (normalized === prefix) {
return true;
}
if (!normalized.startsWith(prefix)) {
return false;
}
const next = normalized[prefix.length];
return next === "-" || next === "." || next === ":" || next === "@";
}
function hasAnyRetiredVersionPrefix(normalized: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => hasRetiredVersionPrefix(normalized, prefix));
}
function upgradeOldClaudeToken(
token: string,
separator: "." | "-",
provider?: string,
): string | null {
const normalized = normalizeString(token);
if (!normalized) {
return null;
}
const opusTarget = claudeTargetModelId("opus", separator, provider);
const sonnetTarget = claudeTargetModelId("sonnet", separator, provider);
if (
normalized.startsWith("claude-opus-4-7") ||
normalized.startsWith("claude-opus-4.7") ||
normalized.startsWith("claude-opus-4-6") ||
normalized.startsWith("claude-opus-4.6") ||
normalized.startsWith("claude-sonnet-4-6") ||
normalized.startsWith("claude-sonnet-4.6")
) {
return null;
}
// claude-haiku-4-5 is a current production model and must not be migrated.
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
return null;
}
if (
normalized === "claude-opus-4" ||
hasAnyRetiredVersionPrefix(normalized, [
"claude-opus-4-5",
"claude-opus-4.5",
"claude-opus-4-1",
"claude-opus-4.1",
"claude-opus-4-0",
"claude-opus-4.0",
]) ||
/^claude-opus-4-20\d{6}/.test(normalized)
) {
return opusTarget;
}
if (
normalized === "claude-sonnet-4" ||
hasAnyRetiredVersionPrefix(normalized, [
"claude-sonnet-4-5",
"claude-sonnet-4.5",
"claude-sonnet-4-1",
"claude-sonnet-4.1",
"claude-sonnet-4-0",
"claude-sonnet-4.0",
]) ||
/^claude-sonnet-4-20\d{6}/.test(normalized)
) {
return sonnetTarget;
}
if (normalized.startsWith("claude-3") && normalized.includes("opus")) {
return opusTarget;
}
if (
normalized.startsWith("claude-3") &&
(normalized.includes("sonnet") || normalized.includes("haiku"))
) {
return sonnetTarget;
}
if (normalized.startsWith("anthropic.claude-opus-")) {
if (provider === "amazon-bedrock" || provider === "amazon-bedrock-mantle") {
return null;
}
if (
normalized.startsWith("anthropic.claude-opus-4-7") ||
normalized.startsWith("anthropic.claude-opus-4-6")
) {
return null;
}
return `anthropic.${claudeTargetModelId("opus", "-", provider)}`;
}
if (
normalized.startsWith("anthropic.claude-sonnet-") ||
normalized.startsWith("anthropic.claude-haiku-")
) {
if (provider === "amazon-bedrock" || provider === "amazon-bedrock-mantle") {
return null;
}
if (normalized.startsWith("anthropic.claude-sonnet-4-6")) {
return null;
}
return `anthropic.${claudeTargetModelId("sonnet", "-", provider)}`;
}
if (
normalized === "opus-4.5" ||
normalized === "opus-4.1" ||
normalized === "opus-4" ||
normalized === "opus-3"
) {
return opusTarget;
}
if (
normalized === "sonnet-4.5" ||
normalized === "sonnet-4.1" ||
normalized === "sonnet-4.0" ||
normalized === "sonnet-4" ||
normalized === "sonnet-3.7" ||
normalized === "sonnet-3.5" ||
normalized === "sonnet-3" ||
normalized === "haiku-3.5" ||
normalized === "haiku-3"
) {
return sonnetTarget;
}
return null;
}
function upgradeOldClaudeModelPart(model: string, provider: string | undefined): string | null {
const separator = preferredClaudeSeparator(provider);
const slashParts = model.split("/");
const lastPart = slashParts.at(-1);
if (lastPart) {
const upgraded = upgradeOldClaudeToken(lastPart, separator, provider);
if (upgraded) {
return [...slashParts.slice(0, -1), upgraded].join("/");
}
}
return upgradeOldClaudeToken(model, separator, provider);
}
function upgradeRetiredModelRef(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const split = splitTrailingAuthProfile(trimmed);
const modelRef = split.model;
const slash = modelRef.indexOf("/");
const provider = slash > 0 ? modelRef.slice(0, slash).trim() : undefined;
const model = slash > 0 ? modelRef.slice(slash + 1).trim() : modelRef;
const normalizedProvider = normalizeString(provider);
const normalizedModel = normalizeString(model);
const retiredOwnerModel =
normalizedProvider === "groq"
? upgradeRetiredGroqModelId(model)
: normalizedProvider === "xai"
? upgradeRetiredXaiModelId(model)
: normalizedProvider === "openai" ||
normalizedProvider === "openai-codex" ||
normalizedProvider === "github-copilot"
? upgradeRetiredOpenAiModelId(model, normalizedProvider)
: undefined;
if (retiredOwnerModel) {
return `${provider}/${retiredOwnerModel}${split.profile ? `@${split.profile}` : ""}`;
}
if (
(normalizedProvider === "github-copilot" || normalizedProvider === "copilot-proxy") &&
normalizedModel === "grok-code-fast-1"
) {
return `${provider}/gpt-5.4-mini${split.profile ? `@${split.profile}` : ""}`;
}
if (!shouldUpgradeClaudeProvider(normalizedProvider || undefined)) {
return null;
}
const upgradedModel = upgradeOldClaudeModelPart(model, normalizedProvider || undefined);
if (!upgradedModel || upgradedModel === model) {
return null;
}
const upgraded = provider ? `${provider}/${upgradedModel}` : upgradedModel;
return `${upgraded}${split.profile ? `@${split.profile}` : ""}`;
}
const MODEL_REF_STRING_KEYS = new Set([
"model",
"primary",
"summaryModel",
"imageModel",
"imageGenerationModel",
"musicGenerationModel",
"pdfModel",
"videoGenerationModel",
]);
const MODEL_REF_ARRAY_KEYS = new Set([
"fallback",
"fallbacks",
"allowedModels",
"modelFallbacks",
"imageModelFallbacks",
]);
const MODEL_REF_MAP_KEYS = new Set(["models"]);
function pathKey(path: string): string {
return path.slice(path.lastIndexOf(".") + 1);
}
function isChannelModelOverridePath(path: string): boolean {
return path.includes(".modelByChannel.");
}
function isModelPolicyAllowPath(path: string): boolean {
return path.endsWith(".modelPolicy.allow");
}
export function scanKnownModelRefs(value: unknown, key?: string, path = ""): boolean {
if (typeof value === "string") {
return Boolean(
key &&
(MODEL_REF_STRING_KEYS.has(key) || isChannelModelOverridePath(path)) &&
upgradeRetiredModelRef(value),
);
}
if (Array.isArray(value)) {
return value.some((entry, index) =>
typeof entry === "string" &&
key &&
(MODEL_REF_ARRAY_KEYS.has(key) || isModelPolicyAllowPath(path))
? Boolean(upgradeRetiredModelRef(entry))
: scanKnownModelRefs(entry, undefined, `${path}.${index}`),
);
}
const record = getRecord(value);
if (!record) {
return false;
}
if (key && MODEL_REF_MAP_KEYS.has(key)) {
return Object.keys(record).some((entryKey) => Boolean(upgradeRetiredModelRef(entryKey)));
}
return Object.entries(record).some(([childKey, child]) =>
scanKnownModelRefs(child, childKey, `${path}.${childKey}`),
);
}
export function collectLegacyDefaultModelAllowRefs(raw: Record<string, unknown>): string[] | null {
// Marker seeding at the config write boundary ships atomically with metadata-only
// model maps. Therefore an unmarked map is legacy even if a general write version advanced.
const defaults = getRecord(getRecord(raw.agents)?.defaults);
return computeModelPolicyAllowlist({
root: raw,
defaults,
});
}
export function migrateExplicitDefaultModelAllowPolicy(
raw: Record<string, unknown>,
changes: string[],
): void {
if (hasModelPolicyAllowlistMigrationMarker(raw)) {
return;
}
const defaults = getRecord(getRecord(raw.agents)?.defaults);
const defaultModelPolicy = getRecord(defaults?.modelPolicy);
const defaultNeedsEvaluation =
Boolean(getRecord(defaults?.models)) &&
!(defaultModelPolicy && Object.hasOwn(defaultModelPolicy, "allow"));
if (!defaultNeedsEvaluation) {
return;
}
const defaultAllow = collectLegacyDefaultModelAllowRefs(raw);
if (defaultAllow) {
const mutableDefaults = ensureRecord(ensureRecord(raw, "agents"), "defaults");
const mutableModelPolicy = ensureRecord(mutableDefaults, "modelPolicy");
// The policy builder still retains configured defaults/fallbacks, so copying the
// original keys reproduces the legacy effective set, including wildcard expansion.
mutableModelPolicy.allow = defaultAllow;
}
const migrations = ensureRecord(ensureRecord(raw, "meta"), "migrations");
migrations[MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER] = true;
changes.push(
defaultAllow
? "Copied the legacy default model map to agents.defaults.modelPolicy.allow."
: "Recorded the legacy default model map as unrestricted without creating modelPolicy.allow.",
);
}
function rewriteModelRefString(value: string, path: string, changes: string[]): string {
const upgraded = upgradeRetiredModelRef(value);
if (!upgraded) {
return value;
}
changes.push(`Upgraded ${path} from ${JSON.stringify(value)} to ${JSON.stringify(upgraded)}.`);
return upgraded;
}
export function setRecordEntry(record: Record<string, unknown>, key: string, value: unknown): void {
// Config dictionaries can contain hostile keys; define own properties so
// rebuilding or copying them never invokes Object.prototype setters.
Object.defineProperty(record, key, {
configurable: true,
enumerable: true,
value,
writable: true,
});
}
function sanitizeModelRefMapEntry(value: unknown): unknown {
// Collisions combine both entries before recursive ref rewriting, so blocked
// keys must be removed at every depth on both sides of the merge.
if (Array.isArray(value)) {
return value.map(sanitizeModelRefMapEntry);
}
const record = getRecord(value);
if (!record) {
return value;
}
const sanitized: Record<string, unknown> = {};
for (const [field, child] of Object.entries(record)) {
if (!isBlockedObjectKey(field)) {
setRecordEntry(sanitized, field, sanitizeModelRefMapEntry(child));
}
}
return sanitized;
}
function modelRefValuesAreEqual(existing: unknown, incoming: unknown, path: string): boolean {
if (isDeepStrictEqual(existing, incoming)) {
return true;
}
const normalizedExisting = rewriteKnownModelRefs(existing, path, []).value;
const normalizedIncoming = rewriteKnownModelRefs(incoming, path, []).value;
return isDeepStrictEqual(normalizedExisting, normalizedIncoming);
}
function mergeModelRefMapEntries(
existing: unknown,
incoming: unknown,
path: string,
): { value: unknown; conflicts: string[] } {
const existingRecord = getRecord(existing);
const incomingRecord = getRecord(incoming);
if (!existingRecord || !incomingRecord) {
return {
value: sanitizeModelRefMapEntry(existing),
conflicts: modelRefValuesAreEqual(existing, incoming, path) ? [] : ["value"],
};
}
const merged = sanitizeModelRefMapEntry(existingRecord) as Record<string, unknown>;
const conflicts: string[] = [];
for (const [field, incomingValue] of Object.entries(incomingRecord)) {
if (incomingValue === undefined || isBlockedObjectKey(field)) {
continue;
}
if (!hasOwnDefinedProperty(existingRecord, field)) {
setRecordEntry(merged, field, sanitizeModelRefMapEntry(incomingValue));
continue;
}
const existingValue = existingRecord[field];
const fieldPath = `${path}.${field}`;
if (modelRefValuesAreEqual(existingValue, incomingValue, fieldPath)) {
continue;
}
const existingField = getRecord(existingValue);
const incomingField = getRecord(incomingValue);
if (existingField && incomingField) {
const nested = mergeModelRefMapEntries(existingField, incomingField, fieldPath);
setRecordEntry(merged, field, nested.value);
conflicts.push(...nested.conflicts.map((c) => `${field}.${c}`));
continue;
}
conflicts.push(field);
}
return { value: merged, conflicts };
}
function rewriteModelRefMapKeys(
record: Record<string, unknown>,
path: string,
changes: string[],
): { value: Record<string, unknown>; changed: boolean } {
let changed = false;
const next: Record<string, unknown> = {};
const consumedCanonicalKeys = new Set<string>();
for (const [key, child] of Object.entries(record)) {
const upgradedKey = upgradeRetiredModelRef(key);
const nextKey = upgradedKey ?? key;
if (!upgradedKey && consumedCanonicalKeys.has(key)) {
continue;
}
if (upgradedKey) {
changes.push(
`Upgraded ${path} key from ${JSON.stringify(key)} to ${JSON.stringify(upgradedKey)}.`,
);
changed = true;
}
if (upgradedKey && !Object.hasOwn(next, nextKey) && Object.hasOwn(record, nextKey)) {
// Seed the canonical entry before its retired aliases so canonical conflict
// precedence and per-alias change reporting do not depend on authored key order.
setRecordEntry(next, nextKey, record[nextKey]);
consumedCanonicalKeys.add(nextKey);
}
if (Object.hasOwn(next, nextKey)) {
const existing = next[nextKey];
const { value, conflicts } = mergeModelRefMapEntries(existing, child, `${path}.${nextKey}`);
setRecordEntry(next, nextKey, value);
const sortedConflicts = conflicts.toSorted();
if (sortedConflicts.length > 0) {
changes.push(
`Merged ${path} key ${JSON.stringify(key)} into ${JSON.stringify(nextKey)}; kept existing values for conflicting fields: ${sortedConflicts.join(", ")}.`,
);
} else {
changes.push(`Merged ${path} key ${JSON.stringify(key)} into ${JSON.stringify(nextKey)}.`);
}
continue;
}
setRecordEntry(next, nextKey, child);
}
return { value: changed ? next : record, changed };
}
export function rewriteKnownModelRefs(
value: unknown,
path: string,
changes: string[],
): { value: unknown; changed: boolean } {
const key = pathKey(path);
if (typeof value === "string") {
if (!MODEL_REF_STRING_KEYS.has(key) && !isChannelModelOverridePath(path)) {
return { value, changed: false };
}
const next = rewriteModelRefString(value, path, changes);
return { value: next, changed: next !== value };
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((entry, index) => {
if (
typeof entry === "string" &&
(MODEL_REF_ARRAY_KEYS.has(key) || isModelPolicyAllowPath(path))
) {
const rewritten = rewriteModelRefString(entry, `${path}.${index}`, changes);
changed ||= rewritten !== entry;
return rewritten;
}
const rewritten = rewriteKnownModelRefs(entry, `${path}.${index}`, changes);
changed ||= rewritten.changed;
return rewritten.value;
});
return { value: changed ? next : value, changed };
}
const record = getRecord(value);
if (!record) {
return { value, changed: false };
}
let working = record;
let changed = false;
if (MODEL_REF_MAP_KEYS.has(key)) {
const rewrittenKeys = rewriteModelRefMapKeys(record, path, changes);
working = rewrittenKeys.value;
changed ||= rewrittenKeys.changed;
}
const next: Record<string, unknown> = {};
for (const [childKey, child] of Object.entries(working)) {
const rewritten = rewriteKnownModelRefs(child, `${path}.${childKey}`, changes);
changed ||= rewritten.changed;
setRecordEntry(next, childKey, rewritten.value);
}
return { value: changed ? next : value, changed };
}
export const RETIRED_MODEL_REF_MESSAGE =
'Configured retired model refs are no longer in the bundled catalogs; run "openclaw doctor --fix" to upgrade them.';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js";
import { ensureRecord, getRecord, type LegacyConfigRule } from "../../../config/legacy.shared.js";
import { isModelThinkingFormat } from "../../../config/types.models.js";
import {
hasInvalidThinkingFormat,
hasStaleContextWindowValue,
} from "./legacy-config-migrations.runtime.models.catalog.js";
const QWEN_THINKING_FORMAT_KEYS = ["qwenThinkingFormat", "qwen_thinking_format"] as const;
function normalizeLegacyVllmQwenThinkingFormat(
value: unknown,
): "qwen" | "qwen-chat-template" | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value
.trim()
.toLowerCase()
.replace(/[_\s]+/g, "-");
switch (normalized) {
case "chat-template":
case "chat-template-argument":
case "chat-template-arguments":
case "chat-template-kwarg":
case "chat-template-kwargs":
case "qwen-chat-template":
return "qwen-chat-template";
case "enable-thinking":
case "qwen":
case "request-body":
case "top-level":
return "qwen";
default:
return undefined;
}
}
export function getLegacyVllmQwenThinkingFormat(params: Record<string, unknown>):
| {
key: (typeof QWEN_THINKING_FORMAT_KEYS)[number];
value: unknown;
compat: "qwen" | "qwen-chat-template" | undefined;
}
| undefined {
for (const key of QWEN_THINKING_FORMAT_KEYS) {
if (Object.hasOwn(params, key)) {
return {
key,
value: params[key],
compat: normalizeLegacyVllmQwenThinkingFormat(params[key]),
};
}
}
return undefined;
}
export function parseVllmAgentModelKey(key: string): string | undefined {
const trimmed = splitTrailingAuthProfile(key).model.trim();
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0) {
return undefined;
}
const providerId = trimmed.slice(0, slashIndex);
if (normalizeProviderId(providerId) !== "vllm") {
return undefined;
}
const modelId = trimmed.slice(slashIndex + 1).trim();
return modelId && modelId !== "*" ? modelId : undefined;
}
function hasLegacyVllmQwenThinkingFormat(defaultModels: unknown): boolean {
const models = getRecord(defaultModels);
if (!models) {
return false;
}
for (const [key, entry] of Object.entries(models)) {
if (!parseVllmAgentModelKey(key)) {
continue;
}
const params = getRecord(getRecord(entry)?.params);
if (params && getLegacyVllmQwenThinkingFormat(params)) {
return true;
}
}
return false;
}
function hasLegacyVllmQwenThinkingProviderParams(provider: unknown): boolean {
const params = getRecord(getRecord(provider)?.params);
return Boolean(params && getLegacyVllmQwenThinkingFormat(params));
}
function hasLegacyVllmQwenThinkingModelParams(provider: unknown): boolean {
const models = getRecord(provider)?.models;
if (!Array.isArray(models)) {
return false;
}
return models.some((model) => {
const params = getRecord(getRecord(model)?.params);
return Boolean(params && getLegacyVllmQwenThinkingFormat(params));
});
}
function hasLegacyVllmQwenThinkingParams(params: unknown): boolean {
const record = getRecord(params);
return Boolean(record && getLegacyVllmQwenThinkingFormat(record));
}
function hasLegacyVllmQwenThinkingAgentParams(agents: unknown): boolean {
const list = getRecord(agents)?.list;
if (!Array.isArray(list)) {
return false;
}
return list.some((agent) => hasLegacyVllmQwenThinkingParams(getRecord(agent)?.params));
}
export function findOrCreateVllmModelEntry(
raw: Record<string, unknown>,
modelId: string,
): { model: Record<string, unknown>; index: number } | undefined {
const modelsRoot = getOrCreateRecord(raw, "models");
const providers = modelsRoot ? getOrCreateRecord(modelsRoot, "providers") : undefined;
const vllm = providers ? getOrCreateVllmProvider(providers) : undefined;
if (!vllm) {
return undefined;
}
if (vllm.models !== undefined && !Array.isArray(vllm.models)) {
return undefined;
}
const models = Array.isArray(vllm.models) ? vllm.models : [];
vllm.models = models;
const providerModelId = `vllm/${modelId}`;
for (const [index, model] of models.entries()) {
const record = getRecord(model);
if (record?.id === modelId || record?.id === providerModelId) {
return { model: record, index };
}
}
const model = { id: modelId, name: modelId };
models.push(model);
return { model, index: models.length - 1 };
}
export function listExistingVllmModelTargets(
raw: Record<string, unknown>,
): Array<{ model: Record<string, unknown>; index: number }> {
const models = findVllmProvider(getRecord(getRecord(raw.models)?.providers))?.models;
if (!Array.isArray(models)) {
return [];
}
return models.flatMap((model, index) => {
const record = getRecord(model);
return record ? [{ model: record, index }] : [];
});
}
export function collectVllmModelIdsFromSelection(value: unknown): string[] {
if (typeof value === "string") {
const modelId = parseVllmAgentModelKey(value);
return modelId ? [modelId] : [];
}
const record = getRecord(value);
if (!record) {
return [];
}
const ids: string[] = [];
if (typeof record.primary === "string") {
const primary = parseVllmAgentModelKey(record.primary);
if (primary) {
ids.push(primary);
}
}
if (Array.isArray(record.fallbacks)) {
for (const fallback of record.fallbacks) {
if (typeof fallback !== "string") {
continue;
}
const modelId = parseVllmAgentModelKey(fallback);
if (modelId) {
ids.push(modelId);
}
}
}
return ids;
}
export function collectVllmModelIdsFromAgentModelMap(value: unknown): string[] {
const models = getRecord(value);
if (!models) {
return [];
}
return Object.keys(models).flatMap((key) => {
const modelId = parseVllmAgentModelKey(key);
return modelId ? [modelId] : [];
});
}
export function createVllmModelTargets(
raw: Record<string, unknown>,
modelIds: string[],
): Array<{ model: Record<string, unknown>; index: number }> {
const targets: Array<{ model: Record<string, unknown>; index: number }> = [];
const seen = new Set<Record<string, unknown>>();
for (const modelId of modelIds) {
const target = findOrCreateVllmModelEntry(raw, modelId);
if (!target || seen.has(target.model)) {
continue;
}
seen.add(target.model);
targets.push(target);
}
return targets;
}
export function combineVllmModelTargets(
...groups: Array<Array<{ model: Record<string, unknown>; index: number }>>
): Array<{ model: Record<string, unknown>; index: number }> {
const targets: Array<{ model: Record<string, unknown>; index: number }> = [];
const seen = new Set<Record<string, unknown>>();
for (const group of groups) {
for (const target of group) {
if (seen.has(target.model)) {
continue;
}
seen.add(target.model);
targets.push(target);
}
}
return targets;
}
export function collectVllmModelIdsFromAgentList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((agent) => {
const record = getRecord(agent);
return record
? [
...collectVllmModelIdsFromSelection(record.model),
...collectVllmModelIdsFromAgentModelMap(record.models),
]
: [];
});
}
function getOrCreateRecord(
root: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
if (root[key] === undefined) {
const next: Record<string, unknown> = {};
root[key] = next;
return next;
}
return getRecord(root[key]) ?? undefined;
}
export function findVllmProvider(
providers: Record<string, unknown> | null | undefined,
): Record<string, unknown> | undefined {
if (!providers) {
return undefined;
}
const key = Object.keys(providers).find((entry) => normalizeProviderId(entry) === "vllm");
return key ? (getRecord(providers[key]) ?? undefined) : undefined;
}
function getOrCreateVllmProvider(
providers: Record<string, unknown>,
): Record<string, unknown> | undefined {
const key = Object.keys(providers).find((entry) => normalizeProviderId(entry) === "vllm");
if (key) {
return getRecord(providers[key]) ?? undefined;
}
return getOrCreateRecord(providers, "vllm");
}
function hasLegacyVllmQwenThinkingNormalizedProvider(providers: unknown): boolean {
const providersRecord = getRecord(providers);
if (!providersRecord || getRecord(providersRecord.vllm)) {
return false;
}
const vllmProvider = findVllmProvider(providersRecord);
return (
hasLegacyVllmQwenThinkingProviderParams(vllmProvider) ||
hasLegacyVllmQwenThinkingModelParams(vllmProvider)
);
}
function preserveMigratedVllmQwenReasoning(model: Record<string, unknown>): void {
if (model.reasoning === undefined) {
model.reasoning = true;
}
}
function removeLegacyVllmQwenThinkingParams(params: Record<string, unknown>): void {
for (const key of QWEN_THINKING_FORMAT_KEYS) {
delete params[key];
}
}
export function applyLegacyVllmQwenThinkingFormat(params: {
sourcePath: string;
legacyParams: Record<string, unknown>;
target: { model: Record<string, unknown>; index: number };
legacyFormat: NonNullable<ReturnType<typeof getLegacyVllmQwenThinkingFormat>>;
changes: string[];
}): boolean {
if (!params.legacyFormat.compat) {
removeLegacyVllmQwenThinkingParams(params.legacyParams);
params.changes.push(
`Removed ${params.sourcePath}.${params.legacyFormat.key} (unrecognized value ${JSON.stringify(params.legacyFormat.value)}; configure models.providers.vllm.models[].compat.thinkingFormat if needed).`,
);
return true;
}
preserveMigratedVllmQwenReasoning(params.target.model);
const compat = ensureRecord(params.target.model, "compat");
const currentThinkingFormat = compat.thinkingFormat;
if (typeof currentThinkingFormat === "string" && isModelThinkingFormat(currentThinkingFormat)) {
removeLegacyVllmQwenThinkingParams(params.legacyParams);
params.changes.push(
`Removed ${params.sourcePath}.${params.legacyFormat.key}; models.providers.vllm.models[${params.target.index}].compat.thinkingFormat is already ${JSON.stringify(currentThinkingFormat)}.`,
);
return true;
}
compat.thinkingFormat = params.legacyFormat.compat;
removeLegacyVllmQwenThinkingParams(params.legacyParams);
params.changes.push(
`Moved ${params.sourcePath}.${params.legacyFormat.key} to models.providers.vllm.models[${params.target.index}].compat.thinkingFormat (${JSON.stringify(params.legacyFormat.compat)}).`,
);
return true;
}
export function removeUntargetedLegacyVllmQwenThinkingFormat(params: {
sourcePath: string;
legacyParams: Record<string, unknown>;
legacyFormat: NonNullable<ReturnType<typeof getLegacyVllmQwenThinkingFormat>>;
changes: string[];
}): void {
removeLegacyVllmQwenThinkingParams(params.legacyParams);
params.changes.push(
`Removed ${params.sourcePath}.${params.legacyFormat.key}; no concrete vLLM model row or agent model ref exists, so configure models.providers.vllm.models[].compat.thinkingFormat on each Qwen model that needs it.`,
);
}
export const LEGACY_VLLM_QWEN_AGENT_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["agents", "defaults", "models"],
message:
'agents.defaults.models.<vllm-model>.params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.vllm.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingFormat(value),
};
export const LEGACY_VLLM_QWEN_PROVIDER_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["models", "providers", "vllm", "params"],
message:
'models.providers.vllm.params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.vllm.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingProviderParams({ params: value }),
};
export const LEGACY_VLLM_QWEN_PROVIDER_MODEL_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["models", "providers", "vllm", "models"],
message:
'models.providers.vllm.models[*].params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.vllm.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingModelParams({ models: value }),
};
export const LEGACY_VLLM_QWEN_NORMALIZED_PROVIDER_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["models", "providers"],
message:
'models.providers.<vllm>.params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.<vllm>.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingNormalizedProvider(value),
};
export const LEGACY_VLLM_QWEN_DEFAULT_PARAMS_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["agents", "defaults", "params"],
message:
'agents.defaults.params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.vllm.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingParams(value),
};
export const LEGACY_VLLM_QWEN_AGENT_PARAMS_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["agents"],
message:
'agents.list[].params.qwenThinkingFormat is legacy; run "openclaw doctor --fix" to move it to models.providers.vllm.models[].compat.thinkingFormat.',
match: (value) => hasLegacyVllmQwenThinkingAgentParams(value),
};
export const INVALID_THINKING_FORMAT_RULE: LegacyConfigRule = {
path: ["models", "providers"],
message:
'models.providers.<id>.models[*].compat.thinkingFormat has an unrecognized value; run "openclaw doctor --fix" to remove it and restore the runtime default.',
match: (value) => hasInvalidThinkingFormat(value),
};
export const STALE_CONTEXT_WINDOW_RULE: LegacyConfigRule = {
path: ["models", "providers"],
message:
'models.providers.<id>.models[*].contextWindow has a stale catalog value; run "openclaw doctor --fix" to repair it.',
match: (value) => hasStaleContextWindowValue(value),
};