/** Local custom models. Credentials stay in the main process; legacy config is read only. */ import { app, safeStorage } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel, ModelDiscoveryInput, ModelDiscoveryResult, ModelEndpoint } from '@shared/models' import { resolveOpenAIEndpoint } from '@shared/openai-endpoint' import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint' import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection' import { modelMetadata } from './model-metadata' import { resolveModelTokenLimits } from '@shared/model-token-limits' import { getGatewayConfig } from './config' interface StoredModel extends Omit { apiKeyEnc?: string apiKeyPlain?: string } interface StoredRegistry { version: 1 models: StoredModel[] selectedModelId: string | null } let cache: StoredRegistry | null = null let loading: Promise | null = null let writes: Promise = Promise.resolve() function filePath(): string { return join(app.getPath('userData'), 'models.json') } function readKey(model: StoredModel): { key: string; storage: CustomModelProfile['keyStorage'] } { if (model.apiKeyEnc) { try { return { key: safeStorage.decryptString(Buffer.from(model.apiKeyEnc, 'base64')), storage: 'encrypted' } } catch { return { key: '', storage: 'none' } } } return model.apiKeyPlain ? { key: model.apiKeyPlain, storage: 'plaintext' } : { key: '', storage: 'none' } } function writeKey(model: StoredModel, key: string): void { delete model.apiKeyEnc delete model.apiKeyPlain if (!key) return try { if (safeStorage.isEncryptionAvailable()) model.apiKeyEnc = safeStorage.encryptString(key).toString('base64') else model.apiKeyPlain = key } catch { throw new Error('无法安全保存 API Key,请检查系统加密服务') } } function viewOf(model: StoredModel): CustomModelProfile { const { apiKeyEnc: _encrypted, apiKeyPlain: _plain, ...profile } = model const { key, storage } = readKey(model) const { error, compaction: _compaction, ...budget } = resolveModelTokenLimits(profile) return { ...profile, ...(!error ? budget : {}), supportedEfforts: profile.supportedEfforts?.slice(), hasApiKey: !!key, keyStorage: key ? storage : 'none' } } function registryView(registry: StoredRegistry): ModelRegistryView { return { models: registry.models.map(viewOf), selectedModelId: registry.selectedModelId } } async function persist(registry: StoredRegistry): Promise { const file = filePath() await fs.mkdir(join(file, '..'), { recursive: true }) const temporary = `${file}.${randomUUID()}.tmp` try { await fs.writeFile(temporary, JSON.stringify(registry, null, 2), { encoding: 'utf8', mode: 0o600 }) await fs.rename(temporary, file) cache = registry } catch (error) { await fs.unlink(temporary).catch(() => undefined) throw error } } async function readRegistry(persistMigration: boolean): Promise { let raw: string try { raw = await fs.readFile(filePath(), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const legacy = await getGatewayConfig() const migrated: StoredModel = { id: 'legacy-default', name: legacy.model, providerId: 'custom', model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key', protocol: 'anthropic', supportsToolCall: true, supportsImages: false, supportsReasoning: false, temperature: legacy.temperature, maxToolRounds: legacy.maxToolRounds } writeKey(migrated, legacy.apiKey) const initial: StoredRegistry = { version: 1, models: [migrated], selectedModelId: migrated.id } if (persistMigration) await persist(initial) return initial } let parsed: Partial & { schemaVersion?: number } try { parsed = JSON.parse(raw) as typeof parsed } catch { throw new Error('模型配置文件损坏,请保留 models.json 后检查内容') } // Accept either version marker without rewriting existing secrets during a read. if (!parsed || typeof parsed !== 'object' || (parsed.version ?? parsed.schemaVersion) !== 1 || !Array.isArray(parsed.models)) { throw new Error('无法读取此版本的模型配置文件') } const ids = new Set() for (const model of parsed.models) { if (!model || typeof model.id !== 'string' || !model.id || ids.has(model.id) || typeof model.model !== 'string' || typeof model.baseUrl !== 'string' || typeof model.name !== 'string' || typeof model.providerId !== 'string') { throw new Error('模型配置内容无效,请保留 models.json 后检查内容') } ids.add(model.id) } const selectedModelId = parsed.selectedModelId === null ? null : typeof parsed.selectedModelId === 'string' && ids.has(parsed.selectedModelId) ? parsed.selectedModelId : parsed.models[0]?.id ?? null return { version: 1, models: parsed.models, selectedModelId } } async function load(): Promise { if (cache) return cache if (loading) return loading loading = readRegistry(true).then(registry => { cache = registry; return registry }) try { return await loading } finally { loading = null } } function mutate(operation: (registry: StoredRegistry) => Promise): Promise { const next = writes.then(async () => operation(await load())) writes = next.catch(() => undefined) return next } function normalize(input: CustomModelInput, existing?: StoredModel, testing = false): StoredModel { if (!input || typeof input !== 'object') throw new Error('模型配置无效') const providerId = 'custom' const model = typeof input.model === 'string' ? input.model.trim() : '' const baseUrl = typeof input.baseUrl === 'string' ? normalizeModelAddress(input.baseUrl) : '' if (!providerId) throw new Error('请选择供应商') if (!model) throw new Error('请输入模型名称') if (!baseUrl) throw new Error('请输入 API 地址') let endpoint: URL try { endpoint = new URL(baseUrl) } catch { throw new Error('请输入有效的 API 地址') } if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) throw new Error('API 地址必须使用 http 或 https,且不能包含账户密码') if (endpoint.search || endpoint.hash) throw new Error('API 地址不能包含查询参数或片段') const id = typeof input.id === 'string' ? input.id.trim() : '' const name = typeof input.name === 'string' ? input.name.trim() : '' if (!id) throw new Error('模型标识不能为空') if (!name) throw new Error('请输入模型显示名称') if (input.authMode !== undefined && !['api-key', 'none'].includes(input.authMode)) throw new Error('鉴权方式无效') const suppliedKey = typeof input.apiKey === 'string' ? input.apiKey.replace(/\s/gu, '') : '' if (suppliedKey && !/^[\x21-\x7e]+$/.test(suppliedKey)) throw new Error('API Key 只能包含 ASCII 可打印字符') const authMode = input.authMode ?? 'api-key' const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '') if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key') const next: StoredModel = { id, name, providerId, model, baseUrl, authMode } if (input.protocol !== undefined && input.protocol !== 'anthropic') throw new Error('首版仅支持 Anthropic 协议') next.protocol = 'anthropic' if (input.tokenBudgetVersion !== undefined && input.tokenBudgetVersion !== 2) throw new Error('模型上下文配置版本无效') if (input.tokenBudgetVersion === 2) next.tokenBudgetVersion = 2 const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && !suppliedKey ? existing.endpoint : undefined) if (configuredEndpoint) { if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(configuredEndpoint.api)) throw new Error('协商协议无效') const root = normalizeModelAddress(configuredEndpoint.baseUrl) if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== 'anthropic') throw new Error('协商地址与填写的域名或协议不一致') next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt, ...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) } } for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'canDisableThinking', 'useCustomProtocol'] as const) { if (typeof input[field] === 'boolean') next[field] = input[field] } if (next.supportsReasoning) { if (typeof input.defaultEffort === 'string') next.defaultEffort = input.defaultEffort if (Array.isArray(input.supportedEfforts) && input.supportedEfforts.every(value => typeof value === 'string')) next.supportedEfforts = [...new Set(input.supportedEfforts)] } else { delete next.onlyReasoning delete next.canDisableThinking } for (const field of ['maxInputTokens', 'maxOutputTokens', 'contextWindowTokens', 'reasoningBudget'] as const) { const value = input[field] if (value !== undefined) { if (!Number.isFinite(value)) throw new Error('Token 数量必须是有效数字') if (!Number.isSafeInteger(value) || value < 0) throw new Error('Token 数量必须是非负整数') next[field] = Math.trunc(value) } } for (const field of ['temperature', 'maxToolRounds'] as const) { const value = input[field] ?? existing?.[field] if (value !== undefined) { if (!Number.isFinite(value)) throw new Error('模型参数必须是有效数字') next[field] = field === 'temperature' ? Math.max(0, Math.min(2, value)) : Math.max(1, Math.min(24, Math.round(value))) } } // Preserve ciphertext when editing without a new key (including on platforms temporarily unable to encrypt). if (existing && authMode !== 'none' && !input.clearApiKey && !suppliedKey) { next.apiKeyEnc = existing.apiKeyEnc next.apiKeyPlain = existing.apiKeyPlain } else writeKey(next, key) return next } export async function listModels(): Promise { await writes return registryView(await load()) } export function saveModel(input: CustomModelInput, overwrite = false): Promise { return mutate(async registry => { const previousId = input?.previousId || input?.id const existing = registry.models.find(model => model.id === previousId) const target = registry.models.find(model => model.id === input?.id?.trim()) if (target && target.id !== input.previousId && !overwrite) throw new Error('同名模型已存在,请确认是否覆盖') const next = normalize(input, existing ?? target) const metadata = await modelMetadata(next.model, next.protocol ?? 'auto').catch(() => null) if (metadata) { next.metadata = metadata; next.price = metadata.price if (next.protocol) { for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'contextWindowTokens', 'maxInputTokens', 'maxOutputTokens'] as const) { if (input[field] === undefined) (next as unknown as Record)[field] = metadata[field] } if (next.supportsReasoning) { for (const field of ['onlyReasoning', 'canDisableThinking', 'defaultEffort', 'supportedEfforts'] as const) if (input[field] === undefined) (next as unknown as Record)[field] = metadata[field] if (input.reasoningBudget === undefined && metadata.reasoningOptions.some(option => option.type === 'budget_tokens')) next.reasoningBudget = 1024 } } } { if (input.maxInputTokens === undefined) next.tokenBudgetVersion = 2 const { error, compaction: _compaction, ...limits } = resolveModelTokenLimits(next) if (error) throw new Error(error) Object.assign(next, limits) } if (next.protocol && !next.endpoint) { // Discovery is read-only; servers without a models API still support manual model IDs. const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key).catch(() => undefined) if (connection) next.endpoint = connection.endpoint } const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id) const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id) remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next) const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId } await persist(updated) return registryView(updated) }) } export function deleteModel(id: string): Promise { return mutate(async registry => { const models = registry.models.filter(model => model.id !== id) const selectedModelId = registry.selectedModelId === id ? models[0]?.id ?? null : registry.selectedModelId const updated: StoredRegistry = { version: 1, models, selectedModelId } await persist(updated) return registryView(updated) }) } export function setSelectedModel(id: string | null): Promise { return mutate(async registry => { if (id !== null && !registry.models.some(model => model.id === id)) throw new Error('所选模型不存在') const updated: StoredRegistry = { ...registry, selectedModelId: id } await persist(updated) return registryView(updated) }) } export async function resolveModel(id?: string | null): Promise { await writes const registry = await load() const targetId = id === undefined ? registry.selectedModelId : id if (!targetId) return null const model = registry.models.find(model => model.id === targetId) if (!model) throw new Error('所选模型不存在,请重新选择模型') const budget = resolveModelTokenLimits(model) if (budget.error) throw new Error(budget.error) const profile = viewOf(model) const legacy = await getGatewayConfig() const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model) : undefined const resolved = verified && verified.checkedAt >= (model.endpoint?.checkedAt ?? 0) ? verified : model.endpoint ?? (model.protocol ? cachedConnection(model.baseUrl, model.protocol, readKey(model).key)?.endpoint ?? modelEndpoint(model.baseUrl, inferModelApi(model.baseUrl, model.protocol)) : undefined) return { profile, config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key, supportsReasoning: model.supportsReasoning, reasoningBudget: model.reasoningBudget, reasoningOptions: model.metadata?.reasoningOptions, temperatureSupported: model.metadata?.temperature, authMode: model.authMode, maxOutputTokens: profile.maxOutputTokens, temperature: model.temperature ?? legacy.temperature, maxToolRounds: model.maxToolRounds ?? legacy.maxToolRounds } } } export async function fetchModels(input: ModelDiscoveryInput): Promise { if (!input || typeof input.baseUrl !== 'string' || (input.protocol !== undefined && input.protocol !== 'anthropic')) throw new Error('首版仅支持 Anthropic 协议') await writes const registry = cache ?? await readRegistry(false), existing = registry.models.find(model => model.id === input.previousId) const key = input.authMode === 'none' ? '' : typeof input.apiKey === 'string' && input.apiKey.trim() ? input.apiKey.replace(/\s/gu, '') : existing ? readKey(existing).key : '' if (!key && input.authMode !== 'none') throw new Error('请输入 API Key') return discoverModelConnection(input.baseUrl, 'anthropic', key, true) } /** Test only the user-specified origin, negotiating paths/protocols before reporting success. */ export async function testModel(input: CustomModelInput): Promise { const startedAt = Date.now() const result = (success: boolean, message: string): ModelTestResult => ({ success, message, durationMs: Date.now() - startedAt }) let model: StoredModel try { await writes const registry = cache ?? await readRegistry(false) model = normalize(input, registry.models.find(item => item.id === (input.previousId || input.id)), true) } catch (error) { return result(false, (error as Error).message) } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 15_000) try { const key = readKey(model).key const legacy = resolveOpenAIEndpoint(model.baseUrl) const known = model.protocol ? model.endpoint ?? cachedConnection(model.baseUrl, model.protocol, key)?.endpoint : undefined // Re-testing an OpenAI route follows the current preference, including at a negotiated custom root. // Stored routes continue to serve ordinary chat until a new generation probe succeeds. const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses') ? { ...known, api: inferModelApi(model.baseUrl, model.protocol) } : known const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol), known].filter((value): value is ModelEndpoint => !!value) : [{ ...legacy, checkedAt: 0 }] const tried = new Set(); let status = 0 for (const endpoint of candidates) { const url = model.protocol ? endpointUrl(endpoint, 'generate', model.model) : legacy.endpoint if (tried.has(url + endpoint.api)) continue tried.add(url + endpoint.api) const payload = endpoint.api === 'openai-responses' ? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 } : endpoint.api === 'anthropic-messages' ? { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], max_tokens: 16 } : endpoint.api === 'google-generative-ai' ? { contents: [{ role: 'user', parts: [{ text: 'Reply with OK.' }] }], generationConfig: { maxOutputTokens: 16 } } : { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, [openAICompletionTokenField(model.model)]: 16 } const response = await modelFetch(url, { method: 'POST', signal: controller.signal, headers: modelHeaders(endpoint.api, key), body: JSON.stringify(payload) }) status = response.status if (!response.ok) { await response.body?.cancel(); if ([401, 403, 429].includes(status) && model.protocol !== 'auto') break; continue } let parsed: unknown try { parsed = await readModelJson(response, 1024 * 1024) } catch { continue } const value = parsed as Record | null const valid = endpoint.api === 'openai-responses' ? isResponsesTestResult(parsed) : endpoint.api === 'anthropic-messages' ? value?.type === 'message' && Array.isArray(value.content) : endpoint.api === 'google-generative-ai' ? Array.isArray(value?.candidates) && value.candidates.length > 0 : Array.isArray(value?.choices) && value.choices.some(choice => choice && (choice.message || typeof choice.text === 'string')) if (!valid) continue const verified: ModelEndpoint = { api: endpoint.api, baseUrl: endpoint.baseUrl, checkedAt: Date.now(), verifiedBy: 'generation' } if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified) return { ...result(true, '连接测试成功'), endpoint: verified } } const messages: Record = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' } return result(false, messages[status] ?? (status >= 400 ? `上游返回 HTTP ${status}` : '上游未返回兼容的对话结果')) } catch (error) { if (controller.signal.aborted) return result(false, '连接超时,请检查 API 地址或稍后重试') return result(false, error instanceof SyntaxError ? '上游未返回有效的 JSON 对话结果' : '无法连接上游,请检查 API 地址和网络') } finally { clearTimeout(timer) } } /** A short probe may spend its whole output budget on reasoning without producing visible text. */ function isResponsesTestResult(value: unknown): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) return false const response = value as Record if (response.error != null || (response.object !== undefined && response.object !== 'response') || (response.status !== undefined && response.status !== 'completed' && response.status !== 'incomplete') || !Array.isArray(response.output)) return false const validOutput = response.output.every(item => { if (!item || typeof item !== 'object' || Array.isArray(item)) return false const output = item as Record if (output.type === 'reasoning') { return Array.isArray(output.summary) && output.summary.every(part => part && typeof part === 'object' && part.type === 'summary_text' && typeof part.text === 'string') } return output.type === 'message' && output.role === 'assistant' && Array.isArray(output.content) && output.content.length > 0 && output.content.every(part => part && typeof part === 'object' && ((part.type === 'output_text' && typeof part.text === 'string') || (part.type === 'refusal' && typeof part.refusal === 'string'))) }) if (!validOutput) return false if (response.status === 'incomplete') { const details = response.incomplete_details return !!details && typeof details === 'object' && 'reason' in details && (details.reason === 'max_output_tokens' || details.reason === 'content_filter') } return response.output.length > 0 }