Files
NianToB/electron/utils/model-diagnostics.ts
inman 0abc48189c
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: prepare Zhinian desktop pilot
2026-05-07 21:49:20 +08:00

493 lines
17 KiB
TypeScript

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<string, unknown>;
type AuthProfileEntry = {
type?: unknown;
provider?: unknown;
[key: string]: unknown;
};
type AuthProfilesStore = {
version?: unknown;
profiles?: Record<string, AuthProfileEntry>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
[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<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
}
function readAuthProfilesStore(filePath: string): Promise<AuthProfilesStore> {
return readJsonFile<AuthProfilesStore>(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<string, { ids: string[]; types: Set<string> }>();
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<string>() };
group.ids.push(profileId);
group.types.add(type);
groups.set(provider, group);
}
const lastGood = isPlainRecord(store.lastGood) ? store.lastGood as Record<string, unknown> : {};
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<void> {
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<void> {
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<string, string[]> : {};
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<string, string> : {};
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<YinianModelConfigDiagnostics> {
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,
},
};
}