import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { YINIAN_MODEL_AUTH_PROFILE_ID, YINIAN_MODEL_DEFAULT_BASE_URL, YINIAN_MODEL_PROVIDER_KEY, } from '../../shared/yinian-model'; const modelDiagnosticsMocks = vi.hoisted(() => ({ config: {} as Record, writeOpenClawConfig: vi.fn(), testOpenClawDir: '/tmp/clawx-tests/model-diagnostics-openclaw', })); vi.mock('@electron/utils/channel-config', () => ({ readOpenClawConfig: vi.fn(async () => modelDiagnosticsMocks.config), writeOpenClawConfig: vi.fn(async (config: Record) => { modelDiagnosticsMocks.config = config; modelDiagnosticsMocks.writeOpenClawConfig(config); }), })); vi.mock('@electron/utils/paths', () => ({ getOpenClawConfigDir: () => modelDiagnosticsMocks.testOpenClawDir, })); vi.mock('@electron/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); function authProfilesPath(homeDir: string): string { return join(homeDir, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json'); } describe('Yinian model diagnostics', () => { const originalHome = process.env.HOME; const testHome = join(tmpdir(), 'clawx-tests', 'model-diagnostics-home'); beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); process.env.HOME = testHome; rmSync(testHome, { recursive: true, force: true }); rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true }); mkdirSync(join(testHome, '.openclaw', 'agents', 'main', 'agent'), { recursive: true }); mkdirSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true }); modelDiagnosticsMocks.config = { models: { providers: { minimax: { baseUrl: 'https://api.minimaxi.com/anthropic', api: 'anthropic-messages', timeoutSeconds: 30, models: [{ id: 'MiniMax-M2.7' }], }, }, pricing: { enabled: true, }, }, agents: { defaults: { model: { primary: 'minimax/MiniMax-M2.7', fallbacks: ['minimax/MiniMax-M2.5'], }, }, }, }; writeFileSync(authProfilesPath(testHome), JSON.stringify({ version: 1, profiles: { 'minimax-cn:default': { type: 'api_key', provider: 'minimax-cn', key: 'secret', }, }, }, null, 2)); }); afterEach(() => { process.env.HOME = originalHome; rmSync(testHome, { recursive: true, force: true }); rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true }); }); it('does not seed legacy runtime model defaults as the generic model API provider', async () => { const { buildYinianModelConfigDiagnostics, ensureYinianModelRuntimeConfigured, } = await import('@electron/utils/model-diagnostics'); await ensureYinianModelRuntimeConfigured(); const config = modelDiagnosticsMocks.config as { models: { mode?: string; pricing?: unknown; providers: Record; }; agents: { defaults: { heartbeat?: { every?: string }; model: { primary?: string; fallbacks?: string[] }; }; }; }; expect(config.models.mode).toBe('merge'); expect(config.models.pricing).toBeUndefined(); expect(config.models.providers.minimax.timeoutSeconds).toBeUndefined(); expect(config.models.providers[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(config.agents.defaults.model.primary).toBe('minimax/MiniMax-M2.7'); expect(config.agents.defaults.model.fallbacks).toEqual(['minimax/MiniMax-M2.5']); expect(config.agents.defaults.heartbeat?.every).toBe('0m'); const authStore = JSON.parse(readFileSync(authProfilesPath(testHome), 'utf8')) as { profiles: Record; order?: Record; lastGood?: Record; }; expect(authStore.profiles[YINIAN_MODEL_AUTH_PROFILE_ID]).toBeUndefined(); expect(authStore.order?.[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(authStore.lastGood?.[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); const diagnostics = await buildYinianModelConfigDiagnostics(); expect(diagnostics.model.primary).toBe('minimax/MiniMax-M2.7'); expect(diagnostics.model.providerKey).toBe('minimax'); expect(diagnostics.model.fallbacks).toEqual(['minimax/MiniMax-M2.5']); expect(diagnostics.runtime.heartbeatDisabled).toBe(true); expect(diagnostics.providers.map((provider) => provider.key)).toEqual(['minimax']); expect(diagnostics.providers.some((provider) => provider.key === YINIAN_MODEL_PROVIDER_KEY)).toBe(false); }); it('removes the legacy placeholder model provider and auth leftovers', async () => { modelDiagnosticsMocks.config = { models: { providers: { [YINIAN_MODEL_PROVIDER_KEY]: { baseUrl: YINIAN_MODEL_DEFAULT_BASE_URL, api: 'openai-completions', timeoutSeconds: 30, models: [{ id: 'custom-model', name: 'Custom Model' }], }, deepseek: { baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions', models: [{ id: 'deepseek-v4-pro' }], }, }, }, agents: { defaults: { model: { primary: `${YINIAN_MODEL_PROVIDER_KEY}/custom-model`, fallbacks: [`${YINIAN_MODEL_PROVIDER_KEY}/custom-model-lite`], }, }, }, }; writeFileSync(authProfilesPath(testHome), JSON.stringify({ version: 1, profiles: { [YINIAN_MODEL_AUTH_PROFILE_ID]: { type: 'api_key', provider: YINIAN_MODEL_PROVIDER_KEY, key: 'placeholder-secret', }, [`${YINIAN_MODEL_PROVIDER_KEY}:legacy`]: { type: 'api_key', provider: YINIAN_MODEL_PROVIDER_KEY, key: 'legacy-secret', }, 'deepseek:default': { type: 'api_key', provider: 'deepseek', key: 'deepseek-secret', }, }, order: { [YINIAN_MODEL_PROVIDER_KEY]: [YINIAN_MODEL_AUTH_PROFILE_ID], deepseek: ['deepseek:default'], }, lastGood: { [YINIAN_MODEL_PROVIDER_KEY]: YINIAN_MODEL_AUTH_PROFILE_ID, deepseek: 'deepseek:default', }, }, null, 2)); const { buildYinianModelConfigDiagnostics, ensureYinianModelRuntimeConfigured, } = await import('@electron/utils/model-diagnostics'); await ensureYinianModelRuntimeConfigured(); const config = modelDiagnosticsMocks.config as { models: { providers: Record }; agents: { defaults: { model: { primary?: string; fallbacks?: string[] } } }; }; expect(config.models.providers[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(config.models.providers.deepseek).toEqual(expect.objectContaining({ baseUrl: 'https://api.deepseek.com/v1', })); expect(config.agents.defaults.model.primary).toBeUndefined(); expect(config.agents.defaults.model.fallbacks).toEqual([]); const authStore = JSON.parse(readFileSync(authProfilesPath(testHome), 'utf8')) as { profiles: Record; order?: Record; lastGood?: Record; }; expect(authStore.profiles[YINIAN_MODEL_AUTH_PROFILE_ID]).toBeUndefined(); expect(authStore.profiles[`${YINIAN_MODEL_PROVIDER_KEY}:legacy`]).toBeUndefined(); expect(authStore.profiles['deepseek:default']).toEqual(expect.objectContaining({ provider: 'deepseek', })); expect(authStore.order?.[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(authStore.order?.deepseek).toEqual(['deepseek:default']); expect(authStore.lastGood?.[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(authStore.lastGood?.deepseek).toBe('deepseek:default'); const diagnostics = await buildYinianModelConfigDiagnostics(); expect(diagnostics.model.primary).toBeNull(); expect(diagnostics.providers).toEqual([]); expect(diagnostics.authProfiles.providers).toEqual([]); }); it('preserves an existing custom default model during runtime repair', async () => { modelDiagnosticsMocks.config = { models: { providers: { 'custom-main': { baseUrl: 'https://llm.example.test/v1', api: 'openai-completions', models: [{ id: 'tenant-model' }], }, }, }, agents: { defaults: { model: { primary: 'custom-main/tenant-model', fallbacks: ['custom-main/tenant-model-lite'], }, }, }, }; writeFileSync(authProfilesPath(testHome), JSON.stringify({ version: 1, profiles: { 'custom-main:default': { type: 'api_key', provider: 'custom-main', key: 'custom-secret', }, }, }, null, 2)); const { buildYinianModelConfigDiagnostics, ensureYinianModelRuntimeConfigured, } = await import('@electron/utils/model-diagnostics'); await ensureYinianModelRuntimeConfigured(); const config = modelDiagnosticsMocks.config as { models: { providers: Record }; agents: { defaults: { model: { primary: string; fallbacks: string[] } } }; }; expect(config.models.providers[YINIAN_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(config.agents.defaults.model.primary).toBe('custom-main/tenant-model'); expect(config.agents.defaults.model.fallbacks).toEqual(['custom-main/tenant-model-lite']); const diagnostics = await buildYinianModelConfigDiagnostics(); expect(diagnostics.ok).toBe(true); expect(diagnostics.model.providerKey).toBe('custom-main'); expect(diagnostics.providers.map((provider) => provider.key)).toEqual(['custom-main']); expect(diagnostics.authProfiles.providers.map((provider) => provider.provider)).toEqual(['custom-main']); }); it('reports only the active DeepSeek provider when other configured providers exist', async () => { modelDiagnosticsMocks.config = { models: { providers: { 'minimax-portal': { baseUrl: 'https://api.minimax.io/anthropic', api: 'anthropic-messages', models: [{ id: 'MiniMax-M3' }], }, deepseek: { baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions', models: [{ id: 'deepseek-v4-pro' }], }, }, }, agents: { defaults: { model: { primary: 'deepseek/deepseek-v4-pro', fallbacks: [], }, }, }, }; writeFileSync(authProfilesPath(testHome), JSON.stringify({ version: 1, profiles: { 'minimax-portal:default': { type: 'api_key', provider: 'minimax-portal', key: 'minimax-secret', }, 'deepseek:default': { type: 'api_key', provider: 'deepseek', key: 'deepseek-secret', }, }, }, null, 2)); const { buildYinianModelConfigDiagnostics, } = await import('@electron/utils/model-diagnostics'); const diagnostics = await buildYinianModelConfigDiagnostics(); expect(diagnostics.ok).toBe(true); expect(diagnostics.model.primary).toBe('deepseek/deepseek-v4-pro'); expect(diagnostics.providers.map((provider) => provider.key)).toEqual(['deepseek']); expect(diagnostics.authProfiles.providers.map((provider) => provider.provider)).toEqual(['deepseek']); }); });