diff --git a/docs/协作/多模型协议桥接-实施方案-01.md b/docs/协作/多模型协议桥接-实施方案-01.md new file mode 100644 index 0000000..622250c --- /dev/null +++ b/docs/协作/多模型协议桥接-实施方案-01.md @@ -0,0 +1,52 @@ +# 多模型协议桥接实施方案 01 + +日期:2026-10-21。起点:`8fcfb69408716a98d4fd09bd34912048ba419891`。 +用户要求:Chat Completions 与 Responses 同批实现;Gemini 暂不考虑;UI 不漂移。 + +## 范围与边界 + +- 保留 Claude Agent SDK、文件/终端执行、MCP、Skill、授权、会话与消息渲染机制。 +- Anthropic Messages 维持直接连接;OpenAI Chat Completions / Responses 经过 YCode 主进程回环协议桥。 +- 仅参考 magpie 的公开协议转换思路(参考提交 `ba319a7587923fc7a4ea3859658244d591261a59`),不引入其订阅账号、登录发现、插件、MCP、Skill、工具执行机制或完整网关。 +- 不对接 wuli-go;不新增自动路由、账号轮换、模型回退、供应商页面、远程网关管理。 +- 不自动读取用户账号或调用真实模型 API。模拟上游与 mock SDK 验证和真实 SDK / Windows 验收分别记录。 + +## 结构 + +1. `shared/models.ts`、`model-support.ts`:协议族与明确 API 偏好分别表达;旧 Anthropic 配置兼容。 +2. `main/models.ts`、`model-connection.ts`:发现、测试、保存、回显尊重明确 API;协议变更使验证缓存失效。沿用密钥加密和原子存储。 +3. `main/model-bridge/`:Messages 解析、统一结构、Chat / Responses 编解码、SSE、错误、取消与本机监听生命周期。 +4. `agent/claude/claude-kernel.ts`:只为非 Anthropic 模型取得桥接租约;真实上游密钥不传给 SDK,使用独立本机令牌。缓存身份包含 API、上游模型/地址、能力与凭据修订。 +5. `chat-turn-service.ts`:只允许实际已实现的 API,继续使用同一内核与事件契约。 + +每个内核固定当前路由。普通队列仍在新回合开始时解析会话模型;立即追加仍沿用当前回合模型。权限入队快照与下次消息设置不改。禁止把此次工作扩大为队列模型快照语义迁移。 + +## UI 冻结 + +- 现有新增/编辑模型的“接口协议”下拉框增加两个选项,合计 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses。 +- 模型选择器仅更新支持判断及真实能力数据,不新增列表分组、图标、徽标或弹窗。 +- 不改选择器 `source.mjs`、`source.css`、`source-adapter.css`;不改模型设置 CSS、ChatPane、MessageBubble 或对话组件布局。 +- 现有错误/连接反馈位置复用;获取模型目录成功不等于生成、工具及流式兼容已验收。 +- 无能力的思考档位不虚构。历史配置编辑后不得强制改写为 Anthropic。 + +## 协议与安全契约 + +- 仅监听回环地址,随机端口、独立租约认证;请求不能携带任意目标 URL/密钥改变路由。 +- 不转发 SDK 本地认证到上游;不把凭据写入日志、事件或测试产物。 +- 支持非流式及流式文本、图片、工具定义/调用/结果、增量 JSON 参数、用量与结束原因。 +- 工具只做格式转换,绝不由桥执行。未知或无法保真的能力明确报错,不静默成功。 +- SSE 处理跨网络块分片与断流;已发送内容后不自动重放,不伪造正常完成。 +- 用户取消/客户端断开取消上游连接;内核关闭、失败及应用退出撤销租约。 +- token 估算与真实上游用量分离;思考签名不伪造,跨协议/重启后的不透明推理状态须如实标注限制。 + +## 交付与验收 + +- 两种 API 同批提交;Anthropic 原路径不回归。 +- 离线协议测试:请求映射、两种响应格式、SSE 分片、工具多轮、图片、错误、取消、端点认证、双会话隔离。 +- mock SDK 主机测试:直接/桥接环境、凭据隔离、启动竞态、协议变更重建、失败/关闭清理、现有授权规则。 +- 模型管理回归:新增/编辑/重开、偏好持久化、目录与生成测试身份、缓存失效、密钥不泄漏。 +- 现有核心、对齐、对话、授权、持久化与生产构建按实际可运行入口验证。 +- UI 最终在 Windows 对照相同尺寸浅/深色旧场景截图;只允许新增协议选项及模型文案变化。离线 DOM 和构建不能替代实机视觉验收。 +- 真实模型与 SDK 端到端兼容、压缩/恢复与供应商特有行为,不得用 mock 通过宣称已完成。 + +状态:实现与离线验证完成;真实 SDK、真实供应商与 Windows 视觉验收尚未进行。结果和剩余限制在交付文档单独登记。 diff --git a/package.json b/package.json index 8c9ea9f..697eb3b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "test": "npm run test:core", "test:core": "npm run typecheck && npm run test:chat && npm run test:sessions && npm run test:persistence && npm run test:conversation", + "test:model-bridge": "esbuild scripts/protocol-bridge-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/protocol-bridge-regression.mjs --log-level=warning && node .tmp/protocol-bridge-regression.mjs && node scripts/claude-bridge-host-regression.mjs", "test:alignment": "node scripts/run-alignment-regression.mjs", "test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom", "test:image-lifecycle": "node scripts/test-image-lifecycle.mjs", diff --git a/scripts/claude-bridge-host-regression.mjs b/scripts/claude-bridge-host-regression.mjs new file mode 100644 index 0000000..813cd20 --- /dev/null +++ b/scripts/claude-bridge-host-regression.mjs @@ -0,0 +1,246 @@ +/** Offline integration only. NEVER imports/runs the installed Claude SDK or opens a network port. */ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-bridge-offline-')) +const queries = [], leases = [], kernels = [] +let queryFailure = false, runtimeFailure = false, grantFailure = false, acquireGate +const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } } +const tick = () => new Promise(r => setImmediate(r)) +const mocks = { + electron: { app: { getPath: () => temp, getAppPath: () => temp } }, + '@anthropic-ai/claude-agent-sdk': { + createSdkMcpServer: config => config, + tool: (name, description, schema, handler) => ({ name, description, schema, handler }), + query: request => { + if (queryFailure) throw new Error('mock SDK startup failure') + const ended = deferred() + const instance = { + closeCount: 0, interrupt: async () => {}, + close() { this.closeCount++; ended.resolve() }, + async *[Symbol.asyncIterator]() { await ended.promise }, + applyFlagSettings: async () => { if (grantFailure) throw new Error('mock grant replay failure') }, setPermissionMode: async () => {}, + getContextUsage: async () => ({ categories: [] }) + } + queries.push({ ...request, instance }) + return instance + } + }, + 'private-runtimes': { privateRuntimeEnvironment: async env => { if (runtimeFailure) throw new Error('mock runtime failure'); return env } }, + 'agent/tools/browser': { browserTool: { description: 'inert browser', run: () => { throw new Error('Unexpected browser execution') } } }, + 'capabilities/claude-capabilities': { createClaudeCapabilityRuntime: () => ({ server: {}, setRequestSelection() {} }) }, + 'model-bridge': { acquireModelBridge: async model => { + const id = leases.length + 1 + const lease = { model: structuredClone(model), baseUrl: `http://127.0.0.1:${24000 + id}/lease-${id}`, apiKey: `mock-local-token-${id}`, closeCount: 0, async close() { this.closeCount++ } } + leases.push(lease) + if (acquireGate) await acquireGate.promise + return lease + } } +} +const portKeys = new Map(Object.keys(mocks).map(key => [key === 'electron' || key.startsWith('@') ? key : resolve('src/main', key), key])) +globalThis.__claudeBridgeOfflineMocks = mocks +let checks = 0 +const check = async (name, body) => { await body(); checks++; console.log(`PASS ${name}`) } +function model(api = 'openai-completions', suffix = '') { + return { profile: { id: `offline${suffix}`, name: 'Offline', providerId: 'offline', model: `model${suffix}`, baseUrl: 'https://invalid.example/v1', authMode: 'api-key', apiPreference: api, protocol: api === 'anthropic-messages' ? 'anthropic' : 'openai', contextWindowTokens: 100000 }, config: { api, model: `model${suffix}`, baseUrl: 'https://invalid.example/v1', apiKey: `FAKE-NOT-A-CREDENTIAL${suffix}` } } +} +const options = (id, value = model()) => ({ sessionId: id, workspaceRoot: temp, model: value, permissionMode: 'default-sandbox', agentMode: 'craft', onSessionId() {} }) +try { + const bundle = join(temp, 'kernel.mjs') + await build({ stdin: { contents: "export * from './src/main/agent/claude/claude-kernel'; export { rememberSessionGrant } from './src/main/agent/claude/session-grants'", resolveDir: process.cwd(), loader: 'ts' }, outfile: bundle, bundle: true, platform: 'node', format: 'esm', logLevel: 'warning', metafile: true, plugins: [{ name: 'strict-offline-ports', setup(b) { + b.onResolve({ filter: /.*/ }, args => { + const path = args.path.startsWith('.') ? resolve(args.resolveDir, args.path).replace(/\.(ts|js)$/, '').replace(/\/index$/, '') : args.path + const key = portKeys.get(path) + if (key) return { path: key, namespace: 'offline' } + if (args.path.includes('claude-agent-sdk')) throw new Error('Actual SDK resolution forbidden') + }) + b.onLoad({ filter: /.*/, namespace: 'offline' }, args => ({ contents: Object.keys(mocks[args.path]).map(name => `export const ${name} = globalThis.__claudeBridgeOfflineMocks[${JSON.stringify(args.path)}][${JSON.stringify(name)}]`).join('\n'), loader: 'js' })) + } }] }).then(result => { + assert(!Object.keys(result.metafile.inputs).some(path => path.includes('node_modules/@anthropic-ai/')), 'real SDK must never enter bundle') + assert(!Object.keys(result.metafile.inputs).some(path => /src\/main\/model-bridge\//.test(path) && !path.endsWith('/model-bridge/paths.ts')), 'real network bridge must never enter bundle') + }) + const { ClaudeKernel, getClaudeKernel, disposeClaudeKernels, rememberSessionGrant } = await import(pathToFileURL(bundle).href) + const make = opts => { const kernel = new ClaudeKernel(opts); kernels.push(kernel); return kernel } + let baseline + await check('both OpenAI APIs use isolated bridge env, not upstream credentials', async () => { + for (const api of ['openai-completions', 'openai-responses']) { + const value = model(api), kernel = make(options(api, value)), before = leases.length + await kernel.ensureStarted() + const lease = leases.at(-1), request = queries.at(-1) + assert.equal(leases.length, before + 1) + assert.deepEqual(lease.model, { ...value, config: { ...value.config, thinkingLevel: 'off' } }) + assert.equal(request.options.env.ANTHROPIC_BASE_URL, lease.baseUrl) + assert.equal(request.options.env.ANTHROPIC_API_KEY, lease.apiKey) + assert(!JSON.stringify(request.options.env).includes(value.config.apiKey)) + assert.equal(request.options.model, value.config.model) + baseline ??= request.options + await kernel.close(); assert.equal(lease.closeCount, 1) + await kernel.close(); assert.equal(lease.closeCount, 1) + } + }) + await check('bridge lease receives effective low/off thinking without mutating resolved config', async () => { + for (const api of ['openai-completions', 'openai-responses']) { + for (const [requested, reasoning, expected] of [['low', true, 'low'], ['off', true, 'off'], [undefined, false, 'off']]) { + const value = model(api) + value.profile.supportsReasoning = reasoning + value.profile.supportedEfforts = ['low', 'high'] + value.config.thinkingLevel = 'high' + const kernel = make({ ...options(`thinking-${api}-${requested}`, value), thinkingLevel: requested }) + await kernel.ensureStarted() + assert.equal(leases.at(-1).model.config.thinkingLevel, expected) + assert.equal(value.config.thinkingLevel, 'high', 'bridge-specific effective level must not mutate resolved model') + await kernel.close() + } + } + }) + await check('Anthropic remains direct and permission/tool hooks remain identical', async () => { + const before = leases.length, value = model('anthropic-messages') + const kernel = make(options('anthropic', value)); await kernel.ensureStarted() + const opts = queries.at(-1).options + assert.equal(leases.length, before) + assert.equal(opts.env.ANTHROPIC_BASE_URL, 'https://invalid.example') + assert.equal(opts.env.ANTHROPIC_API_KEY, value.config.apiKey) + for (const key of ['permissionMode', 'permissionPrompts', 'settingSources', 'strictMcpConfig', 'tools', 'allowedTools', 'disallowedTools', 'plugins', 'agents', 'skills']) assert.deepEqual(opts[key], baseline[key], key) + assert.equal(opts.permissionMode, 'default'); assert.equal(opts.permissionPrompts, 'host') + assert.deepEqual(Object.keys(opts.hooks), ['PreToolUse']) + assert.equal(typeof opts.canUseTool, 'function') + for (const policy of [opts, baseline]) { + const result = await policy.hooks.PreToolUse[0].hooks[0]({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, tool_use_id: 'inactive' }) + assert.equal(result.hookSpecificOutput.permissionDecision, 'deny') + } + await kernel.close() + }) + await check('two simultaneous sessions have separate leases and independent cleanup', async () => { + const a = make(options('dual-a', model('openai-completions', '-a'))), b = make(options('dual-b', model('openai-responses', '-b'))) + const before = leases.length + await Promise.all([a.ensureStarted(), b.ensureStarted()]) + const [la, lb] = leases.slice(before) + assert.notEqual(la.apiKey, lb.apiKey); assert.notEqual(la.baseUrl, lb.baseUrl) + await a.close(); assert.equal(la.closeCount, 1); assert.equal(lb.closeCount, 0) + await b.close(); assert.equal(lb.closeCount, 1) + }) + await check('concurrent ensureStarted acquires exactly one lease and SDK query', async () => { + const kernel = make(options('concurrent')), before = leases.length, q = queries.length + acquireGate = deferred() + const starts = Array.from({ length: 6 }, () => kernel.ensureStarted()) + await tick(); acquireGate.resolve(); acquireGate = undefined + await Promise.all(starts) + assert.equal(leases.length, before + 1); assert.equal(queries.length, q + 1) + await kernel.close(); assert.equal(leases.at(-1).closeCount, 1) + }) + await check('API/model/base URL/key configuration changes retire old leases', async () => { + let opts = options('fingerprint'), kernel = await getClaudeKernel(opts) + await kernel.ensureStarted(); assert.equal(await getClaudeKernel(opts), kernel) + for (const patch of [{ api: 'openai-responses' }, { model: 'changed-model' }, { baseUrl: 'https://other.invalid/v1' }, { apiKey: 'FAKE-CHANGED-KEY' }]) { + const oldLease = leases.at(-1) + opts = { ...opts, model: { ...opts.model, config: { ...opts.model.config, ...patch } } } + const next = await getClaudeKernel(opts); assert.notEqual(next, kernel) + assert.equal(oldLease.closeCount, 1); await next.ensureStarted(); kernel = next + } + await kernel.close() + }) + await check('failed SDK startup releases lease and permits clean retry', async () => { + const kernel = make(options('failed')) + queryFailure = true + await assert.rejects(kernel.ensureStarted(), /mock SDK startup failure/) + queryFailure = false + assert.equal(leases.at(-1).closeCount, 1) + await kernel.ensureStarted(); assert.equal(leases.at(-1).closeCount, 0) + await kernel.close(); assert.equal(leases.at(-1).closeCount, 1) + }) + await check('failed session grant replay releases producer and bridge before retry', async () => { + const kernel = make(options('grant-replay')) + rememberSessionGrant('grant-replay', { rules: ['Read(**)'], updates: [] }, temp) + grantFailure = true + await assert.rejects(kernel.ensureStarted(), /mock grant replay failure/) + grantFailure = false; await tick() + assert.equal(leases.at(-1).closeCount, 1) + await kernel.ensureStarted(); await kernel.close() + assert.equal(leases.at(-1).closeCount, 1) + }) + await check('runtime environment failure cannot strand a bridge lease', async () => { + const kernel = make(options('runtime-failure')), before = leases.length + runtimeFailure = true + await assert.rejects(kernel.ensureStarted(), /mock runtime failure/) + runtimeFailure = false + for (const lease of leases.slice(before)) assert.equal(lease.closeCount, 1) + await kernel.close() + }) + await check('close during asynchronous acquisition releases lease without SDK startup', async () => { + const kernel = make(options('close-during-start')), q = queries.length + acquireGate = deferred() + const start = kernel.ensureStarted().catch(error => error) + await tick() + const closing = kernel.close() + acquireGate.resolve(); acquireGate = undefined + await Promise.all([start, closing]) + assert.equal(queries.length, q); assert.equal(leases.at(-1).closeCount, 1) + }) + await check('unsupported Gemini fails before bridge or SDK creation', async () => { + const value = model('google-generative-ai'); value.profile.protocol = 'gemini' + const kernel = make(options('unsupported', value)), before = leases.length, q = queries.length + await assert.rejects(kernel.ensureStarted(), /协议/) + assert.equal(leases.length, before); assert.equal(queries.length, q) + await kernel.close() + }) + await check('resolved model snapshot cannot be mutated after kernel creation', async () => { + const value = model(), kernel = make(options('snapshot', value)) + value.config.apiKey = 'FAKE-MUTATED'; value.config.model = 'mutated-model' + await kernel.ensureStarted() + assert.equal(leases.at(-1).model.config.apiKey, 'FAKE-NOT-A-CREDENTIAL') + assert.equal(queries.at(-1).options.model, 'model') + await kernel.close() + }) + await check('bridge preserves full-access tool preauthorization and question approval boundary', async () => { + const kernel = make({ ...options('hook-policy'), permissionMode: 'full-access' }), controller = new AbortController() + const run = kernel.runTurn({ runId: 'hook-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal }) + const rejected = assert.rejects(run, error => error.name === 'AbortError') + await tick() + const opts = queries.at(-1).options, hook = opts.hooks.PreToolUse[0].hooks[0] + assert.equal(opts.permissionMode, 'default') + for (const [name, decision] of [['Bash', 'allow'], ['AskUserQuestion', 'ask']]) { + const result = await hook({ hook_event_name: 'PreToolUse', tool_name: name, tool_input: {}, tool_use_id: name }) + assert.equal(result.hookSpecificOutput.permissionDecision, decision) + } + controller.abort(); await rejected; await kernel.close() + }) + await check('unexpected producer termination releases bridge lease', async () => { + const kernel = make(options('producer-end')) + await kernel.ensureStarted(); const lease = leases.at(-1) + queries.at(-1).instance.close(); await tick() + assert.equal(lease.closeCount, 1) + await kernel.close(); assert.equal(lease.closeCount, 1) + }) + await check('cancellation during bridge acquisition releases newly started producer', async () => { + const kernel = make(options('cancel-start')), controller = new AbortController() + acquireGate = deferred() + const run = kernel.runTurn({ runId: 'cancel-start-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal }) + const rejected = assert.rejects(run, error => error.name === 'AbortError') + await tick(); controller.abort(); acquireGate.resolve(); acquireGate = undefined + await rejected; await tick() + assert.equal(leases.at(-1).closeCount, 1) + await kernel.close() + }) + await check('turn cancellation retires producer and releases its bridge lease', async () => { + const kernel = make(options('cancel')), controller = new AbortController() + const run = kernel.runTurn({ runId: 'cancel-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal }) + const rejected = assert.rejects(run, error => error.name === 'AbortError') + await tick(); assert.equal(kernel.busy, true) + const lease = leases.at(-1) + controller.abort(); await rejected; await tick() + assert.equal(lease.closeCount, 1) + await kernel.close(); assert.equal(lease.closeCount, 1) + }) + await disposeClaudeKernels() + assert(leases.every(lease => lease.closeCount === 1), 'all leases released exactly once') + console.log(`PASS ${checks} offline Claude/bridge host groups; no actual SDK startup, network or credentials`) +} finally { + acquireGate?.resolve() + await Promise.all(kernels.map(kernel => kernel.close().catch(() => {}))) + delete globalThis.__claudeBridgeOfflineMocks + await rm(temp, { recursive: true, force: true }) +} diff --git a/scripts/model-registry-regression.ts b/scripts/model-registry-regression.ts index cceb154..dc077c7 100644 --- a/scripts/model-registry-regression.ts +++ b/scripts/model-registry-regression.ts @@ -7,11 +7,19 @@ import { pathToFileURL } from 'node:url' import { createServer } from 'node:http' import type { CustomModelInput } from '../src/shared/models' import type * as Registry from '../src/main/models' +import { cachedConnection, cachedGeneration, rememberConnection, rememberGeneration } from '../src/main/model-connection' +import { modelProfileApi, isSupportedModelProfile } from '../src/shared/model-support' import { endpointCandidates, inferModelApi } from '../src/shared/model-endpoint' const STATE_KEY = '__YCODE_MODEL_REGISTRY_TEST__' const globals = globalThis as typeof globalThis & Record const previous = globals[STATE_KEY] +const realFetch = globalThis.fetch +// This suite only contacts its loopback fake; registry discovery on fixture domains is blocked. +globalThis.fetch = ((url, init) => { + if (new URL(String(url)).hostname !== '127.0.0.1') return Promise.reject(new Error('offline fixture')) + return realFetch(url, init) +}) as typeof fetch const temp = await mkdtemp(join(tmpdir(), 'ycode-model-registry-')) const state = { userData: join(temp, 'userData'), encryption: true } globals[STATE_KEY] = state @@ -23,6 +31,7 @@ const legacy = { baseUrl: 'https://legacy.example/v1', model: 'legacy-model', te const reload = (): Promise => import(`${pathToFileURL(outfile).href}?instance=${++importNumber}`) const input = (id: string, extra: Partial = {}): CustomModelInput => ({ id, name: `Name ${id}`, providerId: 'custom', model: `model-${id}`, baseUrl: 'https://provider.example/v1', + protocol: 'openai', apiPreference: extra.baseUrl?.includes('/responses') ? 'openai-responses' : 'openai-completions', apiKey: ' new-key\n\t with-space ', supportsToolCall: true, supportsImages: true, ...extra }) let server: ReturnType | undefined @@ -120,6 +129,19 @@ try { assert.equal(inferModelApi('https://api.anthropic.com', 'auto'), 'anthropic-messages') assert.equal(inferModelApi('https://generativelanguage.googleapis.com', 'auto'), 'google-generative-ai') + assert.equal(modelProfileApi({ baseUrl: 'https://legacy.example' }), 'anthropic-messages') + assert.equal(isSupportedModelProfile({ baseUrl: 'https://legacy.example', protocol: 'gemini' }), false) + for (const apiPreference of ['openai-completions', 'openai-responses'] as const) { + const route = { api: apiPreference, baseUrl: 'https://cache.invalid/v1/' + apiPreference, checkedAt: Date.now() } + rememberConnection('https://cache.invalid', 'openai', 'private-cache-key', { models: [], endpoint: route }, apiPreference) + rememberGeneration('https://cache.invalid', 'openai', 'private-cache-key', 'same-model', route, apiPreference) + } + for (const apiPreference of ['openai-completions', 'openai-responses'] as const) { + assert.equal(cachedConnection('https://cache.invalid', 'openai', 'private-cache-key', apiPreference)?.endpoint.api, apiPreference) + assert.equal(cachedGeneration('https://cache.invalid', 'openai', 'private-cache-key', 'same-model', apiPreference)?.api, apiPreference) + assert.equal(cachedGeneration('https://cache.invalid', 'openai', 'changed-key', 'same-model', apiPreference), undefined) + assert(endpointCandidates('https://cache.invalid', 'openai', apiPreference).every(route => route.api === apiPreference)) + } let responseMode = 'success' let responsePayload: unknown = undefined const requests: Array<{ path: string; auth?: string; data: Record }> = [] @@ -127,8 +149,9 @@ try { let body = '' request.on('data', chunk => { body += chunk }) request.on('end', () => { - requests.push({ path: request.url ?? '', auth: request.headers.authorization, data: JSON.parse(body) as Record }) + requests.push({ path: request.url ?? '', auth: request.headers.authorization, data: JSON.parse(body || '{}') as Record }) response.setHeader('content-type', 'application/json') + if (request.method === 'GET' && request.url?.endsWith('/models')) { response.end(JSON.stringify({ data: [{ id: 'fake-model' }] })); return } if (responseMode === 'auth') { response.statusCode = 401; response.end(JSON.stringify({ error: 'private-key-echo new-keywith-space' })); return } if (responseMode === 'not-found') { response.statusCode = 404; response.end(JSON.stringify({ error: 'private-endpoint-echo' })); return } if (responseMode === 'malformed') { response.end('not JSON'); return } @@ -150,6 +173,29 @@ try { const address = server.address() assert.ok(address && typeof address === 'object') const endpoint = `http://127.0.0.1:${address.port}/v1` + for (const apiPreference of ['openai-completions', 'openai-responses'] as const) { + const found = await registry.fetchModels({ baseUrl: endpoint, protocol: 'openai', apiPreference, apiKey: 'new-keywith-space' }) + assert.equal(found.endpoint.api, apiPreference, 'shared models URL must not collapse API identity') + assert.deepEqual(found.models.map(model => model.id), ['fake-model']) + const saved = input('persist-' + apiPreference, { baseUrl: endpoint, apiPreference, endpoint: found.endpoint }) + await registry.saveModel(saved) + registry = await reload() + const restored = await registry.resolveModel(saved.id) + assert.equal(restored?.profile.apiPreference, apiPreference) + assert.equal(restored?.config.api, apiPreference) + assert(!JSON.stringify(await registry.listModels()).includes('new-keywith-space')) + await registry.saveModel({ ...saved, previousId: saved.id, apiKey: '' }) + assert.equal((await registry.resolveModel(saved.id))?.config.apiKey, 'new-keywith-space') + } + await assert.rejects(() => registry.saveModel(input('gemini', { protocol: 'gemini', apiPreference: undefined })), /仅支持/) + await assert.rejects(() => registry.saveModel(input('mismatch', { protocol: 'anthropic', apiPreference: 'openai-responses' })), /仅支持/) + await assert.rejects(() => registry.saveModel(input('stale-route', { baseUrl: endpoint, apiPreference: 'openai-responses', endpoint: { api: 'openai-completions', baseUrl: endpoint, checkedAt: 0 } })), /不一致/) + responsePayload = { type: 'message', content: [{ type: 'text', text: 'OK' }] } + const legacyProbe = await registry.testModel(input('legacy-probe', { baseUrl: endpoint, protocol: undefined, apiPreference: undefined })) + assert.equal(legacyProbe.endpoint?.api, 'anthropic-messages') + assert.equal(requests.at(-1)?.path, '/v1/messages') + responsePayload = undefined + requests.length = 0 const testInput = input('unsaved-test', { baseUrl: endpoint }) const beforeTest = await readFile(modelsFile, 'utf8') const successful = await registry.testModel(testInput) @@ -224,15 +270,15 @@ try { } responsePayload = undefined responseMode = 'negotiate-both' - for (const protocol of ['openai', 'auto'] as const) { + for (const protocol of ['openai'] as const) { const start = requests.length - const negotiated = await registry.testModel(input('prefer-responses-' + protocol, { baseUrl: endpoint, protocol })) + const negotiated = await registry.testModel(input('prefer-responses-' + protocol, { baseUrl: endpoint, protocol, apiPreference: undefined })) assert.equal(negotiated.success, true) assert.equal(negotiated.endpoint?.api, 'openai-responses', 'Responses wins when both OpenAI APIs are supported') assert.deepEqual(requests.slice(start).map(request => request.path), ['/v1/responses'], 'a successful Responses probe must not call Chat') } const previousChat = { api: 'openai-completions' as const, baseUrl: endpoint + '/custom', checkedAt: Date.now() - 1000, verifiedBy: 'generation' as const } - const previousInput = input('previous-chat', { baseUrl: endpoint, protocol: 'openai', endpoint: previousChat }) + const previousInput = input('previous-chat', { baseUrl: endpoint, protocol: 'openai', apiPreference: undefined, endpoint: previousChat }) const startRetest = requests.length const retested = await registry.testModel(previousInput) assert.equal(retested.endpoint?.api, 'openai-responses', 're-testing a stored Chat route follows the new priority') @@ -244,10 +290,20 @@ try { assert.equal(requests.at(-1)?.path, '/v1/chat/completions') responseMode = 'negotiate-chat-only' const startFallback = requests.length - const chatFallback = await registry.testModel(input('chat-only-protocol', { baseUrl: endpoint, protocol: 'openai' })) + const chatFallback = await registry.testModel(input('chat-only-protocol', { baseUrl: endpoint, protocol: 'openai', apiPreference: undefined })) assert.equal(chatFallback.success, true) assert.equal(chatFallback.endpoint?.api, 'openai-completions') assert.deepEqual(requests.slice(startFallback).map(request => request.path), ['/v1/responses', '/v1/chat/completions'], 'unsupported Responses falls back to Chat') + const explicitStart = requests.length + const noFallback = await registry.testModel(input('explicit-responses-only', { baseUrl: endpoint, apiPreference: 'openai-responses' })) + assert.equal(noFallback.success, false) + assert(requests.slice(explicitStart).every(request => !request.path.includes('chat/completions')), 'explicit Responses cannot fall back silently') + responseMode = 'negotiate-both' + for (const apiPreference of ['openai-completions', 'openai-responses'] as const) { + const probe = input('cache-identity', { baseUrl: endpoint, apiPreference }) + const tested = await registry.testModel(probe) + assert.equal(tested.endpoint?.api, apiPreference) + } responseMode = 'auth' const authFailure = await registry.testModel(testInput) assert.equal(authFailure.success, false) @@ -283,7 +339,7 @@ try { try { assert.match((await registry.testModel(testInput)).message, /超时/) } finally { globalThis.fetch = originalFetch } - const largeModel = input('large-sol', { model: 'gpt-6.1-sol', baseUrl: endpoint, protocol: 'openai', + const largeModel = input('large-sol', { model: 'gpt-6.1-sol', baseUrl: endpoint, protocol: 'openai', apiPreference: 'openai-responses', endpoint: { api: 'openai-responses', baseUrl: endpoint, checkedAt: Date.now(), verifiedBy: 'generation' }, maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 272000 }) await registry.saveModel(largeModel) @@ -333,9 +389,10 @@ try { registry = await reload() await assert.rejects(() => registry.listModels(), /损坏/) assert.equal(await readFile(modelsFile, 'utf8'), '{malformed', 'corrupt registries must never be silently overwritten') - console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, total/output budgets, legacy budget migration, derived input/reload/reduction, catalog limits, Responses priority, explicit Chat override, Chat fallback, stored-route retesting, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures') + console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, total/output budgets, legacy budget migration, derived input/reload/reduction, catalog limits, explicit API persistence/cache isolation/discovery, legacy Anthropic, Gemini rejection, Responses priority, explicit Chat override, Chat fallback, stored-route retesting, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures') } finally { if (server) await new Promise(resolve => server!.close(() => resolve())) + globalThis.fetch = realFetch globals[STATE_KEY] = previous await rm(temp, { recursive: true, force: true }) } diff --git a/scripts/protocol-bridge-regression.ts b/scripts/protocol-bridge-regression.ts new file mode 100644 index 0000000..da16492 --- /dev/null +++ b/scripts/protocol-bridge-regression.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, stat, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createConversionContext } from '../src/main/model-bridge/history' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { acquireModelBridge } from '../src/main/model-bridge' +import { convertRequest, convertResponse, type ConversionContext } from '../src/main/model-bridge/protocol' +import { readSSE } from '../src/main/model-bridge/stream' +import type { ModelApi, ResolvedModel } from '../src/shared/models' + +let checks = 0 +function check(value: unknown, message: string) { assert.ok(value, message); checks++ } +const context = (): ConversionContext => ({ responsesHistory: new Map() }) +function model(api: ModelApi, baseUrl = 'http://127.0.0.1:1/v1'): ResolvedModel { + return { profile: { id: api, model: 'offline-model', name: 'Offline', providerId: 'offline', baseUrl, hasApiKey: false, keyStorage: 'none', supportsImages: true, supportsToolCall: true }, config: { api, model: 'offline-model', baseUrl, apiKey: 'mock-upstream-secret', temperature: 0.2, maxToolRounds: 3 } } +} +const request = { model: 'attacker-model', max_tokens: 128, system: [{ type: 'text', text: 'System' }], messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGk=' } }] }, { role: 'assistant', content: [{ type: 'tool_use', id: 'call_1', name: 'echo', input: { value: 'ok' } }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }, { type: 'text', text: 'continue' }] }], tools: [{ name: 'echo', input_schema: { type: 'object', properties: { value: { type: 'string' } } } }], tool_choice: { type: 'any', disable_parallel_tool_use: true } } +for (const api of ['openai-completions', 'openai-responses'] as const) { + const value = convertRequest(request, model(api), context()) + check(value.model === 'offline-model', `${api}: immutable model`) + check(value.tool_choice === 'required' && value.parallel_tool_calls === false, `${api}: tool choice`) + check(!('apiKey' in value), `${api}: no credentials in payload`) + if (api === 'openai-responses') { + check(value.store === false && !value.previous_response_id, 'Responses stateless') + check(value.input.some((x: any) => x.type === 'function_call') && value.input.some((x: any) => x.type === 'function_call_output'), 'Responses full tool history') + check(value.input[1].content[1].image_url === 'data:image/png;base64,aGk=', 'Responses image') + } else { + check(value.messages[2].tool_calls[0].function.arguments === '{"value":"ok"}', 'Chat tool history') + check(value.messages[3].role === 'tool' && value.messages[4].role === 'user', 'Chat tool result order') + } + assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'document' }] }] }, model(api), context()), /Unsupported/); checks++ + assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: [{ type: 'document' }] }] }] }, model(api), context()), /Only text and image tool results/); checks++ +} +const chat = convertResponse({ id: 'chat_1', choices: [{ message: { content: 'ok', reasoning_content: 'reason', tool_calls: [{ id: 'call_1', function: { name: 'echo', arguments: '{"v":1}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 20, completion_tokens: 8, prompt_tokens_details: { cached_tokens: 5 } } }, 'm', false, context()) +check(chat.content[0].thinking === 'reason' && !chat.content[0].signature && chat.stop_reason === 'tool_use', 'Chat reasoning and tools') +check(chat.usage.input_tokens === 15 && chat.usage.cache_read_input_tokens === 5 && chat.usage.output_tokens === 8, 'Usage cached semantics') +for (const api of ['openai-completions', 'openai-responses'] as const) { + const m = model(api); m.config.supportsReasoning = true; m.config.thinkingLevel = 'auto' + const auto = convertRequest(request, m, context()) + check(!auto.reasoning_effort && !auto.reasoning?.effort, `${api}: auto effort is not forwarded`) + m.config.thinkingLevel = 'high'; m.profile.supportedEfforts = ['high'] + const high = convertRequest(request, m, context()) + check((high.reasoning?.effort ?? high.reasoning_effort) === 'high', `${api}: supported effort forwarded`) + m.config.thinkingLevel = 'low' + assert.throws(() => convertRequest(request, m, context()), /reasoning effort/); checks++ + const images = convertRequest({ max_tokens: 30, messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'img', content: [{ type: 'text', text: 'screenshot' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGk=' } }] }] }] }, model(api), context()) + const messages = images.input ?? images.messages + check(messages[0].output === 'screenshot' || messages[0].content === 'screenshot', `${api}: tool text preserved with images`) + check(JSON.stringify(messages[1]).includes('data:image/png;base64,aGk=') && JSON.stringify(messages[1]).includes('img'), `${api}: attributed tool image preserved`) +} +const rc = context(), responseData = { id: 'r1', status: 'completed', output: [{ type: 'reasoning', id: 'rs1', encrypted_content: 'opaque', summary: [{ type: 'summary_text', text: 'reason' }] }, { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo', arguments: '{"v":1}' }], usage: { input_tokens: 7, output_tokens: 4 } } +const converted = convertResponse(responseData, 'm', true, rc) +const replay = convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }] }, model('openai-responses'), rc) +check(replay.input[0].encrypted_content === 'opaque' && replay.input[1].call_id === 'call_1', 'Responses opaque reasoning exact-history replay') +assert.throws(() => convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }] }, model('openai-responses'), context()), /history is not available/); checks++ +rc.responsePrefix = undefined +convertResponse({ ...responseData, output: [{ ...responseData.output[0], encrypted_content: 'different' }, responseData.output[1]] }, 'm', true, rc) +assert.throws(() => convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }] }, model('openai-responses'), rc), /Ambiguous/); checks++ + +// Identical visible replies at different history occurrences must not share opaque state. +const occurrences = context(), history: any[] = [{ role: 'user', content: 'first' }] +const hiddenReply = (opaque: string) => ({ id: opaque, status: 'completed', output: [{ type: 'reasoning', id: opaque, summary: [], encrypted_content: opaque }, { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Done' }] }] }) +convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences) +const firstDone = convertResponse(hiddenReply('opaque-first'), 'm', true, occurrences) +history.push({ role: 'assistant', content: firstDone.content }, { role: 'user', content: 'second' }) +convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences) +const secondDone = convertResponse(hiddenReply('opaque-second'), 'm', true, occurrences) +history.push({ role: 'assistant', content: secondDone.content }, { role: 'user', content: 'third' }) +const repeated = convertRequest({ max_tokens: 20, messages: history }, model('openai-responses'), occurrences) +check(repeated.input.filter((x: any) => x.type === 'reasoning').map((x: any) => x.encrypted_content).join(',') === 'opaque-first,opaque-second', 'Identical visible responses replay their own history occurrence') +assert.throws(() => convertRequest({ max_tokens: 20, messages: [{ role: 'assistant', content: firstDone.content }, { role: 'user', content: 'compacted' }] }, model('openai-responses'), occurrences), /compaction or fork/); checks++ + +// UTF-8/SSE fragmentation, CRLF and multiline data. +const encoded = new TextEncoder().encode('data: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\n\n') +const parsed = [] +for await (const value of readSSE(new ReadableStream({ start(c) { for (const byte of encoded) c.enqueue(new Uint8Array([byte])); c.close() } }))) parsed.push(value) +check((parsed[0] as any).text === '你好' && parsed[1] === '[DONE]', 'SSE fragmented UTF8 and CRLF') + +let mode = 'normal', last: any, aborted = false, hits = 0 +const upstream = createServer(async (req, res) => { + hits++; const chunks = []; for await (const chunk of req) chunks.push(chunk) + last = { path: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString()) } + if (mode === 'error') { res.writeHead(401); res.end('mock-upstream-secret'); return } + if (mode === 'redirect') { res.writeHead(307, { location: 'http://127.0.0.1:1/never' }); res.end(); return } + if (mode === 'cancel') { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write(': waiting\n\n'); res.once('close', () => { aborted = true }); return } + const responses = req.url === '/v1/responses' + const completion = responses ? { id: 'r2', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '你好' }] }, { type: 'function_call', call_id: 'call_2', name: 'echo', arguments: '{"v":2}' }], usage: { input_tokens: 9, output_tokens: 3 } } : { id: 'c2', choices: [{ message: { content: '你好', tool_calls: [{ id: 'call_2', function: { name: 'echo', arguments: '{"v":2}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 9, completion_tokens: 3 } } + if (responses && ['hidden', 'reasoning'].includes(mode)) completion.output.push({ type: 'reasoning', id: 'rs2', encrypted_content: 'opaque-private-history', summary: mode === 'hidden' ? [] : [{ type: 'summary_text', text: 'one' }, { type: 'summary_text', text: 'two' }] } as any) + if (!last.body.stream) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(completion)); return } + res.writeHead(200, { 'content-type': 'text/event-stream' }) + const events = responses ? [ + { type: 'response.created', response: { id: 'r2' } }, + { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'm1' } }, + { type: 'response.output_text.delta', output_index: 0, content_index: 0, delta: '你好' }, + { type: 'response.output_text.done', output_index: 0, content_index: 0, text: '你好' }, + { type: 'response.output_item.added', output_index: 1, item: { type: 'function_call', call_id: 'call_2', name: 'echo' } }, + { type: 'response.function_call_arguments.delta', output_index: 1, delta: '{"v":' }, + { type: 'response.function_call_arguments.delta', output_index: 1, delta: '2}' }, + { type: 'response.output_item.done', output_index: 1, item: completion.output[1] }, + { type: 'response.completed', response: completion } + ] : [ + { choices: [{ index: 0, delta: { content: '你好' }, finish_reason: null }] }, + { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_2', function: { name: 'echo', arguments: '{"v":' } }] }, finish_reason: null }] }, + { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '2}' } }] }, finish_reason: 'tool_calls' }] }, + { choices: [], usage: completion.usage } + ] + if (responses && mode === 'reasoning') events.splice(events.length - 1, 0, + { type: 'response.output_item.added', output_index: 2, item: { type: 'reasoning', id: 'rs2' } } as any, + { type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 0, delta: 'one' } as any, + { type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 1, delta: 'two' } as any, + { type: 'response.output_item.done', output_index: 2, item: completion.output[2] } as any) + if (responses && mode === 'mismatch') (events[2] as any).delta = 'truncated' + const wire = events.map(x => `data: ${JSON.stringify(x)}\r\n\r\n`).join('') + (responses ? '' : 'data: [DONE]\n\n') + const bytes = Buffer.from(mode === 'eof' ? wire.slice(0, wire.indexOf('data:', 10)) : wire) + for (let i = 0; i < bytes.length; i += 7) res.write(bytes.subarray(i, i + 7)) + res.end() +}) +await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)) +const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1` +try { + for (const api of ['openai-completions', 'openai-responses'] as const) { + const original = model(api, upstreamUrl), lease = await acquireModelBridge(original) + original.config.model = 'changed'; original.config.apiKey = 'changed' + const send = (body: any, path = '/v1/messages', token = lease.apiKey, signal?: AbortSignal) => fetch(lease.baseUrl + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': token }, body: JSON.stringify(body), signal }) + try { + for (const stream of [false, true]) { + mode = 'normal' + const result = await send({ ...request, stream }) + check(result.status === 200, `${api}: HTTP ${stream ? 'stream' : 'json'}`) + check(last.path === (api === 'openai-responses' ? '/v1/responses' : '/v1/chat/completions') && last.body.model === 'offline-model' && last.auth === 'Bearer mock-upstream-secret', `${api}: frozen route and credentials`) + if (stream) { + const frames: any[] = []; for await (const event of readSSE(result.body!)) frames.push(event) + check(frames[0].type === 'message_start' && frames.at(-1).type === 'message_stop', `${api}: valid stream envelope`) + check(frames.filter(x => x.type === 'content_block_start').length === 2 && frames.filter(x => x.type === 'content_block_stop').length === 2, `${api}: closed text/tool blocks`) + check(frames.find(x => x.type === 'message_delta').usage.input_tokens === 9, `${api}: reported stream usage`) + check(frames.find(x => x.type === 'message_delta').delta.stop_reason === 'tool_use', `${api}: tool stop reason`) + } else { + const data = await result.json(); check(data.content[1].input.v === 2 && data.usage.input_tokens === 9, `${api}: nonstream content`) + } + } + const before = hits + check((await send(request, '/v1/messages', 'wrong')).status === 401, 'Wrong token denied') + check((await send(request, '/v1/other')).status === 404, 'Unknown path denied') + check((await send(request, '/v1/messages?url=http://bad')).status === 404, 'Query injection denied') + check(hits === before, 'Invalid requests never reach upstream') + const count = await send(request, '/v1/messages/count_tokens'), countBody = await count.json() + check(countBody.estimated === true && count.headers.get('x-ycode-token-count-source')?.includes('estimate'), 'Token count explicitly estimated') + mode = 'error'; const error = await send(request); check(error.status === 401 && !(await error.text()).includes('mock-upstream-secret'), 'Upstream errors redacted') + mode = 'redirect'; check((await send(request)).status === 502, 'Redirect not followed') + mode = 'eof'; const eof = await (await send({ ...request, stream: true })).text(); check(eof.includes('event: error') && !eof.includes('event: message_stop'), 'Truncated stream fails explicitly') + mode = 'cancel'; aborted = false + const controller = new AbortController(), pending = await send({ ...request, stream: true }, '/v1/messages', lease.apiKey, controller.signal) + controller.abort(); await pending.body?.cancel().catch(() => {}) + for (let i = 0; i < 100 && !aborted; i++) await new Promise(r => setTimeout(r, 10)) + check(aborted, `${api}: client disconnect aborts upstream`) + } finally { await Promise.all([lease.close(), lease.close()]) } + await assert.rejects(fetch(lease.baseUrl)); checks++ + } + const dir = await mkdtemp(join(tmpdir(), 'ycode-bridge-')) + try { + for (const reasoningMode of ['hidden', 'reasoning']) { + const file = join(dir, reasoningMode + '.json') + const first = await acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: file }) + mode = reasoningMode + let assistant: any[] + try { + const result = await fetch(first.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': first.apiKey }, body: JSON.stringify({ ...request, stream: reasoningMode === 'reasoning' }) }) + if (reasoningMode === 'reasoning') { + const blocks: any[] = [] + for await (const frame of readSSE(result.body!)) { + const f = frame as any + if (f.type === 'content_block_start') blocks[f.index] = { ...f.content_block } + if (f.type === 'content_block_delta') { + const b = blocks[f.index] + if (f.delta.type === 'input_json_delta') b.args = (b.args ?? '') + f.delta.partial_json + else b[b.type] += f.delta[b.type] + } + check(f.type !== 'error', 'Responses multioutput reasoning stream succeeds') + } + assistant = blocks.map(b => { if (b.type === 'tool_use') { b.input = JSON.parse(b.args); delete b.args }; return b }) + } else assistant = (await result.json()).content + } finally { await first.close() } + const disk = await readFile(file, 'utf8') + check(disk.includes('opaque-private-history') && !disk.includes('mock-upstream-secret'), 'History persists opaque state without API credentials') + if (process.platform !== 'win32') check(((await stat(file)).mode & 0o777) === 0o600, 'History private file permissions') + const second = await acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: file }) + try { + mode = 'normal' + const result = await fetch(second.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': second.apiKey }, body: JSON.stringify({ max_tokens: 50, messages: [...request.messages, { role: 'assistant', content: assistant! }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_2', content: 'ok' }] }] }) }) + check(result.status === 200 && last.body.input.some((x: any) => x.encrypted_content === 'opaque-private-history'), `${reasoningMode}: close/reacquire replays opaque reasoning`) + await result.body?.cancel() + mode = 'mismatch' + const mismatch = await fetch(second.baseUrl + '/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': second.apiKey }, body: JSON.stringify({ ...request, stream: true }) }) + check((await mismatch.text()).includes('event: error'), 'Responses final content mismatch fails') + } finally { await second.close() } + } + const bad = join(dir, 'bad.json'); await writeFile(bad, '{broken') + await assert.rejects(acquireModelBridge(model('openai-responses', upstreamUrl), { historyFile: bad }), /history/); checks++ + await writeFile(bad, JSON.stringify({ version: 1, entries: [['[]', [{ type: 'reasoning', summary: 'invalid' }]]] })) + await assert.rejects(createConversionContext(bad), /history/); checks++ + const snapshotFile = join(dir, 'snapshot.json'), saved = await createConversionContext(snapshotFile) + convertResponse(responseData, 'm', true, saved) + const saving = saved.persist!(); saved.responsesHistory.clear(); await saving + check((await createConversionContext(snapshotFile)).responsesHistory.size === 1, 'Persistence snapshots before await') + await writeFile(join(dir, 'blocked'), 'not a directory') + const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null) + check(blocked === null, 'Invalid history path fails closed') + } finally { await rm(dir, { recursive: true, force: true }) } +} finally { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(() => resolve())) } +console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`) diff --git a/scripts/session-store-regression.ts b/scripts/session-store-regression.ts index bc6b63f..850e8a7 100644 --- a/scripts/session-store-regression.ts +++ b/scripts/session-store-regression.ts @@ -1,6 +1,7 @@ +import { modelBridgeHistoryDirectory } from '../src/main/model-bridge/paths' import assert from 'node:assert/strict' import { testTerminalHostPlugin } from './test-terminal-host-plugin' -import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,6 +9,7 @@ import { build } from 'esbuild' import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types' interface SessionStoreApi { + deleteSession(sessionId: string): Promise listSessions(): Promise createSession(title?: string): Promise appendMessage(sessionId: string, message: ChatMessage): Promise @@ -265,6 +267,16 @@ async function main(): Promise { assert.deepEqual(afterRecovery, inMemory, 'session:messages should remain consistent after the write queue recovers') + const deletedHistory = modelBridgeHistoryDirectory(state.userData, recovered.id) + const retainedHistory = modelBridgeHistoryDirectory(state.userData, targetSession.id) + await mkdir(deletedHistory, { recursive: true }) + await mkdir(retainedHistory, { recursive: true }) + await writeFile(join(deletedHistory, 'route.json'), '{}') + await writeFile(join(retainedHistory, 'route.json'), '{}') + await coldModule.sessions.deleteSession(recovered.id) + await assert.rejects(readFile(join(deletedHistory, 'route.json')), /ENOENT/) + assert.equal(await readFile(join(retainedHistory, 'route.json'), 'utf8'), '{}', 'deleting one session preserves another bridge history') + assert.ok(!modelBridgeHistoryDirectory(state.userData, '../escape').includes('..'), 'session identifiers are hashed, not path segments') console.log('session-store-regression: all checks passed') } finally { if (previous === undefined) delete globals[STATE_KEY] diff --git a/src/main/agent/claude/claude-kernel.ts b/src/main/agent/claude/claude-kernel.ts index 7ead5b9..b55d23a 100644 --- a/src/main/agent/claude/claude-kernel.ts +++ b/src/main/agent/claude/claude-kernel.ts @@ -1,4 +1,8 @@ import { privateRuntimeEnvironment } from '../../private-runtimes' +import { acquireModelBridge } from '../../model-bridge' +import { modelBridgeHistoryDirectory } from '../../model-bridge/paths' +import { modelProfileApi } from '@shared/model-support' +import { modelThinkingLevel } from '@shared/model-thinking' import { app } from 'electron' import { existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' @@ -201,7 +205,7 @@ export function claudeRuntimeInfo(): { available: boolean; exePath: string | nul } } -function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv { +function modelEnvironment(model: ResolvedModel, bridge?: { baseUrl: string; apiKey: string }): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_CONFIG_DIR: join(app.getPath('userData'), 'claude-code'), @@ -248,6 +252,10 @@ function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv { } else { delete env.ANTHROPIC_API_KEY } + if (bridge) { + env.ANTHROPIC_BASE_URL = bridge.baseUrl + env.ANTHROPIC_API_KEY = bridge.apiKey + } const contextWindow = model.profile.contextWindowTokens ?? model.profile.maxInputTokens if (contextWindow && Number.isFinite(contextWindow) && contextWindow > 0) { env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.trunc(contextWindow)) @@ -328,6 +336,8 @@ export class ClaudeKernel { private consuming: Promise | null = null private active: ActiveTurn | null = null private starting = false + private startup: Promise | null = null + private bridge: Awaited> | null = null private permissionUpdates: Promise = Promise.resolve() private policyTransitions = 0 private policyRevision = 0 @@ -345,6 +355,8 @@ export class ClaudeKernel { private agentMode: AgentMode constructor(private readonly options: ClaudeKernelOptions) { + // A running kernel must never adopt an edited registry object mid-turn. + this.options = { ...options, model: structuredClone(options.model) } this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => { if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合') } }) @@ -363,7 +375,23 @@ export class ClaudeKernel { } async ensureStarted(): Promise { - const childEnvironment = await privateRuntimeEnvironment(modelEnvironment(this.options.model)) + if (this.closed) throw new Error('Claude 内核已释放') + if (!this.startup) { + const startup = this.startInstance().catch(async (error) => { + if (!this.instance) { + const bridge = this.bridge + this.bridge = null + await bridge?.close() + } + throw error + }) + this.startup = startup + void startup.finally(() => { if (this.startup === startup) this.startup = null }).catch(() => undefined) + } + await this.startup + } + + private async startInstance(): Promise { await this.permissionUpdates await this.draining if (this.policyError) throw this.policyError @@ -380,6 +408,24 @@ export class ClaudeKernel { const newSessionId = !resumeCurrent && this.options.forceNewSession ? randomUUID() : undefined const requestedSessionId = forkSessionId ?? newSessionId if (requestedSessionId) this.currentSessionId = requestedSessionId + const api = modelProfileApi(this.options.model.profile) + if (!api) throw new Error('当前模型协议尚未支持') + if (api !== 'anthropic-messages' && !this.bridge) { + const bridgeModel = structuredClone(this.options.model) + bridgeModel.config.thinkingLevel = modelThinkingLevel(bridgeModel.profile, this.options.thinkingLevel) + const historyIdentity = createHash('sha256').update(JSON.stringify([ + this.options.sessionId, bridgeModel.profile.id, bridgeModel.config.api, + bridgeModel.config.baseUrl, bridgeModel.config.model, + createHash('sha256').update(bridgeModel.config.apiKey ?? '').digest('hex') + ])).digest('hex') + const bridge = await acquireModelBridge(bridgeModel, { + historyFile: join(modelBridgeHistoryDirectory(app.getPath('userData'), this.options.sessionId), `${historyIdentity}.json`) + }) + if (this.closed) { await bridge.close(); throw new Error('Claude 内核已释放') } + this.bridge = bridge + } + const childEnvironment = await privateRuntimeEnvironment(modelEnvironment(this.options.model, this.bridge ?? undefined)) + if (this.closed) throw new Error('Claude 内核已释放') const instance = query({ prompt: input, options: { @@ -524,7 +570,9 @@ export class ClaudeKernel { ? { resume: this.options.resumeSessionId } : { sessionId: newSessionId ?? this.currentSessionId }), stderr: (data) => { - if (data.trim()) console.error(`[claude] ${data.trim()}`) + const safe = [this.options.model.config.apiKey, this.bridge?.apiKey].filter(Boolean) + .reduce((message, secret) => message.split(secret!).join('[REDACTED]'), data.trim()) + if (safe) console.error(`[claude] ${safe}`) } } }) @@ -532,7 +580,7 @@ export class ClaudeKernel { this.processAbort = processAbort this.instance = instance this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode) - const consuming = this.consume(instance) + const consuming = this.consume(instance, this.bridge) this.consuming = consuming void consuming.finally(() => { if (this.consuming === consuming) this.consuming = null @@ -553,13 +601,17 @@ export class ClaudeKernel { } } - private async consume(instance: Query): Promise { + private async consume(instance: Query, bridge: Awaited> | null): Promise { try { for await (const message of instance) await this.handleMessage(message) } catch (error) { this.failActive(error instanceof Error ? error : new Error(String(error))) if (!this.closed) console.error('Claude Code process stopped', error) } finally { + if (bridge && this.bridge === bridge) { + this.bridge = null + await bridge.close() + } this.failActive(new Error('Claude Code 进程已退出')) if (this.instance === instance) { instance.close() @@ -951,7 +1003,14 @@ export class ClaudeKernel { if (this.policyError) throw this.policyError const input = this.input if (!input) throw new Error('Claude Code 输入流未就绪') - if (request.signal.aborted) throw abortError() + if (request.signal.aborted) { + // Cancellation can arrive while the bridge/runtime is starting. Retire + // that producer before rejecting, without permanently closing this kernel. + this.processAbort?.abort() + this.instance?.close() + await this.consuming?.catch(() => undefined) + throw abortError() + } return new Promise((resolve, reject) => { const active: ActiveTurn = { request, @@ -1180,6 +1239,9 @@ export class ClaudeKernel { this.input?.close() this.input = null this.processAbort?.abort() + const bridge = this.bridge + this.bridge = null + await bridge?.close() const instance = this.instance try { await this.instance?.interrupt() } catch { /* the process may already be gone */ } instance?.close() @@ -1219,7 +1281,21 @@ function kernelFingerprint(options: ClaudeKernelOptions): string { modelId: options.model.profile.id, model: options.model.config.model, baseUrl: options.model.config.baseUrl, - apiKey: options.model.config.apiKey, + // Keep only a non-reversible revision in the cache identity, never the key itself. + credentialRevision: createHash('sha256').update(options.model.config.apiKey ?? '').digest('hex'), + api: options.model.config.api, + profileApi: modelProfileApi(options.model.profile), + endpoint: options.model.profile.endpoint, + capabilities: { + tools: options.model.profile.supportsToolCall, images: options.model.profile.supportsImages, + reasoning: options.model.profile.supportsReasoning, efforts: options.model.profile.supportedEfforts, + defaultEffort: options.model.profile.defaultEffort, onlyReasoning: options.model.profile.onlyReasoning, + canDisableThinking: options.model.profile.canDisableThinking, + contextWindow: options.model.profile.contextWindowTokens, maxInput: options.model.profile.maxInputTokens, + maxOutput: options.model.profile.maxOutputTokens, budget: options.model.profile.reasoningBudget, + temperature: options.model.profile.temperature + }, + bridgeVersion: 1, authMode: options.model.profile.authMode, thinkingLevel: options.thinkingLevel, systemContext: options.systemContext diff --git a/src/main/chat-turn-service.ts b/src/main/chat-turn-service.ts index e06ca4c..ebe01ac 100644 --- a/src/main/chat-turn-service.ts +++ b/src/main/chat-turn-service.ts @@ -1,3 +1,4 @@ +import { isSupportedModelProfile } from '@shared/model-support' /** Main-process chat turns backed by one persistent Claude Code kernel per session. */ import type { WebContents } from 'electron' import { randomUUID } from 'node:crypto' @@ -85,10 +86,7 @@ export async function disposeSessionKernel(sessionId: string): Promise { } function isSupportedClaudeModel(model: Awaited>): boolean { - if (!model) return false - return model.config.api === 'anthropic-messages' || - model.profile.protocol === 'anthropic' || - /\/messages\/?$/i.test(model.config.baseUrl ?? '') + return Boolean(model && isSupportedModelProfile(model.profile)) } function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxInputTokens?: number } }): number | undefined { @@ -124,7 +122,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode }) const selectedModel = await models.resolveModel(context.modelId) if (!selectedModel) throw new Error('请先添加并选择模型') - if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型') + if (!isSupportedClaudeModel(selectedModel)) throw new Error('当前模型协议尚未支持') const workspaceRoot = await sessionRoot(sessionId) if (payload.skillIds !== undefined && (!Array.isArray(payload.skillIds) || payload.skillIds.some(id => typeof id !== 'string') || payload.skillIds.length > 100)) throw new Error('技能引用无效') if (payload.mcpServerIds !== undefined && (!Array.isArray(payload.mcpServerIds) || payload.mcpServerIds.some(id => typeof id !== 'string') || payload.mcpServerIds.length > 100)) throw new Error('MCP 服务引用无效') @@ -419,7 +417,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot const context = await sessions.getSessionContext(sessionId) const model = await models.resolveModel(context.modelId) if (!model) throw new Error('请先添加并选择模型') - if (!isSupportedClaudeModel(model)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型') + if (!isSupportedClaudeModel(model)) throw new Error('当前模型协议尚未支持') const claudeSession = await sessions.getClaudeSession(sessionId) if (!claudeSession.sessionId) throw new Error('Claude 会话尚未建立,无法回退文件') const workspaceRoot = await sessionRoot(sessionId) diff --git a/src/main/model-bridge/history.ts b/src/main/model-bridge/history.ts new file mode 100644 index 0000000..aff377d --- /dev/null +++ b/src/main/model-bridge/history.ts @@ -0,0 +1,70 @@ +import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { randomBytes } from 'node:crypto' +import { BridgeError, record, type ConversionContext, type Json } from './protocol' + +const MAX_BYTES = 16 * 1024 * 1024 +const queues = new Map>() +function failure(): BridgeError { return new BridgeError(502, 'Responses reasoning history could not be saved or read. Restore the local history file or start a new conversation.') } +function validItem(x: unknown): boolean { + if (!record(x)) return false + if (x.type === 'reasoning') return typeof x.id === 'string' && (x.encrypted_content === undefined || typeof x.encrypted_content === 'string') && Array.isArray(x.summary) && x.summary.every((s: unknown) => record(s) && s.type === 'summary_text' && typeof s.text === 'string') + if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string' + return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string') +} +async function load(file: string): Promise> { + try { + const stat = await lstat(file) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure() + const value = JSON.parse(await readFile(file, 'utf8')) + if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure() + const entries = new Map() + for (const entry of value.entries) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || entries.has(entry[0])) throw failure() + if (!/^[a-f0-9]{64}:[a-f0-9]{64}$/.test(entry[0])) throw failure() + const items = entry[1] + if (items !== null && (!Array.isArray(items) || !items.every(validItem))) throw failure() + entries.set(entry[0], items) + } + return entries + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map() + throw failure() + } +} +/** Only the trusted main-process caller supplies this path. No upstream credential is serialized. */ +export async function createConversionContext(historyFile?: string): Promise { + if (!historyFile) return { responsesHistory: new Map() } + await queues.get(historyFile) + const context: ConversionContext = { responsesHistory: await load(historyFile) } + context.persist = async () => { + const snapshot = new Map(context.responsesHistory) + const previous = queues.get(historyFile) ?? Promise.resolve() + const pending = previous.catch(() => {}).then(async () => { + let temp: string | undefined + try { + const merged = await load(historyFile) + for (const [key, value] of snapshot) { + const existing = merged.get(key) + merged.set(key, existing === null || existing && JSON.stringify(existing) !== JSON.stringify(value) ? null : value) + } + if (merged.size > 1024) throw failure() + const data = JSON.stringify({ version: 1, entries: [...merged] }) + if (Buffer.byteLength(data) > MAX_BYTES) throw failure() + await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 }) + temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp` + await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + await rename(temp, historyFile); temp = undefined + for (const [key, value] of merged) { + const current = context.responsesHistory.get(key) + context.responsesHistory.set(key, current === null || current && JSON.stringify(current) !== JSON.stringify(value) ? null : value) + } + } catch { throw failure() } + finally { if (temp) await unlink(temp).catch(() => {}) } + }) + queues.set(historyFile, pending) + try { await pending } + finally { if (queues.get(historyFile) === pending) queues.delete(historyFile) } + } + return context +} diff --git a/src/main/model-bridge/index.ts b/src/main/model-bridge/index.ts new file mode 100644 index 0000000..4d6cd8f --- /dev/null +++ b/src/main/model-bridge/index.ts @@ -0,0 +1,132 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomBytes, timingSafeEqual } from 'node:crypto' +import type { AddressInfo } from 'node:net' +import type { ResolvedModel } from '../../shared/models' +import { endpointUrl, modelHeaders } from '../../shared/model-endpoint' +import { BridgeError, convertRequest, convertResponse, record, type ConversionContext, type Json } from './protocol' +import { streamResponse } from './stream' +import { createConversionContext } from './history' + +const BODY_LIMIT = 16 * 1024 * 1024 +function json(response: ServerResponse, status: number, body: Json): void { + if (response.destroyed || response.writableEnded) return + response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body)) +} +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > BODY_LIMIT) throw new BridgeError(413, 'Messages request exceeds 16 MiB limit') + chunks.push(chunk) + } + try { const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (!record(body)) throw new Error(); return body } + catch { throw new BridgeError(400, 'Invalid JSON request') } +} +async function readUpstream(response: Response): Promise { + if (!response.body) throw new BridgeError(502, 'Empty upstream response') + const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0 + try { + while (true) { + const next = await reader.read(); if (next.done) break + size += next.value.byteLength + if (size > BODY_LIMIT * 2) throw new BridgeError(502, 'Upstream response exceeds limit') + chunks.push(next.value) + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) + if (!record(value)) throw new Error() + return value + } catch (error) { if (error instanceof BridgeError) throw error; throw new BridgeError(502, 'Invalid upstream JSON response') } + finally { await reader.cancel().catch(() => {}); reader.releaseLock() } +} +export interface ModelBridgeLease { baseUrl: string; apiKey: string; close(): Promise } + +/** One frozen route per SDK query. No shared mutable current-model route or credential logging. */ +export async function acquireModelBridge(source: ResolvedModel, options: { historyFile?: string } = {}): Promise { + const model: ResolvedModel = structuredClone(source) + const api = model.config.api + if (api !== 'openai-completions' && api !== 'openai-responses') throw new Error('Protocol bridge supports only OpenAI Chat Completions and Responses') + const endpoint = { api, baseUrl: model.config.baseUrl.replace(/\/+$/, ''), checkedAt: 0 } + const url = endpointUrl(endpoint, 'generate') + const parsed = new URL(url) + if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error('Invalid bridge upstream endpoint') + const apiKey = randomBytes(32).toString('hex'), expected = Buffer.from(apiKey) + const controllers = new Set() + const requests = new Set>() + const context: ConversionContext = await createConversionContext(api === 'openai-responses' ? options.historyFile : undefined) + let closed = false, port = 0 + const server = createServer(async (request, response) => { + let finishRequest!: () => void + const requestFinished = new Promise(resolve => { finishRequest = resolve }) + requests.add(requestFinished) + const controller = new AbortController() + controllers.add(controller) + const abort = () => controller.abort() + request.once('aborted', abort) + response.once('close', abort) + const timeout = setTimeout(abort, 10 * 60 * 1000); timeout.unref() + try { + if (closed) throw new BridgeError(503, 'Model bridge lease is closed') + if (request.headers.host !== `127.0.0.1:${port}`) throw new BridgeError(403, 'Invalid loopback host') + if (request.headers.origin) throw new BridgeError(403, 'Browser origins are not permitted') + const supplied = request.headers['x-api-key'] ?? request.headers.authorization?.replace(/^Bearer /, '') + const actual = Buffer.from(typeof supplied === 'string' ? supplied : '') + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new BridgeError(401, 'Invalid bridge token') + if (request.method !== 'POST') throw new BridgeError(405, 'Only POST is supported') + // Anthropic SDK may attach its beta query marker; no other query/routing fields are accepted. + const path = request.url?.replace(/\?beta=true$/, '') + if (path !== '/v1/messages' && path !== '/v1/messages/count_tokens') throw new BridgeError(404, 'Unknown bridge route') + if (!request.headers['content-type']?.startsWith('application/json')) throw new BridgeError(415, 'JSON content type required') + if (Number(request.headers['content-length']) > BODY_LIMIT) throw new BridgeError(413, 'Messages request exceeds 16 MiB limit') + const body = await readBody(request) + const requestContext: ConversionContext = { responsesHistory: context.responsesHistory, persist: context.persist } + if (path.endsWith('/count_tokens')) { + // Validate the same supported content before returning a deliberately labeled estimate. + convertRequest({ ...body, max_tokens: body.max_tokens ?? 1 }, model, requestContext) + response.setHeader('x-ycode-token-count-source', 'conservative-estimate-utf8-bytes') + json(response, 200, { input_tokens: Buffer.byteLength(JSON.stringify({ system: body.system, messages: body.messages, tools: body.tools }), 'utf8'), estimated: true, source: 'conservative-estimate-utf8-bytes' }) + return + } + const payload = convertRequest(body, model, requestContext) + if (controller.signal.aborted) return + const upstream = await fetch(url, { method: 'POST', headers: modelHeaders(api, model.config.authMode === 'none' ? '' : model.config.apiKey), body: JSON.stringify(payload), signal: controller.signal, redirect: 'error' }) + if (!upstream.ok) { + await upstream.body?.cancel() + // Never expose upstream response bodies: they may echo credentials or request content. + throw new BridgeError(upstream.status === 429 ? 429 : upstream.status === 401 || upstream.status === 403 ? 401 : 502, `Upstream ${api} request failed (HTTP ${upstream.status})`) + } + if (payload.stream) { + if (!upstream.headers.get('content-type')?.includes('text/event-stream')) { await upstream.body?.cancel(); throw new BridgeError(502, 'Upstream did not return an SSE stream') } + await streamResponse(upstream, response, model.config.model, api === 'openai-responses', requestContext) + } else { + const converted = convertResponse(await readUpstream(upstream), model.config.model, api === 'openai-responses', requestContext) + await context.persist?.() + json(response, 200, converted) + } + } catch (error) { + if (!response.headersSent && !response.destroyed) { + const status = error instanceof BridgeError ? error.status : controller.signal.aborted ? 504 : 502 + json(response, status, { type: 'error', error: { type: status < 500 ? 'invalid_request_error' : 'api_error', message: error instanceof BridgeError ? error.message : controller.signal.aborted ? 'Model bridge request canceled or timed out' : 'Model bridge upstream request failed' } }) + } + } finally { + clearTimeout(timeout); controller.abort(); controllers.delete(controller) + request.off('aborted', abort); response.off('close', abort) + requests.delete(requestFinished); finishRequest() + } + }) + server.requestTimeout = 60_000; server.headersTimeout = 15_000; server.keepAliveTimeout = 1_000 + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) + }) + port = (server.address() as AddressInfo).port + let closing: Promise | undefined + return { baseUrl: `http://127.0.0.1:${port}`, apiKey, close() { + return closing ??= (async () => { + closed = true; for (const controller of controllers) controller.abort() + const stopped = new Promise(resolve => server.close(() => resolve())) + server.closeAllConnections() + await Promise.all([stopped, ...requests]) + context.responsesHistory.clear() + })() + } } +} diff --git a/src/main/model-bridge/paths.ts b/src/main/model-bridge/paths.ts new file mode 100644 index 0000000..96506b7 --- /dev/null +++ b/src/main/model-bridge/paths.ts @@ -0,0 +1,7 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' + +/** Session IDs never become path components; deleting a session removes its bridge-only history. */ +export function modelBridgeHistoryDirectory(userData: string, sessionId: string): string { + return join(userData, 'model-bridge-history', createHash('sha256').update(sessionId).digest('hex')) +} diff --git a/src/main/model-bridge/protocol.ts b/src/main/model-bridge/protocol.ts new file mode 100644 index 0000000..5226045 --- /dev/null +++ b/src/main/model-bridge/protocol.ts @@ -0,0 +1,221 @@ +import { createHash } from 'node:crypto' +import type { ResolvedModel } from '../../shared/models' +import { openAICompletionTokenField } from '../../shared/model-endpoint' + +/** Protocol-only JSON: never execute tools or accept request-controlled routing. */ +export type Json = Record +export class BridgeError extends Error { + constructor(public status: number, message: string) { super(message) } +} +export function requireValue(ok: unknown, message: string): asserts ok { + if (!ok) throw new BridgeError(400, message) +} +export function record(value: unknown): value is Json { return !!value && typeof value === 'object' && !Array.isArray(value) } +function text(value: unknown, label: string): string { requireValue(typeof value === 'string', `Invalid ${label}`); return value } +function blocks(content: unknown): Json[] { + if (typeof content === 'string') return [{ type: 'text', text: content }] + requireValue(Array.isArray(content) && content.every(record), 'Unsupported message content') + return content +} +function imageUrl(b: Json): string { + const s = b.source + requireValue(record(s), 'Invalid image source') + if (s.type === 'base64') { + requireValue(/^image\/(png|jpeg|gif|webp)$/.test(s.media_type) && typeof s.data === 'string' && /^[A-Za-z0-9+/\r\n]*={0,2}$/.test(s.data), 'Invalid image data') + return `data:${s.media_type};base64,${s.data}` + } + requireValue(s.type === 'url' && typeof s.url === 'string' && /^https?:\/\//.test(s.url), 'Unsupported image source') + return s.url +} +export interface ConversionContext { responsesHistory: Map; persist?: () => Promise; responsePrefix?: string } +/** Ignore SDK cache hints and normalize adjacent text blocks, never a reasoning-text-only key. */ +export function historyKey(content: Json[]): string { + const normalized: Json[] = [] + for (const b of content) { + requireValue(b.type !== 'thinking' || !b.signature, 'Signed or foreign thinking history cannot be translated') + const item = b.type === 'text' ? { type: b.type, text: b.text } : b.type === 'thinking' ? { type: b.type, thinking: b.thinking } : b.type === 'tool_use' ? { type: b.type, id: b.id, name: b.name, input: b.input } : b.type === 'image' ? { type: b.type, source: b.source } : b.type === 'tool_result' ? { type: b.type, tool_use_id: b.tool_use_id, is_error: !!b.is_error, content: JSON.parse(historyKey(blocks(b.content ?? ''))) } : b + const previous = normalized.at(-1) + if (previous && previous.type === item.type && (item.type === 'text' || item.type === 'thinking')) previous[item.type] += item[item.type] + else normalized.push(item) + } + return JSON.stringify(normalized) +} +function replayKey(prefix: string, content: Json[]): string { + return createHash('sha256').update(prefix).digest('hex') + ':' + createHash('sha256').update(historyKey(content)).digest('hex') +} +export function convertRequest(body: Json, model: ResolvedModel, context: ConversionContext): Json { + const { config: c, profile: p } = model, responses = c.api === 'openai-responses' + requireValue(Array.isArray(body.messages), 'messages must be an array') + for (const key of ['mcp_servers', 'container', 'context_management']) requireValue(body[key] === undefined, `Unsupported Messages field: ${key}`) + const input: Json[] = [] + const prefix: Json[] = [] + context.responsePrefix = undefined + if (body.system !== undefined) { + const system = blocks(body.system) + requireValue(system.every(b => b.type === 'text' && typeof b.text === 'string'), 'Only text system blocks are supported') + if (system.length) input.push({ role: 'system', content: system.map(b => b.text).join('\n\n') }) + } + for (const message of body.messages) { + requireValue(record(message) && ['user', 'assistant'].includes(message.role), 'Invalid message role') + const role = message.role, content = blocks(message.content) + const prior = JSON.stringify(prefix) + prefix.push({ role, content: JSON.parse(historyKey(content)) }) + if (responses && role === 'assistant') { + const key = replayKey(prior, content), saved = context.responsesHistory.get(key) + requireValue(saved !== null, 'Ambiguous Responses reasoning history; start a new conversation') + if (saved) { input.push(...saved); continue } + // A recognizable response with a different prefix is a compaction/fork boundary, + // not permission to discard opaque reasoning that has no visible thinking block. + if ([...context.responsesHistory.keys()].some(existing => existing.endsWith(key.slice(64)))) throw new BridgeError(400, 'Responses reasoning history changed after compaction or fork; start a new conversation') + } + let parts: Json[] = [], calls: Json[] = [], reasoning = '' + const toolImages: Json[] = [] + const flush = () => { + if (parts.length || calls.length || reasoning) { + input.push({ role, content: parts.length ? parts : '', ...(!responses && calls.length ? { tool_calls: calls } : {}), ...(!responses && reasoning ? { reasoning_content: reasoning } : {}) }) + parts = []; calls = []; reasoning = '' + } + } + for (const b of content) { + switch (b.type) { + case 'text': parts.push({ type: responses ? role === 'assistant' ? 'output_text' : 'input_text' : 'text', text: text(b.text, 'text') }); break + case 'image': + requireValue(role === 'user', 'Assistant image history is unsupported') + requireValue(p.supportsImages !== false, 'Selected model does not support images') + parts.push(responses ? { type: 'input_image', image_url: imageUrl(b), detail: 'auto' } : { type: 'image_url', image_url: { url: imageUrl(b) } }); break + case 'tool_use': { + requireValue(role === 'assistant' && record(b.input), 'Invalid tool_use') + const id = text(b.id, 'tool id'), name = text(b.name, 'tool name'), args = JSON.stringify(b.input) + if (responses) { flush(); input.push({ type: 'function_call', call_id: id, name, arguments: args }) } + else calls.push({ id, type: 'function', function: { name, arguments: args } }) + break + } + case 'tool_result': { + requireValue(role === 'user', 'tool_result must be a user message'); flush() + const result = blocks(b.content ?? '') + requireValue(result.every(x => x.type === 'text' && typeof x.text === 'string' || x.type === 'image'), 'Only text and image tool results are supported') + const output = result.filter(x => x.type === 'text').map(x => x.text).join('\n') + for (const image of result.filter(x => x.type === 'image')) { + requireValue(p.supportsImages !== false, 'Selected model does not support images') + // Chat tool messages cannot contain image_url. Preserve the actual image in a + // following user message, explicitly associated with its original tool call. + toolImages.push({ type: responses ? 'input_text' : 'text', text: `Image from tool result ${text(b.tool_use_id, 'tool result id')}:` }) + toolImages.push(responses ? { type: 'input_image', image_url: imageUrl(image), detail: 'auto' } : { type: 'image_url', image_url: { url: imageUrl(image) } }) + } + const value = b.is_error ? `Tool error:\n${output}` : output + input.push(responses ? { type: 'function_call_output', call_id: text(b.tool_use_id, 'tool result id'), output: value } : { role: 'tool', tool_call_id: text(b.tool_use_id, 'tool result id'), content: value }) + break + } + case 'thinking': + requireValue(role === 'assistant' && !b.signature, 'Signed or foreign thinking history cannot be translated') + if (responses) { + throw new BridgeError(400, 'Responses reasoning history is not available in this lease; start a new conversation') + } else reasoning += text(b.thinking, 'thinking') + break + default: throw new BridgeError(400, `Unsupported Messages content type: ${String(b.type).slice(0, 60)}`) + } + } + flush() + if (toolImages.length) input.push({ role: 'user', content: toolImages }) + } + context.responsePrefix = JSON.stringify(prefix) + const output: Json = { model: c.model, [responses ? 'input' : 'messages']: input, stream: body.stream === true } + if (responses) { output.store = false; output.include = ['reasoning.encrypted_content'] } + else if (output.stream) output.stream_options = { include_usage: true } + const requestedMax = body.max_tokens + requireValue(Number.isInteger(requestedMax) && requestedMax > 0, 'max_tokens must be a positive integer') + const max = c.maxOutputTokens ? Math.min(c.maxOutputTokens, requestedMax) : requestedMax + output[responses ? 'max_output_tokens' : openAICompletionTokenField(c.model)] = max + if (c.temperatureSupported !== false) output.temperature = typeof body.temperature === 'number' ? body.temperature : c.temperature + if (body.top_p !== undefined) output.top_p = body.top_p + if (body.stop_sequences?.length) { + requireValue(!responses, 'Responses does not support stop_sequences') + output.stop = body.stop_sequences + } + if (body.tools !== undefined) { + requireValue(Array.isArray(body.tools), 'tools must be an array') + requireValue(p.supportsToolCall !== false || !body.tools.length, 'Selected model does not support tools') + output.tools = body.tools.map((tool: Json) => { + requireValue(record(tool) && (!tool.type || tool.type === 'custom') && typeof tool.name === 'string' && record(tool.input_schema), 'Only custom function tools are supported') + const fn = { name: tool.name, description: tool.description, parameters: tool.input_schema } + return responses ? { type: 'function', ...fn, strict: false } : { type: 'function', function: fn } + }) + } + if (body.tool_choice) { + const choice = body.tool_choice + requireValue(record(choice) && ['auto', 'any', 'none', 'tool'].includes(choice.type), 'Unsupported tool_choice') + output.tool_choice = choice.type === 'tool' ? responses ? { type: 'function', name: text(choice.name, 'tool choice') } : { type: 'function', function: { name: text(choice.name, 'tool choice') } } : choice.type === 'any' ? 'required' : choice.type + if (choice.disable_parallel_tool_use !== undefined) output.parallel_tool_calls = !choice.disable_parallel_tool_use + } + // The lease contains the effective UI/metadata decision. SDK-specific thinking budgets + // are never forwarded as if they were OpenAI effort values. + if (c.supportsReasoning && c.thinkingLevel && c.thinkingLevel !== 'off' && c.thinkingLevel !== 'auto') { + const effort = c.thinkingLevel + requireValue(!p.supportedEfforts?.length || p.supportedEfforts.includes(effort), 'Selected model does not support this reasoning effort') + if (responses) output.reasoning = { effort, summary: 'auto' } + else output.reasoning_effort = effort + } else if (c.supportsReasoning && c.thinkingLevel === 'off' && p.canDisableThinking !== false) { + if (responses) output.reasoning = { effort: 'none' } + else output.reasoning_effort = 'none' + } + return output +} + +export function usageFrom(value: Json | undefined, responses: boolean): Json { + if (!value) return {} + const result: Json = {} + const input = value[responses ? 'input_tokens' : 'prompt_tokens'], output = value[responses ? 'output_tokens' : 'completion_tokens'] + if (Number.isFinite(input)) result.input_tokens = input + if (Number.isFinite(output)) result.output_tokens = output + // OpenAI prompt/input totals already include cached tokens; subtract to match Anthropic. + const cached = value[responses ? 'input_tokens_details' : 'prompt_tokens_details']?.cached_tokens + if (Number.isFinite(cached) && Number.isFinite(input) && cached <= input) { result.input_tokens = input - cached; result.cache_read_input_tokens = cached } + return result +} +export function parseArguments(value: unknown): Json { + try { const parsed = JSON.parse(text(value, 'tool arguments')); requireValue(record(parsed), 'Tool arguments must be an object'); return parsed } + catch { throw new BridgeError(502, 'Upstream returned invalid tool arguments') } +} +export function stopReason(reason: unknown): string { + if (reason === 'length' || reason === 'max_output_tokens') return 'max_tokens' + if (reason === 'tool_calls' || reason === 'function_call') return 'tool_use' + if (reason === 'stop' || reason === 'completed' || reason === null) return 'end_turn' + throw new BridgeError(502, 'Upstream returned an unsupported finish reason') +} +export function convertResponse(data: Json, model: string, responses: boolean, context: ConversionContext): Json { + const content: Json[] = [] + let stop: string + if (responses) { + requireValue(Array.isArray(data.output), 'Invalid Responses output') + if (data.status !== 'completed' && data.status !== 'incomplete') throw new BridgeError(502, 'Upstream response did not complete') + for (const item of data.output) { + if (item.type === 'message') for (const part of item.content ?? []) { + if (part.type !== 'output_text') throw new BridgeError(502, 'Unsupported upstream output content') + content.push({ type: 'text', text: part.text }) + } + else if (item.type === 'function_call') content.push({ type: 'tool_use', id: item.call_id, name: item.name, input: parseArguments(item.arguments) }) + else if (item.type === 'reasoning') { + const thinking = (item.summary ?? []).map((x: Json) => { if (x.type !== 'summary_text') throw new BridgeError(502, 'Unsupported reasoning summary'); return x.text }).join('') + if (thinking) { content.push({ type: 'thinking', thinking }) } + // Hidden reasoning is retained with the complete assistant response, not a fabricated signature. + } else throw new BridgeError(502, 'Unsupported upstream output item') + } + stop = data.status === 'incomplete' ? stopReason(data.incomplete_details?.reason) : content.some(x => x.type === 'tool_use') ? 'tool_use' : 'end_turn' + } else { + const choice = data.choices?.[0], message = choice?.message + requireValue(record(message), 'Invalid Chat Completions response') + if (message.refusal) throw new BridgeError(502, 'Upstream refused the request') + if (message.reasoning_content) content.push({ type: 'thinking', thinking: message.reasoning_content }) + if (message.content) { requireValue(typeof message.content === 'string', 'Unsupported upstream message content'); content.push({ type: 'text', text: message.content }) } + for (const call of message.tool_calls ?? []) content.push({ type: 'tool_use', id: call.id, name: call.function.name, input: parseArguments(call.function.arguments) }) + stop = stopReason(choice.finish_reason) + } + if (responses && data.output.some((x: Json) => x.type === 'reasoning')) { + const key = replayKey(context.responsePrefix ?? '[]', content), previous = context.responsesHistory.get(key) + const saved = previous === null || previous && JSON.stringify(previous) !== JSON.stringify(data.output) ? null : data.output + if (!context.responsesHistory.has(key) && context.responsesHistory.size >= 1024) throw new BridgeError(502, 'Responses reasoning replay limit reached; start a new conversation') + if (Buffer.byteLength(JSON.stringify([...context.responsesHistory, [key, saved]])) > 16 * 1024 * 1024) throw new BridgeError(502, 'Responses reasoning history exceeds its storage limit; start a new conversation') + context.responsesHistory.set(key, saved) + } + return { id: data.id || 'msg_bridge', type: 'message', role: 'assistant', model, content, stop_reason: stop, stop_sequence: null, usage: usageFrom(data.usage, responses) } +} diff --git a/src/main/model-bridge/stream.ts b/src/main/model-bridge/stream.ts new file mode 100644 index 0000000..aa5bbaf --- /dev/null +++ b/src/main/model-bridge/stream.ts @@ -0,0 +1,156 @@ +import type { ServerResponse } from 'node:http' +import { BridgeError, convertResponse, historyKey, parseArguments, record, stopReason, usageFrom, type ConversionContext, type Json } from './protocol' + +/** Incremental UTF-8 and SSE framing; handles CRLF split across reads and multi-line data. */ +export async function* readSSE(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(), decoder = new TextDecoder() + let buffer = '', data: string[] = [], eventSize = 0 + const parse = (): Json | '[DONE]' | undefined => { + if (!data.length) return + const value = data.join('\n'); data = []; eventSize = 0 + if (value === '[DONE]') return value + try { const obj = JSON.parse(value); if (!record(obj)) throw new Error(); return obj } + catch { throw new BridgeError(502, 'Invalid upstream SSE JSON') } + } + try { + while (true) { + const { done, value } = await reader.read() + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }) + if (buffer.length > 8 * 1024 * 1024) throw new BridgeError(502, 'Upstream SSE event exceeds limit') + let newline: number + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ''); buffer = buffer.slice(newline + 1) + if (!line) { const event = parse(); if (event !== undefined) yield event } + else if (line.startsWith('data:')) { + eventSize += line.length + if (eventSize > 8 * 1024 * 1024) throw new BridgeError(502, 'Upstream SSE event exceeds limit') + data.push(line.slice(5).replace(/^ /, '')) + } + } + if (done) { + if (buffer.startsWith('data:')) data.push(buffer.slice(5).replace(/^ /, '').replace(/\r$/, '')) + const event = parse(); if (event !== undefined) yield event + break + } + } + } finally { await reader.cancel().catch(() => {}); reader.releaseLock() } +} + +class AnthropicEvents { + private next = 0 + private contentBytes = 0 + private blocks = new Map() + constructor(private response: ServerResponse, model: string) { + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' }) + this.send('message_start', { message: { id: `msg_bridge_${Date.now()}`, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: {} } }) + } + send(type: string, fields: Json = {}): void { if (this.response.writableLength > 8 * 1024 * 1024) throw new BridgeError(502, 'Downstream stream buffer exceeds limit'); if (!this.response.destroyed) this.response.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}\n\n`) } + add(key: string, type: 'text' | 'thinking' | 'tool_use', delta: string, meta: Json = {}): void { + let block = this.blocks.get(key) + if (!block) { + if (this.next >= 1024) throw new BridgeError(502, 'Upstream emitted too many content blocks') + block = { index: this.next++, type, value: '', closed: false, meta }; this.blocks.set(key, block) + this.send('content_block_start', { index: block.index, content_block: type === 'tool_use' ? { type, id: meta.id, name: meta.name, input: {} } : { type, [type]: '' } }) + } + if (block.closed) throw new BridgeError(502, 'Upstream resumed a closed content block') + this.contentBytes += Buffer.byteLength(delta) + if (this.contentBytes > 32 * 1024 * 1024) throw new BridgeError(502, 'Upstream stream content exceeds limit') + block.value += delta + if (block.value.length > 16 * 1024 * 1024) throw new BridgeError(502, 'Upstream content block exceeds limit') + if (delta) this.send('content_block_delta', { index: block.index, delta: type === 'tool_use' ? { type: 'input_json_delta', partial_json: delta } : { type: `${type}_delta`, [type]: delta } }) + } + close(key: string): void { + const block = this.blocks.get(key) + if (block && !block.closed) { + if (block.type === 'tool_use') parseArguments(block.value) + this.send('content_block_stop', { index: block.index }); block.closed = true + } + } + content(): Json[] { + return [...this.blocks.values()].map(b => b.type === 'tool_use' ? { type: b.type, id: b.meta.id, name: b.meta.name, input: parseArguments(b.value) } : { type: b.type, [b.type]: b.value }) + } + finish(stop: string, usage: Json): void { + for (const key of this.blocks.keys()) this.close(key) + this.send('message_delta', { delta: { stop_reason: stop, stop_sequence: null }, usage }) + this.send('message_stop'); this.response.end() + } +} + +export async function streamResponse(upstream: Response, response: ServerResponse, model: string, responses: boolean, context: ConversionContext): Promise { + if (!upstream.body) throw new BridgeError(502, 'Upstream has no stream body') + const events = new AnthropicEvents(response, model) + let completed = false, usage: Json = {}, finish: unknown + const tools = new Map() + const items = new Map() + try { + for await (const frame of readSSE(upstream.body)) { + if (frame === '[DONE]') { if (!responses && finish !== undefined) completed = true; break } + if (frame.error || frame.type === 'error' || frame.type === 'response.failed') throw new BridgeError(502, 'Upstream reported a streaming error') + if (!responses) { + if (frame.usage) usage = usageFrom(frame.usage, false) + for (const choice of frame.choices ?? []) { + if (choice.index !== undefined && choice.index !== 0) throw new BridgeError(502, 'Multiple upstream choices are unsupported') + const delta = choice.delta ?? {} + if (delta.refusal || delta.audio || delta.function_call) throw new BridgeError(502, 'Unsupported upstream stream content') + if (delta.reasoning_content) events.add('thinking', 'thinking', delta.reasoning_content) + if (delta.content) events.add('text', 'text', delta.content) + for (const call of delta.tool_calls ?? []) { + const index = call.index + if (!Number.isInteger(index)) throw new BridgeError(502, 'Invalid upstream tool index') + const tool = tools.get(index) ?? { id: '', name: '', args: '', started: false } + if (tool.started && (call.id && call.id !== tool.id || call.function?.name)) throw new BridgeError(502, 'Upstream changed an active tool identity') + if (call.id) tool.id = call.id + if (call.function?.name) tool.name += call.function.name + const args = call.function?.arguments ?? '' + tool.args += args + if (!tool.started && tool.id && tool.name && tool.args) { events.add(`tool:${index}`, 'tool_use', tool.args, tool); tool.started = true } + else if (tool.started) events.add(`tool:${index}`, 'tool_use', args, tool) + tools.set(index, tool) + } + if (choice.finish_reason !== null && choice.finish_reason !== undefined) finish = choice.finish_reason + } + } else { + const key = `item:${frame.output_index}` + switch (frame.type) { + case 'response.output_item.added': + items.set(frame.output_index, frame.item) + if (frame.item.type === 'function_call') events.add(key, 'tool_use', '', { id: frame.item.call_id, name: frame.item.name }) + else if (!['message', 'reasoning'].includes(frame.item.type)) throw new BridgeError(502, 'Unsupported upstream output item') + break + case 'response.output_text.delta': events.add(`${key}:text:${frame.content_index}`, 'text', frame.delta); break + case 'response.reasoning_summary_text.delta': events.add(`${key}:thinking`, 'thinking', frame.delta); break + case 'response.function_call_arguments.delta': { + const item = items.get(frame.output_index) + if (!item || item.type !== 'function_call') throw new BridgeError(502, 'Tool arguments without a tool item') + events.add(key, 'tool_use', frame.delta, { id: item.call_id, name: item.name }); break + } + case 'response.output_text.done': events.close(`${key}:text:${frame.content_index}`); break + case 'response.output_item.done': + if (frame.item.type === 'function_call') events.close(key) + if (frame.item.type === 'reasoning') events.close(`${key}:thinking`) + break + case 'response.refusal.delta': case 'response.refusal.done': throw new BridgeError(502, 'Upstream refused the request') + case 'response.completed': case 'response.incomplete': { + const converted = convertResponse(frame.response, model, true, context) + if (historyKey(events.content()) !== historyKey(converted.content)) throw new BridgeError(502, 'Upstream stream content does not match its completed response') + await context.persist?.() + finish = converted.stop_reason; usage = converted.usage; completed = true; break + } + case 'response.created': case 'response.in_progress': case 'response.queued': + case 'response.content_part.added': case 'response.content_part.done': + case 'response.reasoning_summary_part.added': case 'response.reasoning_summary_part.done': + case 'response.reasoning_summary_text.done': case 'response.function_call_arguments.done': break + default: throw new BridgeError(502, 'Unsupported Responses stream event') + } + if (completed) break + } + } + if (!completed) throw new BridgeError(502, 'Upstream stream ended before its terminal event') + for (const tool of tools.values()) if (!tool.started) throw new BridgeError(502, 'Incomplete upstream tool call') + events.finish(responses ? finish as string : stopReason(finish), usage) + } catch (error) { + try { events.send('error', { error: { type: 'api_error', message: error instanceof BridgeError ? error.message : 'Upstream stream failed' } }) } catch { response.destroy() } + response.end() + throw error + } +} diff --git a/src/main/model-connection.ts b/src/main/model-connection.ts index 4d8ee73..c82a8a6 100644 --- a/src/main/model-connection.ts +++ b/src/main/model-connection.ts @@ -1,24 +1,24 @@ import { createHash } from 'node:crypto' import { endpointCandidates, endpointUrl, modelHeaders, normalizeModelAddress } from '@shared/model-endpoint' -import type { DiscoveredModel, ModelDiscoveryResult, ModelEndpoint, ModelProtocol } from '@shared/models' +import type { DiscoveredModel, ModelDiscoveryResult, ModelEndpoint, ModelProtocol, SupportedModelApi } from '@shared/models' const discovered = new Map() const generations = new Map() -const identity = (address: string, protocol: ModelProtocol, key: string): string => createHash('sha256').update(JSON.stringify([normalizeModelAddress(address), protocol, key])).digest('hex') -export function cachedGeneration(address: string, protocol: ModelProtocol, key: string, model: string): ModelEndpoint | undefined { - const value = generations.get(identity(address, protocol, key) + ':' + model) +const identity = (address: string, protocol: ModelProtocol, key: string, apiPreference?: SupportedModelApi): string => createHash('sha256').update(JSON.stringify([normalizeModelAddress(address), protocol, apiPreference, key])).digest('hex') +export function cachedGeneration(address: string, protocol: ModelProtocol, key: string, model: string, apiPreference?: SupportedModelApi): ModelEndpoint | undefined { + const value = generations.get(identity(address, protocol, key, apiPreference) + ':' + model) return value && value.expires > Date.now() ? value.endpoint : undefined } -export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint): void { - generations.set(identity(address, protocol, key) + ':' + model, { endpoint, expires: Date.now() + 10 * 60 * 1000 }) +export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint, apiPreference?: SupportedModelApi): void { + generations.set(identity(address, protocol, key, apiPreference) + ':' + model, { endpoint, expires: Date.now() + 10 * 60 * 1000 }) if (generations.size > 100) generations.delete(generations.keys().next().value!) } -export function cachedConnection(address: string, protocol: ModelProtocol, key: string): ModelDiscoveryResult | undefined { - const value = discovered.get(identity(address, protocol, key)) +export function cachedConnection(address: string, protocol: ModelProtocol, key: string, apiPreference?: SupportedModelApi): ModelDiscoveryResult | undefined { + const value = discovered.get(identity(address, protocol, key, apiPreference)) return value && value.expires > Date.now() ? value.result : undefined } -export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult): void { - discovered.set(identity(address, protocol, key), { result, expires: Date.now() + 10 * 60 * 1000 }) +export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult, apiPreference?: SupportedModelApi): void { + discovered.set(identity(address, protocol, key, apiPreference), { result, expires: Date.now() + 10 * 60 * 1000 }) if (discovered.size > 100) discovered.delete(discovered.keys().next().value!) } @@ -60,12 +60,12 @@ function parseModels(value: unknown, endpoint: ModelEndpoint): { models: Discove return { models, next: typeof data.nextPageToken === 'string' ? data.nextPageToken : data.has_more && typeof data.last_id === 'string' ? data.last_id : undefined } } -export async function discoverModelConnection(address: string, protocol: ModelProtocol, key: string, refresh = false): Promise { - const cached = !refresh && cachedConnection(address, protocol, key) +export async function discoverModelConnection(address: string, protocol: ModelProtocol, key: string, refresh = false, apiPreference?: SupportedModelApi): Promise { + const cached = !refresh && cachedConnection(address, protocol, key, apiPreference) if (cached) return cached const attempts = new Set(), timeout = AbortSignal.timeout(15000) let status: number | undefined, authDenied = false - for (const endpoint of endpointCandidates(address, protocol)) { + for (const endpoint of endpointCandidates(address, protocol, apiPreference)) { const url = endpointUrl(endpoint, 'models'), headers = modelHeaders(endpoint.api, key), signature = url + JSON.stringify(headers) if (attempts.has(signature)) continue attempts.add(signature) @@ -97,7 +97,7 @@ export async function discoverModelConnection(address: string, protocol: ModelPr } const models = [...new Map(rows.slice(0, 5000).map(model => [model.id, model])).values()].sort((a, b) => a.id.localeCompare(b.id)) const result: ModelDiscoveryResult = { models, endpoint: { ...endpoint, checkedAt: Date.now(), verifiedBy: 'models' } } - rememberConnection(address, protocol, key, result) + rememberConnection(address, protocol, key, result, apiPreference) return result } throw Error(authDenied ? '鉴权失败,请检查 API Key 和协议' : `接口未提供可用的模型列表${status ? `(HTTP ${status})` : ''},可以手动输入模型名称`) diff --git a/src/main/models.ts b/src/main/models.ts index 6933f48..1707e67 100644 --- a/src/main/models.ts +++ b/src/main/models.ts @@ -4,6 +4,7 @@ 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 { modelProfileApi, supportedModelApi } from '@shared/model-support' 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' @@ -151,15 +152,16 @@ function normalize(input: CustomModelInput, existing?: StoredModel, testing = fa 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' + next.protocol = input.protocol ?? existing?.protocol ?? (input.apiPreference ? apiProtocol(input.apiPreference) : 'anthropic') + next.apiPreference = input.apiPreference ?? (next.protocol === existing?.protocol ? existing?.apiPreference : undefined) + if (!modelProfileApi(next)) throw new Error('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 协议') 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) + const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && next.apiPreference === existing?.apiPreference && !suppliedKey && !input.clearApiKey && authMode === (existing.authMode ?? 'api-key') ? existing.endpoint : undefined) if (configuredEndpoint) { - if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(configuredEndpoint.api)) throw new Error('协商协议无效') + if (!supportedModelApi(configuredEndpoint.api)) throw new Error('协商协议无效') const root = normalizeModelAddress(configuredEndpoint.baseUrl) - if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== 'anthropic') throw new Error('协商地址与填写的域名或协议不一致') + if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== next.protocol || next.apiPreference !== undefined && configuredEndpoint.api !== next.apiPreference) throw new Error('协商地址与填写的域名或协议不一致') next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt, ...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) } } @@ -229,7 +231,7 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise undefined) + const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key, false, next.apiPreference).catch(() => undefined) if (connection) next.endpoint = connection.endpoint } const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id) @@ -266,15 +268,23 @@ export async function resolveModel(id?: string | null): Promise model.id === targetId) + const stored = registry.models.find(model => model.id === targetId) + const model = stored ? { ...stored, protocol: stored.protocol ?? (stored.apiPreference ? apiProtocol(stored.apiPreference) : 'anthropic') } : undefined if (!model) throw new Error('所选模型不存在,请重新选择模型') + if (!modelProfileApi(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) + const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model, model.apiPreference) : undefined + // Old or hand-edited routes must not override an explicit API preference or protocol family. + const usable = (route?: ModelEndpoint): ModelEndpoint | undefined => route && supportedModelApi(route.api) + && apiProtocol(route.api) === model.protocol && (!model.apiPreference || route.api === model.apiPreference) + && new URL(route.baseUrl).origin === new URL(model.baseUrl).origin ? route : undefined + const savedEndpoint = usable(model.endpoint), generation = usable(verified) + const resolved = generation && generation.checkedAt >= (savedEndpoint?.checkedAt ?? 0) ? generation : savedEndpoint + ?? usable(cachedConnection(model.baseUrl, model.protocol, readKey(model).key, model.apiPreference)?.endpoint) + ?? modelEndpoint(model.baseUrl, model.apiPreference ?? inferModelApi(model.baseUrl, model.protocol)) return { profile, config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key, @@ -286,12 +296,12 @@ export async function resolveModel(id?: string | null): Promise { - if (!input || typeof input.baseUrl !== 'string' || (input.protocol !== undefined && input.protocol !== 'anthropic')) throw new Error('首版仅支持 Anthropic 协议') + if (!input || typeof input.baseUrl !== 'string' || !modelProfileApi(input)) throw new Error('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 协议') 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) + return discoverModelConnection(input.baseUrl, input.protocol ?? (input.apiPreference ? apiProtocol(input.apiPreference) : 'anthropic'), key, true, input.apiPreference) } /** Test only the user-specified origin, negotiating paths/protocols before reporting success. */ @@ -309,12 +319,12 @@ export async function testModel(input: CustomModelInput): Promise !!value) + ? { ...known, api: model.apiPreference ?? inferModelApi(model.baseUrl, model.protocol) } : known + const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol, model.apiPreference), known].filter((value): value is ModelEndpoint => !!value) : [{ ...legacy, checkedAt: 0 }] const tried = new Set(); let status = 0 for (const endpoint of candidates) { @@ -336,7 +346,7 @@ export async function testModel(input: CustomModelInput): Promise 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) + if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified, model.apiPreference) return { ...result(true, '连接测试成功'), endpoint: verified } } const messages: Record = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' } diff --git a/src/main/sessions.ts b/src/main/sessions.ts index e162897..e876b47 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -9,6 +9,7 @@ import { app } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' +import { modelBridgeHistoryDirectory } from './model-bridge/paths' import type { ChatMessage, SessionMeta } from '@shared/types' import { applyAssistantEvent } from '@shared/message-parts' import type { SessionContext } from '@shared/workspaces' @@ -238,6 +239,8 @@ export async function deleteSession(sessionId: string): Promise { const store = await load() store.sessions = store.sessions.filter((s) => s.id !== sessionId) await persist() + // The caller retires the kernel before deletion; no late response may recreate this cache. + await fs.rm(modelBridgeHistoryDirectory(app.getPath('userData'), sessionId), { recursive: true, force: true }) } /** Keep the abandoned branch on disk before replacing the active conversation. */ diff --git a/src/renderer/src/components/ModelSettingsPanel.tsx b/src/renderer/src/components/ModelSettingsPanel.tsx index 32f723f..c7d9e67 100644 --- a/src/renderer/src/components/ModelSettingsPanel.tsx +++ b/src/renderer/src/components/ModelSettingsPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' -import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, ModelProtocol } from '@shared/models' -import { normalizeModelAddress } from '@shared/model-endpoint' +import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, SupportedModelApi } from '@shared/models' +import { modelProfileApi } from '@shared/model-support' +import { apiProtocol, normalizeModelAddress } from '@shared/model-endpoint' import { resolveModelTokenLimits } from '@shared/model-token-limits' import { modelLabel as t, sourceModelIcons } from './model-settings-source' import SourceSelect, { type SourceSelectGroup } from './source-select/SourceSelect' @@ -53,7 +54,7 @@ export function parseModelTokenLimit(value: string): number | undefined { function defaultInput(providerId = 'custom'): CustomModelInput { return { id: '', name: '', providerId: 'custom', model: '', baseUrl: '', authMode: 'api-key', apiKey: '', supportsToolCall: false, supportsImages: false, supportsReasoning: false, onlyReasoning: false, - canDisableThinking: true, defaultEffort: '', supportedEfforts: [], protocol: 'anthropic', useCustomProtocol: false } + canDisableThinking: true, defaultEffort: '', supportedEfforts: [], protocol: 'anthropic', apiPreference: 'anthropic-messages', useCustomProtocol: false } } function Icon({ kind }: { kind: 'close' | 'edit' | 'delete' | 'link' | 'eye' | 'eyeClosed' | 'model' | 'down' | 'check' | 'loading' }): ReactNode { const source = sourceModelIcons[kind] @@ -92,7 +93,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate const update = (patch: Partial): void => { if ('apiKey' in patch) keyEdited.current = true Object.keys(patch).forEach(key => dirty.current.add(key)) - if (['baseUrl', 'apiKey', 'protocol', 'authMode'].some(key => key in patch)) { + if (['baseUrl', 'apiKey', 'protocol', 'apiPreference', 'authMode'].some(key => key in patch)) { queryGeneration.current++; setFetching(false); setRemoteModels([]); patch.endpoint = undefined } setInput((current) => ({ ...current, ...patch })); setError(''); setTestError('') @@ -105,7 +106,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate setModelListRequest(0) queryGeneration.current++; applyMetadata.current = !model; dirty.current.clear(); setRemoteModels([]); setFetching(false) const budget = model ? resolveModelTokenLimits(model) : undefined - setEditing(model ?? null); setInput(model ? { ...model, tokenBudgetVersion: 2, providerId: 'custom', protocol: 'anthropic', apiKey: '', previousId: model.id } : defaultInput()); setContextTokens(model ? String(budget?.contextWindowTokens ?? model.contextWindowTokens ?? model.maxInputTokens ?? '') : ''); setOutputTokens(model?.maxOutputTokens !== undefined ? String(model.maxOutputTokens) : '') + setEditing(model ?? null); setInput(model ? { ...model, tokenBudgetVersion: 2, providerId: 'custom', protocol: model.protocol ?? (model.apiPreference ? apiProtocol(model.apiPreference) : 'anthropic'), apiPreference: modelProfileApi(model), apiKey: '', previousId: model.id } : defaultInput()); setContextTokens(model ? String(budget?.contextWindowTokens ?? model.contextWindowTokens ?? model.maxInputTokens ?? '') : ''); setOutputTokens(model?.maxOutputTokens !== undefined ? String(model.maxOutputTokens) : '') setShowKey(false); setError(''); setTestError(''); setOverwrite(null); setOpen(true) if (model) setFailed((current) => { const next = new Set(current); next.delete(model.id); return next }) } @@ -144,7 +145,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate if (!isModelEndpointValid(input.baseUrl) || !input.baseUrl.trim()) { setError('请填写有效的接口域名'); return } const request = ++queryGeneration.current; setFetching(true); setError('') try { - const result = await window.ycode.models.fetch({ baseUrl: input.baseUrl, apiKey: input.apiKey, previousId: editing?.id, protocol: input.protocol, authMode: credentialMode() }) + const result = await window.ycode.models.fetch({ baseUrl: input.baseUrl, apiKey: input.apiKey, previousId: editing?.id, protocol: input.protocol, apiPreference: input.apiPreference, authMode: credentialMode() }) if (request !== queryGeneration.current) return setRemoteModels(result.models); setInput(current => ({ ...current, endpoint: current.endpoint?.verifiedBy === 'generation' ? current.endpoint : result.endpoint })) setNotice(result.models.length ? `已获取 ${result.models.length} 个模型,可选择或手动输入` : '接口返回空列表,可以手动输入模型名称') @@ -179,7 +180,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate const request = queryGeneration.current try { const connectionChanged = !editing || data.model !== editing.model || normalizeModelAddress(data.baseUrl) !== normalizeModelAddress(editing.baseUrl) - || data.protocol !== (editing.protocol ?? 'anthropic') || !!data.apiKey || data.authMode !== (editing.authMode ?? 'api-key') + || data.apiPreference !== modelProfileApi(editing) || data.protocol !== (editing.protocol ?? 'anthropic') || !!data.apiKey || data.authMode !== (editing.authMode ?? 'api-key') if (connectionChanged && data.endpoint?.verifiedBy !== 'generation') { const result = await onTest(data) if (request !== queryGeneration.current) return @@ -230,7 +231,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
{error &&
{error}
}
undefined} options={providerOptions} fullWidth ariaLabel="供应商" className={cx('provider-select')} /> -
update({ protocol: value as ModelProtocol })} options={[{ value: 'anthropic', label: 'Anthropic Messages' }]} ariaLabel="接口协议" fullWidth />
+
update({ protocol: apiProtocol(value as SupportedModelApi), apiPreference: value as SupportedModelApi })} options={[{ value: 'anthropic-messages', label: 'Anthropic Messages' }, { value: 'openai-completions', label: 'OpenAI Chat Completions' }, { value: 'openai-responses', label: 'OpenAI Responses' }]} ariaLabel="接口协议" fullWidth />
update({ baseUrl: event.target.value })} placeholder="api.example.com 或 https://api.example.com" />
填写域名即可,兼容已有完整地址。保存新接口时会发送简短测试,自动验证协议和路径。
diff --git a/src/renderer/src/workbuddy/composer/model-selector/SourceModelSelector.tsx b/src/renderer/src/workbuddy/composer/model-selector/SourceModelSelector.tsx index e1ad067..9b661ef 100644 --- a/src/renderer/src/workbuddy/composer/model-selector/SourceModelSelector.tsx +++ b/src/renderer/src/workbuddy/composer/model-selector/SourceModelSelector.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CustomModelProfile } from '@shared/models' import type { ThinkingLevel } from '@shared/workspaces' +import { isSupportedModelProfile, modelProfileApi } from '@shared/model-support' import { modelThinkingLevel } from '@shared/model-thinking' import { ModelSelectorImpl, ModelSelectorRuntimeProvider, type ModelEffort, type ModelOption, type ModelSelection } from './source.mjs' import { SourceHeaderEditIcon } from '../../../components/SourceChatHeaderIcons' @@ -21,15 +22,15 @@ function toOption(model: CustomModelProfile): ModelOption { const efforts = (model.supportedEfforts ?? []).filter((value): value is ModelEffort => (EFFORTS as string[]).includes(value)) return { id: model.id, - name: (model.name || model.model || model.id) + (model.protocol === 'anthropic' ? '' : '(不支持)'), + name: (model.name || model.model || model.id) + (isSupportedModelProfile(model) ? '' : '(不支持)'), // No icon/iconUrl: the recovered resolveModelIcon then falls back to the name-based brand registry, // which is what the original conversation selector shows for these models. supportsReasoning: !!model.supportsReasoning, onlyReasoning: !!model.onlyReasoning, canDisableThinking: model.canDisableThinking !== false, ...(model.defaultEffort && (EFFORTS as string[]).includes(model.defaultEffort) ? { defaultEffort: model.defaultEffort as ModelEffort } : {}), - // An unlisted effort set falls back to the levels this host can persist. - supportedEfforts: model.metadata ? efforts : efforts.length ? efforts : EFFORTS, + // Preserve the historical Anthropic UI; new APIs only advertise declared effort levels. + supportedEfforts: modelProfileApi(model) === 'anthropic-messages' && !model.metadata && !efforts.length ? EFFORTS : efforts, maxInputTokens: model.contextWindowTokens ?? model.maxInputTokens } } @@ -68,8 +69,8 @@ export default function SourceModelSelector({ models, selectedId, label, disable useEffect(() => { setDraft(selection) }, [selection]) const commit = useCallback(async (next: ModelSelection): Promise => { const target = models.find((model) => model.id === next.id) - if (!target || target.protocol !== 'anthropic') { - setError('首版仅支持 Anthropic/Claude 兼容模型') + if (!target || !isSupportedModelProfile(target)) { + setError('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 模型') return } if (lock.current) return diff --git a/src/shared/model-endpoint.ts b/src/shared/model-endpoint.ts index adefb97..5f07274 100644 --- a/src/shared/model-endpoint.ts +++ b/src/shared/model-endpoint.ts @@ -1,4 +1,4 @@ -import type { ModelApi, ModelEndpoint, ModelProtocol } from './models' +import type { ModelApi, ModelEndpoint, ModelProtocol, SupportedModelApi } from './models' export function normalizeModelAddress(raw: string): string { const value = raw.trim() @@ -37,9 +37,9 @@ export function endpointUrl(endpoint: ModelEndpoint, operation: 'models' | 'gene return url.href } -export function endpointCandidates(address: string, protocol: ModelProtocol = 'auto'): ModelEndpoint[] { +export function endpointCandidates(address: string, protocol: ModelProtocol = 'auto', apiPreference?: SupportedModelApi): ModelEndpoint[] { const normalized = normalizeModelAddress(address), inferred = inferModelApi(normalized, protocol) - const apis: ModelApi[] = protocol === 'auto' ? [inferred, 'openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai'] + const apis: ModelApi[] = apiPreference ? [apiPreference] : protocol === 'auto' ? [inferred, 'openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai'] : protocol === 'openai' ? [inferred, inferred === 'openai-responses' ? 'openai-completions' : 'openai-responses'] : [inferred] const seen = new Set(), result: ModelEndpoint[] = [] for (const api of apis) { diff --git a/src/shared/model-support.ts b/src/shared/model-support.ts new file mode 100644 index 0000000..a215dd6 --- /dev/null +++ b/src/shared/model-support.ts @@ -0,0 +1,21 @@ +import type { CustomModelProfile, SupportedModelApi } from './models' +import { apiProtocol, inferModelApi } from './model-endpoint' + +export function supportedModelApi(value: unknown): value is SupportedModelApi { + return value === 'anthropic-messages' || value === 'openai-completions' || value === 'openai-responses' +} + +/** Missing protocol is the historical Anthropic configuration, not automatic negotiation. */ +export function modelProfileApi(profile: Pick): SupportedModelApi | undefined { + const protocol = profile.protocol ?? (profile.apiPreference ? apiProtocol(profile.apiPreference) : 'anthropic') + if (protocol !== 'anthropic' && protocol !== 'openai') return undefined + if (profile.apiPreference !== undefined) return supportedModelApi(profile.apiPreference) && apiProtocol(profile.apiPreference) === protocol ? profile.apiPreference : undefined + if (profile.endpoint && supportedModelApi(profile.endpoint.api) && apiProtocol(profile.endpoint.api) === protocol) return profile.endpoint.api + if (protocol === 'anthropic') return 'anthropic-messages' + const api = inferModelApi(profile.baseUrl, protocol) + return supportedModelApi(api) ? api : undefined +} + +export function isSupportedModelProfile(profile: Pick): boolean { + try { return modelProfileApi(profile) !== undefined } catch { return false } +} diff --git a/src/shared/models.ts b/src/shared/models.ts index fdace20..80bfca4 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -2,6 +2,7 @@ import type { GatewayConfig } from './types' export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini' export type ModelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai' +export type SupportedModelApi = Exclude export interface ModelEndpoint { api: ModelApi; baseUrl: string; checkedAt: number; verifiedBy?: 'models' | 'generation' } export interface ModelPrice { input: number; output: number; cacheRead?: number; cacheWrite?: number; minContext?: number; maxContext?: number; crossedTiers?: boolean } export interface ModelReasoningOption { type: 'effort' | 'budget_tokens' | 'toggle'; values?: string[]; min?: number; max?: number } @@ -16,7 +17,7 @@ export interface ModelMetadata { price?: ModelPrice; temperature?: boolean } export interface ModelMetadataStatus { fetchedAt: string; sha256: string; providers: number; models: number; source: string } -export interface ModelDiscoveryInput { baseUrl: string; protocol?: ModelProtocol; apiKey?: string; previousId?: string; authMode?: 'api-key' | 'none' } +export interface ModelDiscoveryInput { baseUrl: string; protocol?: ModelProtocol; apiPreference?: SupportedModelApi; apiKey?: string; previousId?: string; authMode?: 'api-key' | 'none' } export interface DiscoveredModel { id: string; name: string } export interface ModelDiscoveryResult { models: DiscoveredModel[]; endpoint: ModelEndpoint } @@ -30,6 +31,7 @@ export interface CustomModelProfile { keyStorage: 'encrypted' | 'plaintext' | 'none' authMode?: 'api-key' | 'none' protocol?: ModelProtocol + apiPreference?: SupportedModelApi endpoint?: ModelEndpoint metadata?: ModelMetadata contextWindowTokens?: number