340 lines
12 KiB
TypeScript
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']);
|
|
});
|
|
});
|