Files
NianToB/tests/unit/model-diagnostics.test.ts

340 lines
12 KiB
TypeScript

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<string, unknown>,
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<string, unknown>) => {
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<string, { timeoutSeconds?: number }>;
};
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<string, { provider?: string; key?: string }>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
};
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<string, unknown> };
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<string, unknown>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
};
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<string, unknown> };
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']);
});
});