diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 78b3e0168bd4..0ee2a3ca4841 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2652,7 +2652,7 @@ src/config/config-env-vars.ts 5 src/config/config-journal-snapshot.ts 2 src/config/config-path-mutation.ts 1 src/config/context-visibility.ts 1 -src/config/defaults.ts 7 +src/config/defaults.ts 6 src/config/doc-baseline.ts 8 src/config/docs-config-examples.ts 1 src/config/future-version-guard.ts 2 diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index a938d3d530a2..c5987b201006 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -4,6 +4,7 @@ * model catalogs without discarding existing credentials. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { resolveCatalogOwnedModelCompat } from "./model-compat-catalog.js"; @@ -43,24 +44,6 @@ export type ExistingProviderConfig = ProviderConfig & { api?: string; }; -function isPositiveFiniteTokenLimit(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - -function resolvePreferredTokenLimit(params: { - explicitPresent: boolean; - explicitValue: unknown; - implicitValue: unknown; -}): number | undefined { - if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) { - return params.explicitValue; - } - if (isPositiveFiniteTokenLimit(params.implicitValue)) { - return params.implicitValue; - } - return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined; -} - function getProviderModelId(model: unknown): string { if (!model || typeof model !== "object") { return ""; @@ -117,21 +100,15 @@ export function mergeProviderModels( return explicitModel; } - const contextWindow = resolvePreferredTokenLimit({ - explicitPresent: "contextWindow" in explicitModel, - explicitValue: explicitModel.contextWindow, - implicitValue: implicitModel.contextWindow, - }); - const contextTokens = resolvePreferredTokenLimit({ - explicitPresent: "contextTokens" in explicitModel, - explicitValue: explicitModel.contextTokens, - implicitValue: implicitModel.contextTokens, - }); - const maxTokens = resolvePreferredTokenLimit({ - explicitPresent: "maxTokens" in explicitModel, - explicitValue: explicitModel.maxTokens, - implicitValue: implicitModel.maxTokens, - }); + const contextWindow = + asPositiveFiniteNumber(explicitModel.contextWindow) ?? + asPositiveFiniteNumber(implicitModel.contextWindow); + const contextTokens = + asPositiveFiniteNumber(explicitModel.contextTokens) ?? + asPositiveFiniteNumber(implicitModel.contextTokens); + const maxTokens = + asPositiveFiniteNumber(explicitModel.maxTokens) ?? + asPositiveFiniteNumber(implicitModel.maxTokens); const compat = resolveCatalogOwnedModelCompat({ catalogRoute: { api: implicitModel.api ?? implicit.api, diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index 89544f1490f8..8ff082c608f3 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -171,7 +171,7 @@ describe("normalizeProviders", () => { } }); - it("deduplicates Google Gemini provider rows after model id normalization", async () => { + it("deduplicates model rows and keeps repeated publication stable with secret ownership", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); try { const providers: NonNullable["providers"]> = { @@ -195,9 +195,10 @@ describe("normalizeProviders", () => { }), ], }, + custom: { baseUrl: "https://models.example/v1", models: [] }, }; - const normalized = normalizeProviders({ providers, agentDir }); + const normalized = normalizeProviders({ providers, agentDir, env: {} }); expect(normalized?.google?.models).toHaveLength(1); // The first normalized row wins so explicit config details are not replaced by discovery. @@ -208,6 +209,20 @@ describe("normalizeProviders", () => { expect(model?.maxTokens).toBe(2048); expect(model?.reasoning).toBe(false); expect(model?.cost).toEqual({ input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }); + + const published = normalizeProviderCatalogModelsForConfig(normalized); + expect(published).toBe(normalized); + const secretRefManagedProviders = new Set(); + const repeated = normalizeProviders({ + providers: published, + agentDir, + env: {}, + secretRefManagedProviders, + }); + // A no-op object pass must still record marker ownership for secret preservation. + expect(repeated).toBe(published); + expect(normalizeProviderCatalogModelsForConfig(repeated)).toBe(published); + expect(secretRefManagedProviders.has("google")).toBe(true); } finally { await fs.rm(agentDir, { recursive: true, force: true }); } diff --git a/src/agents/models-config.providers.normalize.ts b/src/agents/models-config.providers.normalize.ts index b9d30a0bcd54..e7a321b3ab66 100644 --- a/src/agents/models-config.providers.normalize.ts +++ b/src/agents/models-config.providers.normalize.ts @@ -76,9 +76,9 @@ function normalizeProviderModelsForConfig( provider: ProviderConfig, options: ProviderModelNormalizationOptions = {}, completeCatalogCosts = false, -): { provider: ProviderConfig; mutated: boolean } { +): ProviderConfig { if (!Array.isArray(provider.models) || provider.models.length === 0) { - return { provider, mutated: false }; + return provider; } let mutated = false; @@ -122,9 +122,7 @@ function normalizeProviderModelsForConfig( } } - return mutated - ? { provider: { ...provider, models: nextModels }, mutated } - : { provider, mutated }; + return mutated ? { ...provider, models: nextModels } : provider; } export function normalizeProviderCatalogModelsForConfig( @@ -141,10 +139,8 @@ export function normalizeProviderCatalogModelsForConfig( // Complete the publication schema after duplicate rows merge, or synthetic // zeroes can mask explicit cache prices supplied by a later row. const normalized = normalizeProviderModelsForConfig(providerKey, provider, options, true); - if (normalized.mutated) { - mutated = true; - } - next[providerKey] = normalized.provider; + mutated ||= normalized !== provider; + next[providerKey] = normalized; } return mutated ? next : providers; @@ -195,35 +191,26 @@ export function normalizeProviders(params: { secretDefaults: params.secretDefaults, }); if (normalizedHeaders.mutated) { - mutated = true; normalizedProvider = { ...normalizedProvider, headers: normalizedHeaders.headers }; } - const providerWithConfiguredApiKey = normalizeConfiguredProviderApiKey({ + normalizedProvider = normalizeConfiguredProviderApiKey({ providerKey: normalizedKey, provider: normalizedProvider, secretDefaults: params.secretDefaults, profileApiKey: undefined, secretRefManagedProviders: params.secretRefManagedProviders, }); - if (providerWithConfiguredApiKey !== normalizedProvider) { - mutated = true; - normalizedProvider = providerWithConfiguredApiKey; - } // Reverse-lookup: if apiKey looks like a resolved secret value (not an env // var name), check whether it matches the canonical env var for this provider. // This prevents resolveConfigEnvVars()-resolved secrets from being persisted // to models.json as plaintext. (Fixes #38757) - const providerWithResolvedEnvApiKey = normalizeResolvedEnvApiKey({ + normalizedProvider = normalizeResolvedEnvApiKey({ providerKey: normalizedKey, provider: normalizedProvider, env, secretRefManagedProviders: params.secretRefManagedProviders, }); - if (providerWithResolvedEnvApiKey !== normalizedProvider) { - mutated = true; - normalizedProvider = providerWithResolvedEnvApiKey; - } const needsProfileApiKey = Array.isArray(normalizedProvider.models) && @@ -236,7 +223,7 @@ export function normalizeProviders(params: { const providerApiKeyResolver = needsProfileApiKey ? resolveProviderConfigApiKeyResolver(normalizedKey, undefined, params.manifestRegistry) : undefined; - const providerWithApiKey = resolveMissingProviderApiKey({ + normalizedProvider = resolveMissingProviderApiKey({ providerKey: normalizedKey, provider: normalizedProvider, env, @@ -244,32 +231,17 @@ export function normalizeProviders(params: { secretRefManagedProviders: params.secretRefManagedProviders, providerApiKeyResolver, }); - if (providerWithApiKey !== normalizedProvider) { - mutated = true; - normalizedProvider = providerWithApiKey; - } - const providerSpecificNormalized = normalizeProviderSpecificConfig( + normalizedProvider = normalizeProviderSpecificConfig( normalizedKey, normalizedProvider, params.manifestRegistry, ); - if (providerSpecificNormalized !== normalizedProvider) { - mutated = true; - normalizedProvider = providerSpecificNormalized; - } - const providerWithNormalizedModels = normalizeProviderModelsForConfig( - normalizedKey, - normalizedProvider, - { - manifestPlugins: params.manifestPlugins, - }, - ); - if (providerWithNormalizedModels.mutated) { - mutated = true; - normalizedProvider = providerWithNormalizedModels.provider; - } + normalizedProvider = normalizeProviderModelsForConfig(normalizedKey, normalizedProvider, { + manifestPlugins: params.manifestPlugins, + }); + mutated ||= normalizedProvider !== provider; const existing = next[normalizedKey]; if (existing) { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 3984a122a3fd..b5fbe83f4ed0 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -4,6 +4,7 @@ import { collectManifestModelIdNormalizationPolicies, normalizeConfiguredProviderCatalogModelId, } from "@openclaw/model-catalog-core/provider-model-id-normalization"; +import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; @@ -13,7 +14,10 @@ import { DEFAULT_SUBAGENT_MAX_CONCURRENT, resolveAgentMaxConcurrent, } from "./agent-limits.js"; -import { normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig } from "./model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelSelectionForConfig, +} from "./model-input.js"; import { applyProviderConfigDefaultsForConfig, normalizeProviderConfigForConfigDefaults, @@ -66,10 +70,6 @@ const MISTRAL_SAFE_MAX_TOKENS_BY_MODEL = { type ModelDefinitionLike = Partial & Pick; -function isPositiveNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - function resolveModelCost( raw?: Partial, ): ModelDefinitionConfig["cost"] { @@ -243,9 +243,7 @@ export function applyModelDefaults( continue; } const providerApi = normalizedProvider.api; - const providerMaxTokens = isPositiveNumber(normalizedProvider.maxTokens) - ? normalizedProvider.maxTokens - : undefined; + const providerMaxTokens = asPositiveFiniteNumber(normalizedProvider.maxTokens); const nextProvider = normalizedProvider; if (nextProvider !== provider) { mutated = true; @@ -253,15 +251,11 @@ export function applyModelDefaults( let providerMutated = false; const nextModels = models.map((model) => { const raw = model as ModelDefinitionLike; - let modelMutated = false; const id = normalizeConfiguredProviderCatalogModelId( providerId, raw.id, modelIdNormalizationPolicies, ); - if (id !== raw.id) { - modelMutated = true; - } // Config entries are overrides, not full definitions: authored fields // win, the owning catalog row fills omitted fields, and only then do @@ -271,14 +265,8 @@ export function applyModelDefaults( const catalogModel = resolveCatalogModel(providerId, id); const reasoning = typeof raw.reasoning === "boolean" ? raw.reasoning : (catalogModel?.reasoning ?? false); - if (raw.reasoning !== reasoning) { - modelMutated = true; - } const input = raw.input ?? catalogModel?.input ?? [...DEFAULT_MODEL_INPUT]; - if (raw.input === undefined) { - modelMutated = true; - } const cost = resolveModelCost( raw.cost || catalogModel?.cost ? { ...catalogModel?.cost, ...raw.cost } : undefined, @@ -297,51 +285,29 @@ export function applyModelDefaults( raw.cost.cacheRead !== cost.cacheRead || raw.cost.cacheWrite !== cost.cacheWrite || raw.cost.tieredPricing !== cost.tieredPricing; - if (costMutated) { - modelMutated = true; - } - - const contextWindow = isPositiveNumber(raw.contextWindow) - ? raw.contextWindow - : isPositiveNumber(catalogModel?.contextWindow) - ? catalogModel.contextWindow - : undefined; - if (raw.contextWindow !== contextWindow) { - modelMutated = true; - } - - const contextTokens = isPositiveNumber(raw.contextTokens) - ? raw.contextTokens - : isPositiveNumber(catalogModel?.contextTokens) - ? catalogModel.contextTokens - : undefined; - if (raw.contextTokens !== contextTokens) { - modelMutated = true; - } + const contextWindow = + asPositiveFiniteNumber(raw.contextWindow) ?? + asPositiveFiniteNumber(catalogModel?.contextWindow); + const contextTokens = + asPositiveFiniteNumber(raw.contextTokens) ?? + asPositiveFiniteNumber(catalogModel?.contextTokens); const maxTokenContextWindow = contextWindow ?? DEFAULT_CONTEXT_TOKENS; const defaultMaxTokens = Math.min( providerMaxTokens ?? DEFAULT_MODEL_MAX_TOKENS, maxTokenContextWindow, ); - const rawMaxTokens = isPositiveNumber(raw.maxTokens) - ? raw.maxTokens - : isPositiveNumber(catalogModel?.maxTokens) - ? catalogModel.maxTokens - : defaultMaxTokens; + const rawMaxTokens = + asPositiveFiniteNumber(raw.maxTokens) ?? + asPositiveFiniteNumber(catalogModel?.maxTokens) ?? + defaultMaxTokens; const maxTokens = resolveNormalizedProviderModelMaxTokens({ providerId, modelId: id, contextWindow: maxTokenContextWindow, rawMaxTokens, }); - if (raw.maxTokens !== maxTokens) { - modelMutated = true; - } const api = raw.api ?? providerApi; - if (raw.api !== api) { - modelMutated = true; - } const thinkingLevelMap = raw.thinkingLevelMap === undefined && catalogModel?.thinkingLevelMap !== undefined @@ -351,10 +317,17 @@ export function applyModelDefaults( raw.compat === undefined && catalogModel?.compat !== undefined ? catalogModel.compat : undefined; - if (thinkingLevelMap !== undefined || compat !== undefined) { - modelMutated = true; - } - + const modelMutated = + id !== raw.id || + raw.reasoning !== reasoning || + raw.input === undefined || + costMutated || + raw.contextWindow !== contextWindow || + raw.contextTokens !== contextTokens || + raw.maxTokens !== maxTokens || + raw.api !== api || + thinkingLevelMap !== undefined || + compat !== undefined; if (!modelMutated) { return model; } @@ -408,7 +381,7 @@ export function applyModelDefaults( } let nextAgent = agent; if (Object.hasOwn(agent, "model")) { - const normalizedModel = normalizeAgentModelConfigForDefaults(agent.model); + const normalizedModel = normalizeAgentModelSelectionForConfig(agent.model); if (normalizedModel !== agent.model) { nextAgent = { ...nextAgent, model: normalizedModel as typeof agent.model }; listMutated = true; @@ -438,7 +411,7 @@ export function applyModelDefaults( } let nextAgent = existingAgent; - const normalizedModel = normalizeAgentModelConfigForDefaults(existingAgent.model); + const normalizedModel = normalizeAgentModelSelectionForConfig(existingAgent.model); if (normalizedModel !== existingAgent.model) { nextAgent = { ...nextAgent, model: normalizedModel as typeof existingAgent.model }; mutated = true; @@ -499,38 +472,6 @@ export function applyModelDefaults( }; } -function normalizeAgentModelConfigForDefaults(value: unknown): unknown { - if (typeof value === "string") { - const normalized = normalizeAgentModelRefForConfig(value); - return normalized === value ? value : normalized; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return value; - } - - const raw = value as Record; - let mutated = false; - const next: Record = { ...raw }; - if (typeof raw.primary === "string") { - const primary = normalizeAgentModelRefForConfig(raw.primary); - if (primary !== raw.primary) { - next.primary = primary; - mutated = true; - } - } - if (Array.isArray(raw.fallbacks)) { - const rawFallbacks = raw.fallbacks; - const fallbacks = rawFallbacks.map((fallback) => - typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback, - ); - if (fallbacks.some((fallback, index) => fallback !== rawFallbacks[index])) { - next.fallbacks = fallbacks; - mutated = true; - } - } - return mutated ? next : value; -} - export function applyAgentDefaults(cfg: OpenClawConfig): OpenClawConfig { const agents = cfg.agents; const defaults = agents?.defaults; diff --git a/src/config/model-input-normalization.ts b/src/config/model-input-normalization.ts index aedeaf063aa2..5d1e49330f4a 100644 --- a/src/config/model-input-normalization.ts +++ b/src/config/model-input-normalization.ts @@ -4,42 +4,16 @@ import { type ManifestModelIdNormalizationProvider, } from "@openclaw/model-catalog-core/provider-model-id-normalization"; import { isRecord } from "../utils.js"; -import { normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig } from "./model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, + normalizeAgentModelSelectionForConfig, +} from "./model-input.js"; import type { OpenClawConfig } from "./types.openclaw.js"; const MODEL_SELECTION_KEYS = ["model", "imageModel", "voiceModel", "pdfModel"] as const; const MEDIA_MODEL_KEYS = ["image", "video", "music"] as const; -function normalizeModelSelection(value: unknown): unknown { - if (typeof value === "string") { - return normalizeAgentModelRefForConfig(value); - } - if (!isRecord(value)) { - return value; - } - - let next = value; - const assign = (key: string, candidate: unknown) => { - if (candidate === next[key]) { - return; - } - next = { ...next, [key]: candidate }; - }; - if (typeof value.primary === "string") { - assign("primary", normalizeAgentModelRefForConfig(value.primary)); - } - if (Array.isArray(value.fallbacks)) { - const originalFallbacks = value.fallbacks; - const fallbacks = originalFallbacks.map((fallback) => - typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback, - ); - if (fallbacks.some((fallback, index) => fallback !== originalFallbacks[index])) { - assign("fallbacks", fallbacks); - } - } - return next; -} - function normalizeStringModelRef(value: unknown): unknown { return typeof value === "string" ? normalizeAgentModelRefForConfig(value) : value; } @@ -71,7 +45,7 @@ function normalizeAgentModelScope(value: unknown): unknown { for (const key of MODEL_SELECTION_KEYS) { if (Object.hasOwn(value, key)) { - assign(key, normalizeModelSelection(value[key])); + assign(key, normalizeAgentModelSelectionForConfig(value[key])); } } if (Object.hasOwn(value, "utilityModel")) { @@ -85,7 +59,7 @@ function normalizeAgentModelScope(value: unknown): unknown { if (!Object.hasOwn(originalMediaModels, key)) { continue; } - const normalized = normalizeModelSelection(originalMediaModels[key]); + const normalized = normalizeAgentModelSelectionForConfig(originalMediaModels[key]); if (normalized !== mediaModels[key]) { mediaModels[key] = normalized; mediaModelsChanged = true; @@ -96,7 +70,10 @@ function normalizeAgentModelScope(value: unknown): unknown { } } assign("heartbeat", normalizeNestedModelField(value.heartbeat, "model", normalizeStringModelRef)); - assign("subagents", normalizeNestedModelField(value.subagents, "model", normalizeModelSelection)); + assign( + "subagents", + normalizeNestedModelField(value.subagents, "model", normalizeAgentModelSelectionForConfig), + ); if (isRecord(value.compaction)) { let compaction = normalizeNestedModelField(value.compaction, "model", normalizeStringModelRef); diff --git a/src/config/model-input.ts b/src/config/model-input.ts index 4e4835114092..ad33615c71ce 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -76,6 +76,36 @@ export function normalizeAgentModelRefForConfig(model: string): string { return modelKey(provider, normalizedModel); } +/** Normalizes primary/fallback refs without replacing unchanged config values. */ +export function normalizeAgentModelSelectionForConfig(value: unknown): unknown { + if (typeof value === "string") { + return normalizeAgentModelRefForConfig(value); + } + if (!isPlainRecord(value)) { + return value; + } + + let next = value; + const assign = (key: string, candidate: unknown) => { + if (candidate !== next[key]) { + next = { ...next, [key]: candidate }; + } + }; + if (typeof value.primary === "string") { + assign("primary", normalizeAgentModelRefForConfig(value.primary)); + } + if (Array.isArray(value.fallbacks)) { + const originalFallbacks = value.fallbacks; + const fallbacks = originalFallbacks.map((fallback) => + typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback, + ); + if (fallbacks.some((fallback, index) => fallback !== originalFallbacks[index])) { + assign("fallbacks", fallbacks); + } + } + return next; +} + function mergeAgentModelEntryForConfig(existing: unknown, incoming: unknown): unknown { if (!isPlainRecord(existing) || !isPlainRecord(incoming)) { return incoming;