import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { getOpenClawConfigDir } from './paths'; import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { logger } from './logger'; import { YINIAN_LEGACY_MODEL_PROVIDER_KEYS, YINIAN_MODEL_AUTH_PROFILE_ID, YINIAN_MODEL_DEFAULT_BASE_URL, YINIAN_MODEL_PROVIDER_KEY, } from '../../shared/yinian-model'; const YINIAN_INTERNAL_PROVIDER_KEYS = [ YINIAN_MODEL_PROVIDER_KEY, ...YINIAN_LEGACY_MODEL_PROVIDER_KEYS, ] as const; const YINIAN_FALLBACK_SKILL_IDS = ['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search', 'web-search']; type JsonObject = Record; type AuthProfileEntry = { type?: unknown; provider?: unknown; [key: string]: unknown; }; type AuthProfilesStore = { version?: unknown; profiles?: Record; order?: Record; lastGood?: Record; [key: string]: unknown; }; export type ModelDiagnosticStatus = 'ok' | 'warning' | 'error'; export interface YinianModelConfigCheck { id: string; label: string; status: ModelDiagnosticStatus; detail: string; } export interface YinianModelConfigDiagnostics { capturedAt: number; ok: boolean; model: { primary: string | null; fallbacks: string[]; providerKey: string | null; modelId: string | null; }; runtime: { heartbeatEvery: string | null; heartbeatDisabled: boolean; }; providers: Array<{ key: string; configured: boolean; baseUrl: string | null; api: string | null; timeoutSeconds: number | null; authHeader: boolean | null; modelCount: number; }>; authProfiles: { path: string; exists: boolean; providers: Array<{ provider: string; profileCount: number; profileIds: string[]; types: string[]; hasDefaultProfile: boolean; lastGoodProfileId: string | null; }>; }; checks: YinianModelConfigCheck[]; paths: { openclawConfig: string; authProfiles: string; }; } function isPlainRecord(value: unknown): value is JsonObject { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function arraysEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } function resolveYinianEnabledSkillIds(config: JsonObject): string[] { const skills = isPlainRecord(config.skills) ? config.skills : {}; const entries = isPlainRecord(skills.entries) ? skills.entries : {}; const enabled = Object.entries(entries) .filter(([, value]) => !isPlainRecord(value) || value.enabled !== false) .map(([id]) => id.trim()) .filter(Boolean); const unique = [...new Set(enabled)]; const ordered = [ ...YINIAN_FALLBACK_SKILL_IDS.filter((id) => unique.includes(id)), ...unique.filter((id) => !YINIAN_FALLBACK_SKILL_IDS.includes(id)).sort((left, right) => left.localeCompare(right)), ]; return ordered.length > 0 ? ordered : [...YINIAN_FALLBACK_SKILL_IDS]; } function getAuthProfilesPath(agentId = 'main'): string { return join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json'); } async function readJsonFile(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf8'); return JSON.parse(raw) as T; } catch { return null; } } async function writeJsonFile(filePath: string, data: unknown): Promise { await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); } function readAuthProfilesStore(filePath: string): Promise { return readJsonFile(filePath).then((store) => { if (store && isPlainRecord(store.profiles)) return store; return { version: 1, profiles: {} }; }); } function getPrimaryModel(config: JsonObject): string | null { const agents = isPlainRecord(config.agents) ? config.agents : {}; const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {}; const model = isPlainRecord(defaults.model) ? defaults.model : {}; return typeof model.primary === 'string' && model.primary.trim() ? model.primary : null; } function getFallbackModels(config: JsonObject): string[] { const agents = isPlainRecord(config.agents) ? config.agents : {}; const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {}; const model = isPlainRecord(defaults.model) ? defaults.model : {}; return Array.isArray(model.fallbacks) ? model.fallbacks.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : []; } function splitModelRef(modelRef: string | null): { providerKey: string | null; modelId: string | null } { if (!modelRef) return { providerKey: null, modelId: null }; const slashIndex = modelRef.indexOf('/'); if (slashIndex <= 0 || slashIndex >= modelRef.length - 1) { return { providerKey: null, modelId: modelRef }; } return { providerKey: modelRef.slice(0, slashIndex), modelId: modelRef.slice(slashIndex + 1), }; } function groupAuthProfiles( store: AuthProfilesStore, referencedProviderKeys?: Set, ): YinianModelConfigDiagnostics['authProfiles']['providers'] { const profiles = isPlainRecord(store.profiles) ? store.profiles : {}; const groups = new Map }>(); for (const [profileId, profile] of Object.entries(profiles)) { if (!isPlainRecord(profile)) continue; const provider = typeof profile.provider === 'string' && profile.provider.trim() ? profile.provider : profileId.split(':')[0] || 'unknown'; if (referencedProviderKeys && !referencedProviderKeys.has(provider)) continue; const type = typeof profile.type === 'string' && profile.type.trim() ? profile.type : 'unknown'; const group = groups.get(provider) ?? { ids: [], types: new Set() }; group.ids.push(profileId); group.types.add(type); groups.set(provider, group); } const lastGood = isPlainRecord(store.lastGood) ? store.lastGood as Record : {}; return Array.from(groups.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, group]) => ({ provider, profileCount: group.ids.length, profileIds: group.ids.sort(), types: Array.from(group.types).sort(), hasDefaultProfile: group.ids.includes(`${provider}:default`), lastGoodProfileId: typeof lastGood[provider] === 'string' ? lastGood[provider] as string : null, })); } function hasMatchingAuthProfile(store: AuthProfilesStore, providerKey: string | null): boolean { if (!providerKey || !isPlainRecord(store.profiles)) return false; return Object.values(store.profiles).some((profile) => ( isPlainRecord(profile) && profile.provider === providerKey )); } function hasConfiguredProvider(config: JsonObject, providerKey: string | null): boolean { if (!providerKey) return false; const models = isPlainRecord(config.models) ? config.models : {}; const providers = isPlainRecord(models.providers) ? models.providers : {}; return isPlainRecord(providers[providerKey]); } function getProviderEntry(config: JsonObject, providerKey: string | null): JsonObject | null { if (!providerKey) return null; const models = isPlainRecord(config.models) ? config.models : {}; const providers = isPlainRecord(models.providers) ? models.providers : {}; return isPlainRecord(providers[providerKey]) ? providers[providerKey] : null; } function isPlaceholderModelApiProvider(provider: JsonObject | null): boolean { if (!provider) return false; return provider.baseUrl === YINIAN_MODEL_DEFAULT_BASE_URL; } function collectModelProviderKeys(primary: string | null, fallbacks: string[]): string[] { const providerKeys = [ splitModelRef(primary).providerKey, ...fallbacks.map((modelRef) => splitModelRef(modelRef).providerKey), ].filter((providerKey): providerKey is string => typeof providerKey === 'string' && providerKey.trim().length > 0); return [...new Set(providerKeys)]; } function buildProviderDiagnostics(config: JsonObject, referencedProviderKeys: string[]): YinianModelConfigDiagnostics['providers'] { const models = isPlainRecord(config.models) ? config.models : {}; const providers = isPlainRecord(models.providers) ? models.providers : {}; return referencedProviderKeys.map((key) => { const entry = isPlainRecord(providers[key]) ? providers[key] : {}; const modelsList = Array.isArray(entry.models) ? entry.models : []; return { key, configured: isPlainRecord(providers[key]), baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : null, api: typeof entry.api === 'string' ? entry.api : null, timeoutSeconds: typeof entry.timeoutSeconds === 'number' ? entry.timeoutSeconds : null, authHeader: typeof entry.authHeader === 'boolean' ? entry.authHeader : null, modelCount: modelsList.length, }; }); } function getHeartbeatEvery(config: JsonObject): string | null { const agents = isPlainRecord(config.agents) ? config.agents : {}; const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {}; const heartbeat = isPlainRecord(defaults.heartbeat) ? defaults.heartbeat : {}; return typeof heartbeat.every === 'string' ? heartbeat.every : null; } export async function ensureYinianModelRuntimeConfigured(): Promise { const config = await readOpenClawConfig() as JsonObject; const models = isPlainRecord(config.models) ? { ...config.models } : {}; const providers = isPlainRecord(models.providers) ? { ...models.providers } : {}; let changed = false; if ('pricing' in models) { delete models.pricing; changed = true; } for (const providerKey of YINIAN_INTERNAL_PROVIDER_KEYS) { const currentProvider = providers[providerKey]; if (!isPlainRecord(currentProvider)) continue; if ('timeoutSeconds' in currentProvider) { const nextProvider = { ...currentProvider }; delete nextProvider.timeoutSeconds; providers[providerKey] = nextProvider; changed = true; } } if (models.mode !== 'merge') { models.mode = 'merge'; changed = true; } const agents = isPlainRecord(config.agents) ? { ...config.agents } : {}; const defaults = isPlainRecord(agents.defaults) ? { ...agents.defaults } : {}; const defaultModel = isPlainRecord(defaults.model) ? { ...defaults.model } : {}; const currentPrimary = typeof defaultModel.primary === 'string' && defaultModel.primary.trim() ? defaultModel.primary.trim() : ''; const { providerKey: currentPrimaryProvider } = splitModelRef(currentPrimary || null); const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY]) ? providers[YINIAN_MODEL_PROVIDER_KEY] : null; const hasPlaceholderYinianProvider = isPlaceholderModelApiProvider(currentYinianProvider); const shouldCleanYinianModelAuth = !currentYinianProvider || hasPlaceholderYinianProvider; if (hasPlaceholderYinianProvider) { delete providers[YINIAN_MODEL_PROVIDER_KEY]; if (currentPrimaryProvider === YINIAN_MODEL_PROVIDER_KEY) { delete defaultModel.primary; defaultModel.fallbacks = []; } changed = true; } if (!Array.isArray(defaultModel.fallbacks)) { defaultModel.fallbacks = []; changed = true; } defaults.model = defaultModel; const enabledSkillIds = resolveYinianEnabledSkillIds(config); const currentDefaultSkills = Array.isArray(defaults.skills) ? defaults.skills.filter((value): value is string => typeof value === 'string') : []; if (!arraysEqual(currentDefaultSkills, enabledSkillIds)) { defaults.skills = enabledSkillIds; changed = true; } const heartbeat = isPlainRecord(defaults.heartbeat) ? { ...defaults.heartbeat } : {}; if (heartbeat.every !== '0m') { heartbeat.every = '0m'; defaults.heartbeat = heartbeat; changed = true; } defaults.workspace = typeof defaults.workspace === 'string' && defaults.workspace.trim() ? defaults.workspace : join(homedir(), '.openclaw', 'workspace'); agents.defaults = defaults; const list = Array.isArray(agents.list) ? agents.list : []; const normalizedList = list.map((item) => { if (!isPlainRecord(item) || item.id !== 'main') return item; const tools = isPlainRecord(item.tools) ? item.tools : {}; const currentSkills = Array.isArray(item.skills) ? item.skills.filter((value): value is string => typeof value === 'string') : []; const nextSkills = arraysEqual(currentSkills, enabledSkillIds) ? currentSkills : enabledSkillIds; return { ...item, skills: nextSkills, tools: { ...tools, profile: 'coding', }, }; }); if (JSON.stringify(normalizedList) !== JSON.stringify(list)) { agents.list = normalizedList; changed = true; } const activeList = Array.isArray(agents.list) ? agents.list : list; const hasMainAgent = activeList.some((item) => isPlainRecord(item) && item.id === 'main'); if (!hasMainAgent) { agents.list = [ ...activeList, { id: 'main', name: '智念助手', default: true, workspace: join(homedir(), '.openclaw', 'workspace'), agentDir: '~/.openclaw/agents/main/agent', skills: enabledSkillIds, tools: { profile: 'coding' }, }, ]; changed = true; } if (JSON.stringify(config.agents) !== JSON.stringify(agents)) { config.agents = agents; changed = true; } if (changed) { models.providers = providers; config.models = models; await writeOpenClawConfig(config); logger.info('[provider-sync] Applied Yinian model runtime defaults'); } if (shouldCleanYinianModelAuth) { await removeYinianModelAuthProfileLeftovers(); } } export async function removeYinianModelAuthProfileLeftovers(agentId = 'main'): Promise { const authPath = getAuthProfilesPath(agentId); const store = await readAuthProfilesStore(authPath); store.version = typeof store.version === 'number' ? store.version : 1; store.profiles = isPlainRecord(store.profiles) ? store.profiles : {}; let changed = false; for (const [profileId, profile] of Object.entries(store.profiles)) { const isYinianProfileId = profileId === YINIAN_MODEL_AUTH_PROFILE_ID || profileId.startsWith(`${YINIAN_MODEL_PROVIDER_KEY}:`); const isYinianProvider = isPlainRecord(profile) && profile.provider === YINIAN_MODEL_PROVIDER_KEY; if (isYinianProfileId || isYinianProvider) { delete store.profiles[profileId]; changed = true; } } if (isPlainRecord(store.order) && YINIAN_MODEL_PROVIDER_KEY in store.order) { const order = { ...store.order } as Record; delete order[YINIAN_MODEL_PROVIDER_KEY]; store.order = order; changed = true; } if (isPlainRecord(store.lastGood) && YINIAN_MODEL_PROVIDER_KEY in store.lastGood) { const lastGood = { ...store.lastGood } as Record; delete lastGood[YINIAN_MODEL_PROVIDER_KEY]; store.lastGood = lastGood; changed = true; } if (changed) { await writeJsonFile(authPath, store); logger.info('[provider-sync] Removed legacy Yinian model auth profile leftovers'); } } export async function buildYinianModelConfigDiagnostics(): Promise { const openclawConfigPath = join(getOpenClawConfigDir(), 'openclaw.json'); const authProfilesPath = getAuthProfilesPath('main'); const config = await readOpenClawConfig() as JsonObject; const authStore = await readAuthProfilesStore(authProfilesPath); const primary = getPrimaryModel(config); const fallbacks = getFallbackModels(config); const referencedProviderKeys = collectModelProviderKeys(primary, fallbacks); const referencedProviderKeySet = new Set(referencedProviderKeys); const { providerKey, modelId } = splitModelRef(primary); const heartbeatEvery = getHeartbeatEvery(config); const providerConfigured = hasConfiguredProvider(config, providerKey); const providerEntry = getProviderEntry(config, providerKey); const placeholderProvider = isPlaceholderModelApiProvider(providerEntry); const authProfileConfigured = hasMatchingAuthProfile(authStore, providerKey); const providers = buildProviderDiagnostics(config, referencedProviderKeys); const checks: YinianModelConfigCheck[] = [ { id: 'primary-model', label: '默认模型', status: primary ? 'ok' : 'error', detail: primary ?? '未配置默认模型', }, { id: 'provider-entry', label: '模型服务', status: providerConfigured && !placeholderProvider ? 'ok' : 'error', detail: providerKey ? ( !providerConfigured ? `缺少 ${providerKey} 服务配置` : placeholderProvider ? `${providerKey} 仍使用示例 API 地址,请在设置中重新保存默认模型服务` : `已配置 ${providerKey}` ) : '无法从默认模型识别服务', }, { id: 'auth-profile', label: '调用凭据', status: authProfileConfigured ? 'ok' : 'error', detail: providerKey ? (authProfileConfigured ? `已找到 ${providerKey} 的本地凭据` : `未找到 ${providerKey} 的本地凭据`) : '无法检查调用凭据', }, { id: 'heartbeat', label: '后台心跳', status: heartbeatEvery === '0m' ? 'ok' : 'warning', detail: heartbeatEvery === '0m' ? '已关闭,不会把心跳任务混入普通会话' : `当前间隔 ${heartbeatEvery ?? '默认'},可能产生后台心跳会话`, }, ]; return { capturedAt: Date.now(), ok: checks.every((check) => check.status !== 'error'), model: { primary, fallbacks, providerKey, modelId, }, runtime: { heartbeatEvery, heartbeatDisabled: heartbeatEvery === '0m', }, providers, authProfiles: { path: authProfilesPath, exists: existsSync(authProfilesPath), providers: groupAuthProfiles(authStore, referencedProviderKeySet), }, checks, paths: { openclawConfig: openclawConfigPath, authProfiles: authProfilesPath, }, }; }