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'; const YINIAN_MODEL_TIMEOUT_SECONDS = 300; const YINIAN_MODEL_PROVIDER_KEY = 'minimax'; const YINIAN_MODEL_ID = 'MiniMax-M2.7'; const YINIAN_MODEL_REF = `${YINIAN_MODEL_PROVIDER_KEY}/${YINIAN_MODEL_ID}`; const YINIAN_INTERNAL_PROVIDER_KEYS = ['minimax', 'minimax-portal']; const YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS = [ 'minimax:cn', 'minimax-cn:default', 'minimax-portal-cn:default', 'minimax-portal:default', ]; const YINIAN_MODEL_AUTH_TARGET_PROFILE_ID = 'minimax:default'; const YINIAN_MODEL_AUTH_TARGET_PROVIDER = 'minimax'; 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: { pricingEnabled: boolean | null; pricingCatalogFetchDisabled: 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 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): 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'; 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 buildProviderDiagnostics(config: JsonObject, primaryProviderKey: string | null): YinianModelConfigDiagnostics['providers'] { const models = isPlainRecord(config.models) ? config.models : {}; const providers = isPlainRecord(models.providers) ? models.providers : {}; const providerKeys = Array.from(new Set([ ...(primaryProviderKey ? [primaryProviderKey] : []), ...YINIAN_INTERNAL_PROVIDER_KEYS, ])); return providerKeys.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 getPricingEnabled(config: JsonObject): boolean | null { const models = isPlainRecord(config.models) ? config.models : {}; const pricing = isPlainRecord(models.pricing) ? models.pricing : null; return typeof pricing?.enabled === 'boolean' ? pricing.enabled : 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 } : {}; const pricing = isPlainRecord(models.pricing) ? { ...models.pricing } : {}; let changed = false; const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY]) ? { ...providers[YINIAN_MODEL_PROVIDER_KEY] } : {}; const currentModels = Array.isArray(currentYinianProvider.models) ? currentYinianProvider.models.filter(isPlainRecord) : []; const hasYinianModel = currentModels.some((item) => item.id === YINIAN_MODEL_ID); const nextYinianProvider = { ...currentYinianProvider, baseUrl: typeof currentYinianProvider.baseUrl === 'string' && currentYinianProvider.baseUrl.trim() ? currentYinianProvider.baseUrl : 'https://api.minimaxi.com/anthropic', api: typeof currentYinianProvider.api === 'string' && currentYinianProvider.api.trim() ? currentYinianProvider.api : 'anthropic-messages', authHeader: typeof currentYinianProvider.authHeader === 'boolean' ? currentYinianProvider.authHeader : true, timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS, models: hasYinianModel ? currentModels : [ ...currentModels, { id: YINIAN_MODEL_ID, name: 'MiniMax M2.7', reasoning: true, input: ['text', 'image'], contextWindow: 204800, maxTokens: 131072, }, ], }; if (JSON.stringify(providers[YINIAN_MODEL_PROVIDER_KEY]) !== JSON.stringify(nextYinianProvider)) { providers[YINIAN_MODEL_PROVIDER_KEY] = nextYinianProvider; changed = true; } for (const providerKey of YINIAN_INTERNAL_PROVIDER_KEYS) { const currentProvider = providers[providerKey]; if (!isPlainRecord(currentProvider)) continue; if (currentProvider.timeoutSeconds !== YINIAN_MODEL_TIMEOUT_SECONDS) { providers[providerKey] = { ...currentProvider, timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS, }; changed = true; } } if (pricing.enabled !== false) { pricing.enabled = false; 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 } : {}; if (defaultModel.primary !== YINIAN_MODEL_REF) { defaultModel.primary = YINIAN_MODEL_REF; changed = true; } if (!Array.isArray(defaultModel.fallbacks)) { defaultModel.fallbacks = ['minimax/MiniMax-M2.5']; changed = true; } defaults.model = defaultModel; if (!Array.isArray(defaults.skills)) { defaults.skills = []; 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 : {}; return { ...item, 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: [], tools: { profile: 'coding' }, }, ]; changed = true; } if (JSON.stringify(config.agents) !== JSON.stringify(agents)) { config.agents = agents; changed = true; } if (changed) { models.providers = providers; models.pricing = pricing; config.models = models; await writeOpenClawConfig(config); logger.info('[provider-sync] Applied Yinian model runtime defaults'); } await ensureYinianModelAuthProfileAliases(); } export async function ensureYinianModelAuthProfileAliases(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 : {}; const target = store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID]; const sourceId = YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS.find((profileId) => isPlainRecord(store.profiles?.[profileId])); const source = sourceId ? store.profiles[sourceId] : null; let changed = false; if (!isPlainRecord(target) && isPlainRecord(source)) { store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = { ...source, provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER, }; changed = true; } else if (isPlainRecord(target) && target.provider !== YINIAN_MODEL_AUTH_TARGET_PROVIDER) { store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = { ...target, provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER, }; changed = true; } if (isPlainRecord(store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID])) { const order = isPlainRecord(store.order) ? { ...store.order } as Record : {}; const currentOrder = Array.isArray(order[YINIAN_MODEL_AUTH_TARGET_PROVIDER]) ? order[YINIAN_MODEL_AUTH_TARGET_PROVIDER] : []; if (!currentOrder.includes(YINIAN_MODEL_AUTH_TARGET_PROFILE_ID)) { order[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = [ YINIAN_MODEL_AUTH_TARGET_PROFILE_ID, ...currentOrder.filter((item) => item !== YINIAN_MODEL_AUTH_TARGET_PROFILE_ID), ]; store.order = order; changed = true; } const lastGood = isPlainRecord(store.lastGood) ? { ...store.lastGood } as Record : {}; if (!lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER]) { lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = YINIAN_MODEL_AUTH_TARGET_PROFILE_ID; store.lastGood = lastGood; changed = true; } } if (changed) { await writeJsonFile(authPath, store); logger.info('[provider-sync] Normalized Yinian model auth profile aliases'); } } 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 { providerKey, modelId } = splitModelRef(primary); const pricingEnabled = getPricingEnabled(config); const providerConfigured = hasConfiguredProvider(config, providerKey); const authProfileConfigured = hasMatchingAuthProfile(authStore, providerKey); const providers = buildProviderDiagnostics(config, providerKey); const primaryProvider = providers.find((provider) => provider.key === providerKey); const checks: YinianModelConfigCheck[] = [ { id: 'primary-model', label: '默认模型', status: primary ? 'ok' : 'error', detail: primary ?? '未配置默认模型', }, { id: 'provider-entry', label: '模型服务', status: providerConfigured ? 'ok' : 'error', detail: providerKey ? (providerConfigured ? `已配置 ${providerKey}` : `缺少 ${providerKey} 服务配置`) : '无法从默认模型识别服务', }, { id: 'auth-profile', label: '调用凭据', status: authProfileConfigured ? 'ok' : 'error', detail: providerKey ? (authProfileConfigured ? `已找到 ${providerKey} 的本地凭据` : `未找到 ${providerKey} 的本地凭据`) : '无法检查调用凭据', }, { id: 'timeout', label: '长任务等待', status: (primaryProvider?.timeoutSeconds ?? 0) >= YINIAN_MODEL_TIMEOUT_SECONDS ? 'ok' : 'warning', detail: primaryProvider?.timeoutSeconds ? `${primaryProvider.timeoutSeconds} 秒` : '未显式配置等待时长', }, { id: 'pricing-catalog', label: '外部价格目录', status: pricingEnabled === false ? 'ok' : 'warning', detail: pricingEnabled === false ? '已关闭启动时外部目录拉取' : '未关闭,弱网环境可能拖慢后台启动', }, ]; return { capturedAt: Date.now(), ok: checks.every((check) => check.status !== 'error'), model: { primary, fallbacks, providerKey, modelId, }, runtime: { pricingEnabled, pricingCatalogFetchDisabled: pricingEnabled === false, }, providers, authProfiles: { path: authProfilesPath, exists: existsSync(authProfilesPath), providers: groupAuthProfiles(authStore), }, checks, paths: { openclawConfig: openclawConfigPath, authProfiles: authProfilesPath, }, }; }