From be6543caf8ca1d698728b6636ff40f9260636777 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:15:10 +0100 Subject: [PATCH] fix(doctor): preserve active auth profile metadata --- CHANGELOG.md | 1 + src/commands/doctor-auth-profile-config.ts | 210 +++++++++++ src/commands/doctor-config-flow.ts | 10 +- .../doctor/shared/config-flow-steps.test.ts | 328 ++++++++++++++++++ .../doctor/shared/config-flow-steps.ts | 15 +- .../release-configured-plugin-installs.ts | 49 +-- src/config/model-refs.ts | 77 ++++ src/config/plugin-auto-enable.shared.ts | 66 +--- src/config/validation.ts | 59 +--- 9 files changed, 653 insertions(+), 162 deletions(-) create mode 100644 src/commands/doctor-auth-profile-config.ts create mode 100644 src/config/model-refs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9f62d728d..722f0e6c56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. +- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/src/commands/doctor-auth-profile-config.ts b/src/commands/doctor-auth-profile-config.ts new file mode 100644 index 00000000000..bd95da733fd --- /dev/null +++ b/src/commands/doctor-auth-profile-config.ts @@ -0,0 +1,210 @@ +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; +import { collectConfiguredModelRefs } from "../config/model-refs.js"; +import type { AuthProfileConfig } from "../config/types.auth.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { isRecord } from "../utils.js"; + +const AUTH_PROFILE_MODES = new Set(["api_key", "oauth", "token"]); + +export type AuthProfileConfigProtectionResult = { + config: OpenClawConfig; + repairs: string[]; + warnings: string[]; +}; + +function normalizeProviderId(value: unknown): string { + return normalizeLowercaseStringOrEmpty(value); +} + +function normalizeProfileId(value: unknown): string | null { + return normalizeOptionalString(value) ?? null; +} + +function normalizeMode(value: unknown): AuthProfileConfig["mode"] | null { + return typeof value === "string" && AUTH_PROFILE_MODES.has(value as AuthProfileConfig["mode"]) + ? (value as AuthProfileConfig["mode"]) + : null; +} + +function extractProviderFromModelRef(value: string): string | null { + const { model } = splitTrailingAuthProfile(value); + const slash = model.indexOf("/"); + if (slash <= 0) { + return null; + } + return normalizeProviderId(model.slice(0, slash)) || null; +} + +function extractProviderFromProfileId(profileId: string): string | null { + const colon = profileId.indexOf(":"); + if (colon <= 0) { + return null; + } + return normalizeProviderId(profileId.slice(0, colon)) || null; +} + +function collectActiveAuthHints(config: OpenClawConfig): { + activeProviders: Set; + explicitProfileIds: Set; + explicitProfileProviders: Map>; +} { + const activeProviders = new Set(); + const explicitProfileIds = new Set(); + const explicitProfileProviders = new Map>(); + + const models = isRecord(config.models) ? config.models : {}; + const providers = isRecord(models.providers) ? models.providers : {}; + for (const providerId of Object.keys(providers)) { + const normalized = normalizeProviderId(providerId); + if (normalized) { + activeProviders.add(normalized); + } + } + + for (const { value } of collectConfiguredModelRefs(config)) { + const { profile } = splitTrailingAuthProfile(value); + const provider = extractProviderFromModelRef(value); + if (profile) { + explicitProfileIds.add(profile); + if (provider) { + const providers = explicitProfileProviders.get(profile) ?? new Set(); + providers.add(provider); + explicitProfileProviders.set(profile, providers); + } + } + if (provider) { + activeProviders.add(provider); + } + } + + const auth = isRecord(config.auth) ? config.auth : {}; + const order = isRecord(auth.order) ? auth.order : {}; + for (const [providerId, profileIds] of Object.entries(order)) { + const provider = normalizeProviderId(providerId); + if (!provider || !activeProviders.has(provider) || !Array.isArray(profileIds)) { + continue; + } + for (const profileId of profileIds) { + const normalized = normalizeProfileId(profileId); + if (normalized) { + explicitProfileIds.add(normalized); + } + } + } + + return { activeProviders, explicitProfileIds, explicitProfileProviders }; +} + +function isValidProfileMetadata(value: unknown): value is AuthProfileConfig { + if (!isRecord(value)) { + return false; + } + return normalizeProviderId(value.provider) !== "" && normalizeMode(value.mode) !== null; +} + +function buildProfileMetadata(params: { + profileId: string; + before: unknown; + after: unknown; + providerHint?: string; +}): AuthProfileConfig | null { + const before = isRecord(params.before) ? params.before : {}; + const after = isRecord(params.after) ? params.after : {}; + const provider = + normalizeProviderId(after.provider) || + normalizeProviderId(before.provider) || + extractProviderFromProfileId(params.profileId) || + normalizeProviderId(params.providerHint); + if (!provider) { + return null; + } + const mode = normalizeMode(after.mode) ?? normalizeMode(before.mode) ?? "api_key"; + const repaired: AuthProfileConfig = { provider, mode }; + const email = normalizeOptionalString(after.email) ?? normalizeOptionalString(before.email); + const displayName = + normalizeOptionalString(after.displayName) ?? normalizeOptionalString(before.displayName); + if (email) { + repaired.email = email; + } + if (displayName) { + repaired.displayName = displayName; + } + return repaired; +} + +function ensureAuthProfiles(config: OpenClawConfig): Record { + const root = config as Record; + const auth: Record = isRecord(root.auth) ? root.auth : {}; + if (root.auth !== auth) { + root.auth = auth; + } + if (!isRecord(auth.profiles)) { + auth.profiles = {}; + } + return auth.profiles as Record; +} + +export function protectActiveAuthProfileConfig(params: { + before: OpenClawConfig; + after: OpenClawConfig; +}): AuthProfileConfigProtectionResult { + const { activeProviders, explicitProfileIds, explicitProfileProviders } = collectActiveAuthHints( + params.before, + ); + const beforeAuth = isRecord(params.before.auth) ? params.before.auth : {}; + const beforeProfiles = isRecord(beforeAuth.profiles) ? beforeAuth.profiles : {}; + if (Object.keys(beforeProfiles).length === 0) { + return { config: params.after, repairs: [], warnings: [] }; + } + + const config = structuredClone(params.after); + const afterAuth = isRecord(config.auth) ? config.auth : {}; + const afterProfiles = isRecord(afterAuth.profiles) ? afterAuth.profiles : {}; + const repairs: string[] = []; + const warnings: string[] = []; + + for (const [profileId, beforeProfile] of Object.entries(beforeProfiles)) { + const afterProfile = afterProfiles[profileId]; + const afterProfileRecord = isRecord(afterProfile) ? afterProfile : null; + const beforeProfileRecord = isRecord(beforeProfile) ? beforeProfile : null; + if (isValidProfileMetadata(afterProfile)) { + continue; + } + const provider = + normalizeProviderId(afterProfileRecord?.provider) || + normalizeProviderId(beforeProfileRecord?.provider) || + extractProviderFromProfileId(profileId); + const protectsActiveProvider = !!provider && activeProviders.has(provider); + const protectsExplicitProfile = explicitProfileIds.has(profileId); + if (!protectsActiveProvider && !protectsExplicitProfile) { + continue; + } + + const repaired = buildProfileMetadata({ + profileId, + before: beforeProfile, + after: afterProfile, + providerHint: + explicitProfileProviders.get(profileId)?.size === 1 + ? [...(explicitProfileProviders.get(profileId) ?? [])][0] + : undefined, + }); + if (!repaired) { + warnings.push( + `auth.profiles.${profileId}: active auth profile metadata could not be inferred; repair manually before running doctor --fix.`, + ); + continue; + } + const profiles = ensureAuthProfiles(config); + profiles[profileId] = repaired; + repairs.push( + `Repaired auth.profiles.${profileId} metadata for active ${repaired.provider} auth.`, + ); + } + + return { config, repairs, warnings }; +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 0f834da73d6..98443d2de10 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -257,10 +257,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { doctorFixCommand, }); ({ cfg, candidate, pendingChanges, fixHints } = unknownStep.state); - if (unknownStep.removed.length > 0) { - const lines = unknownStep.removed.map((path) => `- ${path}`).join("\n"); + if (unknownStep.removed.length > 0 || unknownStep.repairs.length > 0) { + const lines = [ + ...unknownStep.removed.map((path) => `- ${path}`), + ...unknownStep.repairs.map((change) => `- ${change}`), + ].join("\n"); note(lines, shouldRepair ? "Doctor changes" : "Unknown config keys"); } + if (unknownStep.warnings.length > 0) { + note(unknownStep.warnings.join("\n"), "Doctor warnings"); + } const finalized = await finalizeDoctorConfigFlow({ cfg, diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts index 77f9e48c41c..5cefdfa1717 100644 --- a/src/commands/doctor/shared/config-flow-steps.test.ts +++ b/src/commands/doctor/shared/config-flow-steps.test.ts @@ -160,4 +160,332 @@ describe("doctor config flow steps", () => { expect(result.state.candidate).toEqual({}); expect(result.state.fixHints).toContain('Run "openclaw doctor --fix" to remove these keys.'); }); + + it("repairs active malformed auth profile metadata after unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:default metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("keeps valid active auth profile metadata while stripping stale secret fields", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": { provider: "openai", mode: "api_key" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-test", + }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("repairs non-default auth profiles for active providers", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:work": {}, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:work": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:work metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:work"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("preserves explicit model auth profile refs during unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("infers providers for bare auth profile suffixes", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + work: {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + }, + removed: ["auth.profiles.work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + work: { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.warnings).toEqual([]); + expect(result.state.cfg.auth?.profiles?.work).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("protects auth profiles referenced only by channel model overrides", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); }); diff --git a/src/commands/doctor/shared/config-flow-steps.ts b/src/commands/doctor/shared/config-flow-steps.ts index 79f17abe604..3c73da4c0ea 100644 --- a/src/commands/doctor/shared/config-flow-steps.ts +++ b/src/commands/doctor/shared/config-flow-steps.ts @@ -1,4 +1,5 @@ import { formatConfigIssueLines } from "../../../config/issue-format.js"; +import { protectActiveAuthProfileConfig } from "../../doctor-auth-profile-config.js"; import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js"; import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js"; import type { DoctorConfigMutationState } from "./config-mutation-state.js"; @@ -75,21 +76,29 @@ export function applyUnknownConfigKeyStep(params: { }): { state: DoctorConfigMutationState; removed: string[]; + repairs: string[]; + warnings: string[]; } { const unknown = stripUnknownConfigKeys(params.state.candidate); if (unknown.removed.length === 0) { - return { state: params.state, removed: [] }; + return { state: params.state, removed: [], repairs: [], warnings: [] }; } + const protectedAuth = protectActiveAuthProfileConfig({ + before: params.state.candidate, + after: unknown.config, + }); return { state: { - cfg: params.shouldRepair ? unknown.config : params.state.cfg, - candidate: unknown.config, + cfg: params.shouldRepair ? protectedAuth.config : params.state.cfg, + candidate: protectedAuth.config, pendingChanges: true, fixHints: params.shouldRepair ? params.state.fixHints : [...params.state.fixHints, `Run "${params.doctorFixCommand}" to remove these keys.`], }, removed: unknown.removed, + repairs: protectedAuth.repairs, + warnings: protectedAuth.warnings, }; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index b1bfbf6894a..6d3ddb0133f 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -2,6 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-r import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; import { normalizeChatChannelId } from "../../../channels/registry.js"; import { isChannelConfigured } from "../../../config/channel-configured.js"; +import { collectConfiguredModelRefs } from "../../../config/model-refs.js"; import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; @@ -151,49 +152,13 @@ function collectConfiguredProviderIds(cfg: OpenClawConfig): Set { for (const providerId of Object.keys(asObjectRecord(cfg.models?.providers) ?? {})) { add(providerId); } - const collectModelRef = (value: unknown) => { - const ref = normalizeId(value); - const slash = ref?.indexOf("/") ?? -1; - if (ref && slash > 0) { - add(ref.slice(0, slash)); + for (const { value } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { + const slash = value.indexOf("/"); + if (slash > 0) { + add(value.slice(0, slash)); } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - collectModelRef(value); - return; - } - const record = asObjectRecord(value); - if (!record) { - return; - } - collectModelRef(record.primary); - if (Array.isArray(record.fallbacks)) { - for (const fallback of record.fallbacks) { - collectModelRef(fallback); - } - } - }; - const collectAgent = (agent: unknown) => { - const record = asObjectRecord(agent); - if (!record) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(record[key]); - } - for (const modelRef of Object.keys(asObjectRecord(record.models) ?? {})) { - collectModelRef(modelRef); - } - }; - collectAgent(cfg.agents?.defaults); - for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) { - collectAgent(agent); } return ids; } diff --git a/src/config/model-refs.ts b/src/config/model-refs.ts new file mode 100644 index 00000000000..8008aa8b911 --- /dev/null +++ b/src/config/model-refs.ts @@ -0,0 +1,77 @@ +import { isRecord } from "../utils.js"; + +export type ConfiguredModelRef = { + path: string; + value: string; +}; + +export const AGENT_MODEL_CONFIG_KEYS = [ + "model", + "imageModel", + "imageGenerationModel", + "videoGenerationModel", + "musicGenerationModel", + "pdfModel", +] as const; + +export function collectConfiguredModelRefs( + config: unknown, + options: { includeChannelModelOverrides?: boolean } = {}, +): ConfiguredModelRef[] { + const refs: ConfiguredModelRef[] = []; + const pushModelRef = (path: string, value: unknown) => { + if (typeof value === "string" && value.trim()) { + refs.push({ path, value: value.trim() }); + } + }; + const collectModelConfig = (path: string, value: unknown) => { + if (typeof value === "string") { + pushModelRef(path, value); + return; + } + if (!isRecord(value)) { + return; + } + pushModelRef(`${path}.primary`, value.primary); + if (Array.isArray(value.fallbacks)) { + for (const [index, entry] of value.fallbacks.entries()) { + pushModelRef(`${path}.fallbacks.${index}`, entry); + } + } + }; + const collectFromAgent = (path: string, agent: unknown) => { + if (!isRecord(agent)) { + return; + } + for (const key of AGENT_MODEL_CONFIG_KEYS) { + collectModelConfig(`${path}.${key}`, agent[key]); + } + if (isRecord(agent.models)) { + for (const modelRef of Object.keys(agent.models)) { + pushModelRef(`${path}.models.${modelRef}`, modelRef); + } + } + }; + + const root = isRecord(config) ? config : {}; + const agents = isRecord(root.agents) ? root.agents : {}; + collectFromAgent("agents.defaults", agents.defaults); + if (Array.isArray(agents.list)) { + for (const [index, entry] of agents.list.entries()) { + collectFromAgent(`agents.list.${index}`, entry); + } + } + if (options.includeChannelModelOverrides !== false) { + const channels = isRecord(root.channels) ? root.channels : {}; + const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; + for (const [channelId, channelMap] of Object.entries(modelByChannel)) { + if (!isRecord(channelMap)) { + continue; + } + for (const [targetId, modelRef] of Object.entries(channelMap)) { + pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef); + } + } + } + return refs; +} diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 745dff0b4c6..71148a45186 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -15,6 +15,7 @@ import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.j import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { isChannelConfigured } from "./channel-configured.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js"; import type { PluginAutoEnableCandidate, @@ -47,61 +48,6 @@ function resolveAutoEnableProviderPluginIds( return Object.fromEntries(entries); } -function collectModelRefs(cfg: OpenClawConfig): string[] { - const refs: string[] = []; - const pushModelRef = (value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push(value.trim()); - } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - pushModelRef(value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(value.primary); - const fallbacks = value.fallbacks; - if (Array.isArray(fallbacks)) { - for (const entry of fallbacks) { - pushModelRef(entry); - } - } - }; - const collectFromAgent = (agent: Record | null | undefined) => { - if (!agent) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(agent[key]); - } - const models = agent.models; - if (isRecord(models)) { - for (const key of Object.keys(models)) { - pushModelRef(key); - } - } - }; - - collectFromAgent(cfg.agents?.defaults as Record | undefined); - const list = cfg.agents?.list; - if (Array.isArray(list)) { - for (const entry of list) { - if (isRecord(entry)) { - collectFromAgent(entry); - } - } - } - return refs; -} - function extractProviderFromModelRef(value: string): string | null { const trimmed = value.trim(); const slash = trimmed.indexOf("/"); @@ -157,7 +103,9 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean } } - for (const ref of collectModelRefs(cfg)) { + for (const { value: ref } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { const provider = extractProviderFromModelRef(ref); if (provider && provider === normalized) { return true; @@ -493,7 +441,7 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) { return true; } - if (collectModelRefs(cfg).length > 0) { + if (collectConfiguredModelRefs(cfg, { includeChannelModelOverrides: false }).length > 0) { return true; } return hasConfiguredEmbeddedHarnessRuntime(cfg, env); @@ -618,7 +566,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const modelRef of collectModelRefs(params.config)) { + for (const { value: modelRef } of collectConfiguredModelRefs(params.config, { + includeChannelModelOverrides: false, + })) { const owningPluginIds = resolveOwningPluginIdsForModelRef({ model: modelRef, config: params.config, diff --git a/src/config/validation.ts b/src/config/validation.ts index 60d4375cff7..ac60dfd8140 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -35,6 +35,7 @@ import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-value import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js"; import { collectChannelSchemaMetadata } from "./channel-config-metadata.js"; import { materializeRuntimeConfig } from "./materialize.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -50,10 +51,6 @@ type AllowedValuesCollection = { hasValues: boolean; }; type JsonSchemaLike = Record; -type ConfiguredModelRef = { - path: string; - value: string; -}; function stripDeprecatedValidationKeys(raw: unknown): unknown { if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) { @@ -1110,58 +1107,6 @@ function validateConfigObjectWithPluginsBase( issues.push(issue); }; - const collectConfiguredModelRefs = (): ConfiguredModelRef[] => { - const refs: ConfiguredModelRef[] = []; - const pushModelRef = (path: string, value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push({ path, value: value.trim() }); - } - }; - const collectModelConfig = (path: string, value: unknown) => { - if (typeof value === "string") { - pushModelRef(path, value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(`${path}.primary`, value.primary); - if (Array.isArray(value.fallbacks)) { - for (const [index, entry] of value.fallbacks.entries()) { - pushModelRef(`${path}.fallbacks.${index}`, entry); - } - } - }; - const collectFromAgent = (path: string, agent: unknown) => { - if (!isRecord(agent)) { - return; - } - for (const key of [ - "model", - "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - "pdfModel", - ]) { - collectModelConfig(`${path}.${key}`, agent[key]); - } - if (isRecord(agent.models)) { - for (const modelRef of Object.keys(agent.models)) { - pushModelRef(`${path}.models.${modelRef}`, modelRef); - } - } - }; - - collectFromAgent("agents.defaults", config.agents?.defaults); - if (Array.isArray(config.agents?.list)) { - for (const [index, entry] of config.agents.list.entries()) { - collectFromAgent(`agents.list.${index}`, entry); - } - } - return refs; - }; - const parseProviderModelRef = (value: string): { provider: string; model: string } | null => { const slashIndex = value.indexOf("/"); if (slashIndex <= 0 || slashIndex >= value.length - 1) { @@ -1173,7 +1118,7 @@ function validateConfigObjectWithPluginsBase( }; const validateConfiguredModelRefs = () => { - const configuredRefs = collectConfiguredModelRefs(); + const configuredRefs = collectConfiguredModelRefs(config); if (configuredRefs.length === 0) { return; }