chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,315 @@
|
||||
/** 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 } from '@shared/models'
|
||||
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
||||
import { getGatewayConfig } from './config'
|
||||
|
||||
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
|
||||
apiKeyEnc?: string
|
||||
apiKeyPlain?: string
|
||||
}
|
||||
interface StoredRegistry {
|
||||
version: 1
|
||||
models: StoredModel[]
|
||||
selectedModelId: string | null
|
||||
}
|
||||
|
||||
let cache: StoredRegistry | null = null
|
||||
let loading: Promise<StoredRegistry> | null = null
|
||||
let writes: Promise<unknown> = 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)
|
||||
return { ...profile, 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<void> {
|
||||
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<StoredRegistry> {
|
||||
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',
|
||||
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<StoredRegistry> & { 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<string>()
|
||||
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<StoredRegistry> {
|
||||
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<ModelRegistryView>): Promise<ModelRegistryView> {
|
||||
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 = typeof input.providerId === 'string' ? input.providerId.trim() : ''
|
||||
const model = typeof input.model === 'string' ? input.model.trim() : ''
|
||||
const baseUrl = typeof input.baseUrl === 'string' ? input.baseUrl.trim().replace(/\/+$/, '') : ''
|
||||
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 }
|
||||
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'] as const) {
|
||||
const value = input[field]
|
||||
if (value !== undefined) {
|
||||
if (!Number.isFinite(value)) 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<ModelRegistryView> {
|
||||
await writes
|
||||
return registryView(await load())
|
||||
}
|
||||
|
||||
export function saveModel(input: CustomModelInput, overwrite = false): Promise<ModelRegistryView> {
|
||||
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 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<ModelRegistryView> {
|
||||
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<ModelRegistryView> {
|
||||
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<ResolvedModel | null> {
|
||||
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 legacy = await getGatewayConfig()
|
||||
return {
|
||||
profile: viewOf(model),
|
||||
config: { ...legacy, baseUrl: model.baseUrl, model: model.model, apiKey: readKey(model).key,
|
||||
authMode: model.authMode, maxOutputTokens: model.maxOutputTokens,
|
||||
temperature: model.temperature ?? legacy.temperature, maxToolRounds: model.maxToolRounds ?? legacy.maxToolRounds }
|
||||
}
|
||||
}
|
||||
|
||||
/** Test unsaved settings using only the specified user's OpenAI-compatible endpoint. */
|
||||
export async function testModel(input: CustomModelInput): Promise<ModelTestResult> {
|
||||
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 { api, endpoint } = resolveOpenAIEndpoint(model.baseUrl)
|
||||
const payload = api === 'openai-responses'
|
||||
? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 }
|
||||
: { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, max_tokens: 16 }
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST', signal: controller.signal,
|
||||
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限',
|
||||
404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
||||
return result(false, messages[response.status] ?? `上游返回 HTTP ${response.status}`)
|
||||
}
|
||||
// Limit response consumption; never return upstream text that could echo credentials.
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return result(false, '上游返回为空')
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
total += value.length
|
||||
if (total > 1024 * 1024) { await reader.cancel(); return result(false, '上游响应过大') }
|
||||
chunks.push(value)
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
||||
if (api === 'openai-responses') {
|
||||
if (!isResponsesTestResult(parsed)) return result(false, '上游未返回兼容的 Responses 结果')
|
||||
} else if (!parsed || typeof parsed !== 'object' || !('choices' in parsed)
|
||||
|| !Array.isArray(parsed.choices) || !parsed.choices.length
|
||||
|| !parsed.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))) {
|
||||
return result(false, '上游未返回兼容的对话结果')
|
||||
}
|
||||
return result(true, '连接测试成功')
|
||||
} 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user