feat(models): bridge Chat Completions and Responses through Claude runtime
This commit is contained in:
1 parent
8fcfb69408
commit
d773961168
21 files changed
+1346
-68
No files matched your search
@@ -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 视觉验收尚未进行。结果和剩余限制在交付文档单独登记。
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test:core",
|
"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: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": "node scripts/run-alignment-regression.mjs",
|
||||||
"test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom",
|
"test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom",
|
||||||
"test:image-lifecycle": "node scripts/test-image-lifecycle.mjs",
|
"test:image-lifecycle": "node scripts/test-image-lifecycle.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 })
|
||||||
|
}
|
||||||
@@ -7,11 +7,19 @@ import { pathToFileURL } from 'node:url'
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import type { CustomModelInput } from '../src/shared/models'
|
import type { CustomModelInput } from '../src/shared/models'
|
||||||
import type * as Registry from '../src/main/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'
|
import { endpointCandidates, inferModelApi } from '../src/shared/model-endpoint'
|
||||||
|
|
||||||
const STATE_KEY = '__YCODE_MODEL_REGISTRY_TEST__'
|
const STATE_KEY = '__YCODE_MODEL_REGISTRY_TEST__'
|
||||||
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
||||||
const previous = globals[STATE_KEY]
|
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 temp = await mkdtemp(join(tmpdir(), 'ycode-model-registry-'))
|
||||||
const state = { userData: join(temp, 'userData'), encryption: true }
|
const state = { userData: join(temp, 'userData'), encryption: true }
|
||||||
globals[STATE_KEY] = state
|
globals[STATE_KEY] = state
|
||||||
@@ -23,6 +31,7 @@ const legacy = { baseUrl: 'https://legacy.example/v1', model: 'legacy-model', te
|
|||||||
const reload = (): Promise<typeof Registry> => import(`${pathToFileURL(outfile).href}?instance=${++importNumber}`)
|
const reload = (): Promise<typeof Registry> => import(`${pathToFileURL(outfile).href}?instance=${++importNumber}`)
|
||||||
const input = (id: string, extra: Partial<CustomModelInput> = {}): CustomModelInput => ({
|
const input = (id: string, extra: Partial<CustomModelInput> = {}): CustomModelInput => ({
|
||||||
id, name: `Name ${id}`, providerId: 'custom', model: `model-${id}`, baseUrl: 'https://provider.example/v1',
|
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
|
apiKey: ' new-key\n\t with-space ', supportsToolCall: true, supportsImages: true, ...extra
|
||||||
})
|
})
|
||||||
let server: ReturnType<typeof createServer> | undefined
|
let server: ReturnType<typeof createServer> | undefined
|
||||||
@@ -120,6 +129,19 @@ try {
|
|||||||
assert.equal(inferModelApi('https://api.anthropic.com', 'auto'), 'anthropic-messages')
|
assert.equal(inferModelApi('https://api.anthropic.com', 'auto'), 'anthropic-messages')
|
||||||
assert.equal(inferModelApi('https://generativelanguage.googleapis.com', 'auto'), 'google-generative-ai')
|
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 responseMode = 'success'
|
||||||
let responsePayload: unknown = undefined
|
let responsePayload: unknown = undefined
|
||||||
const requests: Array<{ path: string; auth?: string; data: Record<string, unknown> }> = []
|
const requests: Array<{ path: string; auth?: string; data: Record<string, unknown> }> = []
|
||||||
@@ -127,8 +149,9 @@ try {
|
|||||||
let body = ''
|
let body = ''
|
||||||
request.on('data', chunk => { body += chunk })
|
request.on('data', chunk => { body += chunk })
|
||||||
request.on('end', () => {
|
request.on('end', () => {
|
||||||
requests.push({ path: request.url ?? '', auth: request.headers.authorization, data: JSON.parse(body) as Record<string, unknown> })
|
requests.push({ path: request.url ?? '', auth: request.headers.authorization, data: JSON.parse(body || '{}') as Record<string, unknown> })
|
||||||
response.setHeader('content-type', 'application/json')
|
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 === '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 === 'not-found') { response.statusCode = 404; response.end(JSON.stringify({ error: 'private-endpoint-echo' })); return }
|
||||||
if (responseMode === 'malformed') { response.end('not JSON'); return }
|
if (responseMode === 'malformed') { response.end('not JSON'); return }
|
||||||
@@ -150,6 +173,29 @@ try {
|
|||||||
const address = server.address()
|
const address = server.address()
|
||||||
assert.ok(address && typeof address === 'object')
|
assert.ok(address && typeof address === 'object')
|
||||||
const endpoint = `http://127.0.0.1:${address.port}/v1`
|
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 testInput = input('unsaved-test', { baseUrl: endpoint })
|
||||||
const beforeTest = await readFile(modelsFile, 'utf8')
|
const beforeTest = await readFile(modelsFile, 'utf8')
|
||||||
const successful = await registry.testModel(testInput)
|
const successful = await registry.testModel(testInput)
|
||||||
@@ -224,15 +270,15 @@ try {
|
|||||||
}
|
}
|
||||||
responsePayload = undefined
|
responsePayload = undefined
|
||||||
responseMode = 'negotiate-both'
|
responseMode = 'negotiate-both'
|
||||||
for (const protocol of ['openai', 'auto'] as const) {
|
for (const protocol of ['openai'] as const) {
|
||||||
const start = requests.length
|
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.success, true)
|
||||||
assert.equal(negotiated.endpoint?.api, 'openai-responses', 'Responses wins when both OpenAI APIs are supported')
|
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')
|
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 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 startRetest = requests.length
|
||||||
const retested = await registry.testModel(previousInput)
|
const retested = await registry.testModel(previousInput)
|
||||||
assert.equal(retested.endpoint?.api, 'openai-responses', 're-testing a stored Chat route follows the new priority')
|
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')
|
assert.equal(requests.at(-1)?.path, '/v1/chat/completions')
|
||||||
responseMode = 'negotiate-chat-only'
|
responseMode = 'negotiate-chat-only'
|
||||||
const startFallback = requests.length
|
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.success, true)
|
||||||
assert.equal(chatFallback.endpoint?.api, 'openai-completions')
|
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')
|
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'
|
responseMode = 'auth'
|
||||||
const authFailure = await registry.testModel(testInput)
|
const authFailure = await registry.testModel(testInput)
|
||||||
assert.equal(authFailure.success, false)
|
assert.equal(authFailure.success, false)
|
||||||
@@ -283,7 +339,7 @@ try {
|
|||||||
try {
|
try {
|
||||||
assert.match((await registry.testModel(testInput)).message, /超时/)
|
assert.match((await registry.testModel(testInput)).message, /超时/)
|
||||||
} finally { globalThis.fetch = originalFetch }
|
} 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' },
|
endpoint: { api: 'openai-responses', baseUrl: endpoint, checkedAt: Date.now(), verifiedBy: 'generation' },
|
||||||
maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 272000 })
|
maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 272000 })
|
||||||
await registry.saveModel(largeModel)
|
await registry.saveModel(largeModel)
|
||||||
@@ -333,9 +389,10 @@ try {
|
|||||||
registry = await reload()
|
registry = await reload()
|
||||||
await assert.rejects(() => registry.listModels(), /损坏/)
|
await assert.rejects(() => registry.listModels(), /损坏/)
|
||||||
assert.equal(await readFile(modelsFile, 'utf8'), '{malformed', 'corrupt registries must never be silently overwritten')
|
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 {
|
} finally {
|
||||||
if (server) await new Promise<void>(resolve => server!.close(() => resolve()))
|
if (server) await new Promise<void>(resolve => server!.close(() => resolve()))
|
||||||
|
globalThis.fetch = realFetch
|
||||||
globals[STATE_KEY] = previous
|
globals[STATE_KEY] = previous
|
||||||
await rm(temp, { recursive: true, force: true })
|
await rm(temp, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -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<void>(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<void>(resolve => upstream.close(() => resolve())) }
|
||||||
|
console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { modelBridgeHistoryDirectory } from '../src/main/model-bridge/paths'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
@@ -8,6 +9,7 @@ import { build } from 'esbuild'
|
|||||||
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
||||||
|
|
||||||
interface SessionStoreApi {
|
interface SessionStoreApi {
|
||||||
|
deleteSession(sessionId: string): Promise<void>
|
||||||
listSessions(): Promise<SessionMeta[]>
|
listSessions(): Promise<SessionMeta[]>
|
||||||
createSession(title?: string): Promise<SessionMeta>
|
createSession(title?: string): Promise<SessionMeta>
|
||||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||||
@@ -265,6 +267,16 @@ async function main(): Promise<void> {
|
|||||||
assert.deepEqual(afterRecovery, inMemory,
|
assert.deepEqual(afterRecovery, inMemory,
|
||||||
'session:messages should remain consistent after the write queue recovers')
|
'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')
|
console.log('session-store-regression: all checks passed')
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete globals[STATE_KEY]
|
if (previous === undefined) delete globals[STATE_KEY]
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { privateRuntimeEnvironment } from '../../private-runtimes'
|
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 { app } from 'electron'
|
||||||
import { existsSync, mkdirSync } from 'node:fs'
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
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 = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLAUDE_CONFIG_DIR: join(app.getPath('userData'), 'claude-code'),
|
CLAUDE_CONFIG_DIR: join(app.getPath('userData'), 'claude-code'),
|
||||||
@@ -248,6 +252,10 @@ function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv {
|
|||||||
} else {
|
} else {
|
||||||
delete env.ANTHROPIC_API_KEY
|
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
|
const contextWindow = model.profile.contextWindowTokens ?? model.profile.maxInputTokens
|
||||||
if (contextWindow && Number.isFinite(contextWindow) && contextWindow > 0) {
|
if (contextWindow && Number.isFinite(contextWindow) && contextWindow > 0) {
|
||||||
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.trunc(contextWindow))
|
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.trunc(contextWindow))
|
||||||
@@ -328,6 +336,8 @@ export class ClaudeKernel {
|
|||||||
private consuming: Promise<void> | null = null
|
private consuming: Promise<void> | null = null
|
||||||
private active: ActiveTurn | null = null
|
private active: ActiveTurn | null = null
|
||||||
private starting = false
|
private starting = false
|
||||||
|
private startup: Promise<void> | null = null
|
||||||
|
private bridge: Awaited<ReturnType<typeof acquireModelBridge>> | null = null
|
||||||
private permissionUpdates: Promise<void> = Promise.resolve()
|
private permissionUpdates: Promise<void> = Promise.resolve()
|
||||||
private policyTransitions = 0
|
private policyTransitions = 0
|
||||||
private policyRevision = 0
|
private policyRevision = 0
|
||||||
@@ -345,6 +355,8 @@ export class ClaudeKernel {
|
|||||||
private agentMode: AgentMode
|
private agentMode: AgentMode
|
||||||
|
|
||||||
constructor(private readonly options: ClaudeKernelOptions) {
|
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 () => {
|
this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => {
|
||||||
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
|
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
|
||||||
} })
|
} })
|
||||||
@@ -363,7 +375,23 @@ export class ClaudeKernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureStarted(): Promise<void> {
|
async ensureStarted(): Promise<void> {
|
||||||
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<void> {
|
||||||
await this.permissionUpdates
|
await this.permissionUpdates
|
||||||
await this.draining
|
await this.draining
|
||||||
if (this.policyError) throw this.policyError
|
if (this.policyError) throw this.policyError
|
||||||
@@ -380,6 +408,24 @@ export class ClaudeKernel {
|
|||||||
const newSessionId = !resumeCurrent && this.options.forceNewSession ? randomUUID() : undefined
|
const newSessionId = !resumeCurrent && this.options.forceNewSession ? randomUUID() : undefined
|
||||||
const requestedSessionId = forkSessionId ?? newSessionId
|
const requestedSessionId = forkSessionId ?? newSessionId
|
||||||
if (requestedSessionId) this.currentSessionId = requestedSessionId
|
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({
|
const instance = query({
|
||||||
prompt: input,
|
prompt: input,
|
||||||
options: {
|
options: {
|
||||||
@@ -524,7 +570,9 @@ export class ClaudeKernel {
|
|||||||
? { resume: this.options.resumeSessionId }
|
? { resume: this.options.resumeSessionId }
|
||||||
: { sessionId: newSessionId ?? this.currentSessionId }),
|
: { sessionId: newSessionId ?? this.currentSessionId }),
|
||||||
stderr: (data) => {
|
stderr: (data) => {
|
||||||
if (data.trim()) console.error(`[claude] ${data.trim()}`)
|
const safe = [this.options.model.config.apiKey, this.bridge?.apiKey].filter(Boolean)
|
||||||
|
.reduce<string>((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.processAbort = processAbort
|
||||||
this.instance = instance
|
this.instance = instance
|
||||||
this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode)
|
this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode)
|
||||||
const consuming = this.consume(instance)
|
const consuming = this.consume(instance, this.bridge)
|
||||||
this.consuming = consuming
|
this.consuming = consuming
|
||||||
void consuming.finally(() => {
|
void consuming.finally(() => {
|
||||||
if (this.consuming === consuming) this.consuming = null
|
if (this.consuming === consuming) this.consuming = null
|
||||||
@@ -553,13 +601,17 @@ export class ClaudeKernel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async consume(instance: Query): Promise<void> {
|
private async consume(instance: Query, bridge: Awaited<ReturnType<typeof acquireModelBridge>> | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
for await (const message of instance) await this.handleMessage(message)
|
for await (const message of instance) await this.handleMessage(message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.failActive(error instanceof Error ? error : new Error(String(error)))
|
this.failActive(error instanceof Error ? error : new Error(String(error)))
|
||||||
if (!this.closed) console.error('Claude Code process stopped', error)
|
if (!this.closed) console.error('Claude Code process stopped', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (bridge && this.bridge === bridge) {
|
||||||
|
this.bridge = null
|
||||||
|
await bridge.close()
|
||||||
|
}
|
||||||
this.failActive(new Error('Claude Code 进程已退出'))
|
this.failActive(new Error('Claude Code 进程已退出'))
|
||||||
if (this.instance === instance) {
|
if (this.instance === instance) {
|
||||||
instance.close()
|
instance.close()
|
||||||
@@ -951,7 +1003,14 @@ export class ClaudeKernel {
|
|||||||
if (this.policyError) throw this.policyError
|
if (this.policyError) throw this.policyError
|
||||||
const input = this.input
|
const input = this.input
|
||||||
if (!input) throw new Error('Claude Code 输入流未就绪')
|
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<ClaudeTurnResult>((resolve, reject) => {
|
return new Promise<ClaudeTurnResult>((resolve, reject) => {
|
||||||
const active: ActiveTurn = {
|
const active: ActiveTurn = {
|
||||||
request,
|
request,
|
||||||
@@ -1180,6 +1239,9 @@ export class ClaudeKernel {
|
|||||||
this.input?.close()
|
this.input?.close()
|
||||||
this.input = null
|
this.input = null
|
||||||
this.processAbort?.abort()
|
this.processAbort?.abort()
|
||||||
|
const bridge = this.bridge
|
||||||
|
this.bridge = null
|
||||||
|
await bridge?.close()
|
||||||
const instance = this.instance
|
const instance = this.instance
|
||||||
try { await this.instance?.interrupt() } catch { /* the process may already be gone */ }
|
try { await this.instance?.interrupt() } catch { /* the process may already be gone */ }
|
||||||
instance?.close()
|
instance?.close()
|
||||||
@@ -1219,7 +1281,21 @@ function kernelFingerprint(options: ClaudeKernelOptions): string {
|
|||||||
modelId: options.model.profile.id,
|
modelId: options.model.profile.id,
|
||||||
model: options.model.config.model,
|
model: options.model.config.model,
|
||||||
baseUrl: options.model.config.baseUrl,
|
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,
|
authMode: options.model.profile.authMode,
|
||||||
thinkingLevel: options.thinkingLevel,
|
thinkingLevel: options.thinkingLevel,
|
||||||
systemContext: options.systemContext
|
systemContext: options.systemContext
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isSupportedModelProfile } from '@shared/model-support'
|
||||||
/** Main-process chat turns backed by one persistent Claude Code kernel per session. */
|
/** Main-process chat turns backed by one persistent Claude Code kernel per session. */
|
||||||
import type { WebContents } from 'electron'
|
import type { WebContents } from 'electron'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@@ -85,10 +86,7 @@ export async function disposeSessionKernel(sessionId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedClaudeModel(model: Awaited<ReturnType<typeof models.resolveModel>>): boolean {
|
function isSupportedClaudeModel(model: Awaited<ReturnType<typeof models.resolveModel>>): boolean {
|
||||||
if (!model) return false
|
return Boolean(model && isSupportedModelProfile(model.profile))
|
||||||
return model.config.api === 'anthropic-messages' ||
|
|
||||||
model.profile.protocol === 'anthropic' ||
|
|
||||||
/\/messages\/?$/i.test(model.config.baseUrl ?? '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxInputTokens?: number } }): number | undefined {
|
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 })
|
if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode })
|
||||||
const selectedModel = await models.resolveModel(context.modelId)
|
const selectedModel = await models.resolveModel(context.modelId)
|
||||||
if (!selectedModel) throw new Error('请先添加并选择模型')
|
if (!selectedModel) throw new Error('请先添加并选择模型')
|
||||||
if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
|
if (!isSupportedClaudeModel(selectedModel)) throw new Error('当前模型协议尚未支持')
|
||||||
const workspaceRoot = await sessionRoot(sessionId)
|
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.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 服务引用无效')
|
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 context = await sessions.getSessionContext(sessionId)
|
||||||
const model = await models.resolveModel(context.modelId)
|
const model = await models.resolveModel(context.modelId)
|
||||||
if (!model) throw new Error('请先添加并选择模型')
|
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)
|
const claudeSession = await sessions.getClaudeSession(sessionId)
|
||||||
if (!claudeSession.sessionId) throw new Error('Claude 会话尚未建立,无法回退文件')
|
if (!claudeSession.sessionId) throw new Error('Claude 会话尚未建立,无法回退文件')
|
||||||
const workspaceRoot = await sessionRoot(sessionId)
|
const workspaceRoot = await sessionRoot(sessionId)
|
||||||
|
|||||||
@@ -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<string, Promise<void>>()
|
||||||
|
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<Map<string, Json[] | null>> {
|
||||||
|
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<string, Json[] | null>()
|
||||||
|
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<ConversionContext> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Json> {
|
||||||
|
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<Json> {
|
||||||
|
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<void> }
|
||||||
|
|
||||||
|
/** 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<ModelBridgeLease> {
|
||||||
|
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<AbortController>()
|
||||||
|
const requests = new Set<Promise<void>>()
|
||||||
|
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<void>(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<void>((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<void> | 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<void>(resolve => server.close(() => resolve()))
|
||||||
|
server.closeAllConnections()
|
||||||
|
await Promise.all([stopped, ...requests])
|
||||||
|
context.responsesHistory.clear()
|
||||||
|
})()
|
||||||
|
} }
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
@@ -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<string, any>
|
||||||
|
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<string, Json[] | null>; persist?: () => Promise<void>; 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) }
|
||||||
|
}
|
||||||
@@ -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<Uint8Array>): AsyncGenerator<Json | '[DONE]'> {
|
||||||
|
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<string, { index: number; type: string; value: string; closed: boolean; meta: Json }>()
|
||||||
|
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<void> {
|
||||||
|
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<number, { id: string; name: string; args: string; started: boolean }>()
|
||||||
|
const items = new Map<number, Json>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { endpointCandidates, endpointUrl, modelHeaders, normalizeModelAddress } from '@shared/model-endpoint'
|
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<string, { result: ModelDiscoveryResult; expires: number }>()
|
const discovered = new Map<string, { result: ModelDiscoveryResult; expires: number }>()
|
||||||
const generations = new Map<string, { endpoint: ModelEndpoint; expires: number }>()
|
const generations = new Map<string, { endpoint: ModelEndpoint; expires: number }>()
|
||||||
const identity = (address: string, protocol: ModelProtocol, key: string): string => createHash('sha256').update(JSON.stringify([normalizeModelAddress(address), protocol, key])).digest('hex')
|
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): ModelEndpoint | undefined {
|
export function cachedGeneration(address: string, protocol: ModelProtocol, key: string, model: string, apiPreference?: SupportedModelApi): ModelEndpoint | undefined {
|
||||||
const value = generations.get(identity(address, protocol, key) + ':' + model)
|
const value = generations.get(identity(address, protocol, key, apiPreference) + ':' + model)
|
||||||
return value && value.expires > Date.now() ? value.endpoint : undefined
|
return value && value.expires > Date.now() ? value.endpoint : undefined
|
||||||
}
|
}
|
||||||
export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint): void {
|
export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint, apiPreference?: SupportedModelApi): void {
|
||||||
generations.set(identity(address, protocol, key) + ':' + model, { endpoint, expires: Date.now() + 10 * 60 * 1000 })
|
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!)
|
if (generations.size > 100) generations.delete(generations.keys().next().value!)
|
||||||
}
|
}
|
||||||
export function cachedConnection(address: string, protocol: ModelProtocol, key: string): ModelDiscoveryResult | undefined {
|
export function cachedConnection(address: string, protocol: ModelProtocol, key: string, apiPreference?: SupportedModelApi): ModelDiscoveryResult | undefined {
|
||||||
const value = discovered.get(identity(address, protocol, key))
|
const value = discovered.get(identity(address, protocol, key, apiPreference))
|
||||||
return value && value.expires > Date.now() ? value.result : undefined
|
return value && value.expires > Date.now() ? value.result : undefined
|
||||||
}
|
}
|
||||||
export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult): void {
|
export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult, apiPreference?: SupportedModelApi): void {
|
||||||
discovered.set(identity(address, protocol, key), { result, expires: Date.now() + 10 * 60 * 1000 })
|
discovered.set(identity(address, protocol, key, apiPreference), { result, expires: Date.now() + 10 * 60 * 1000 })
|
||||||
if (discovered.size > 100) discovered.delete(discovered.keys().next().value!)
|
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 }
|
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<ModelDiscoveryResult> {
|
export async function discoverModelConnection(address: string, protocol: ModelProtocol, key: string, refresh = false, apiPreference?: SupportedModelApi): Promise<ModelDiscoveryResult> {
|
||||||
const cached = !refresh && cachedConnection(address, protocol, key)
|
const cached = !refresh && cachedConnection(address, protocol, key, apiPreference)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const attempts = new Set<string>(), timeout = AbortSignal.timeout(15000)
|
const attempts = new Set<string>(), timeout = AbortSignal.timeout(15000)
|
||||||
let status: number | undefined, authDenied = false
|
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)
|
const url = endpointUrl(endpoint, 'models'), headers = modelHeaders(endpoint.api, key), signature = url + JSON.stringify(headers)
|
||||||
if (attempts.has(signature)) continue
|
if (attempts.has(signature)) continue
|
||||||
attempts.add(signature)
|
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 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' } }
|
const result: ModelDiscoveryResult = { models, endpoint: { ...endpoint, checkedAt: Date.now(), verifiedBy: 'models' } }
|
||||||
rememberConnection(address, protocol, key, result)
|
rememberConnection(address, protocol, key, result, apiPreference)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
throw Error(authDenied ? '鉴权失败,请检查 API Key 和协议' : `接口未提供可用的模型列表${status ? `(HTTP ${status})` : ''},可以手动输入模型名称`)
|
throw Error(authDenied ? '鉴权失败,请检查 API Key 和协议' : `接口未提供可用的模型列表${status ? `(HTTP ${status})` : ''},可以手动输入模型名称`)
|
||||||
|
|||||||
+26
-16
@@ -4,6 +4,7 @@ import { promises as fs } from 'node:fs'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel, ModelDiscoveryInput, ModelDiscoveryResult, ModelEndpoint } from '@shared/models'
|
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 { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
||||||
import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint'
|
import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint'
|
||||||
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
|
import { 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 : '')
|
const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '')
|
||||||
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
|
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
|
||||||
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
|
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
|
||||||
if (input.protocol !== undefined && input.protocol !== 'anthropic') throw new Error('首版仅支持 Anthropic 协议')
|
next.protocol = input.protocol ?? existing?.protocol ?? (input.apiPreference ? apiProtocol(input.apiPreference) : 'anthropic')
|
||||||
next.protocol = '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 !== undefined && input.tokenBudgetVersion !== 2) throw new Error('模型上下文配置版本无效')
|
||||||
if (input.tokenBudgetVersion === 2) next.tokenBudgetVersion = 2
|
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 (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)
|
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,
|
next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt,
|
||||||
...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) }
|
...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) }
|
||||||
}
|
}
|
||||||
@@ -229,7 +231,7 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise<M
|
|||||||
}
|
}
|
||||||
if (next.protocol && !next.endpoint) {
|
if (next.protocol && !next.endpoint) {
|
||||||
// Discovery is read-only; servers without a models API still support manual model IDs.
|
// Discovery is read-only; servers without a models API still support manual model IDs.
|
||||||
const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key).catch(() => undefined)
|
const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key, false, next.apiPreference).catch(() => undefined)
|
||||||
if (connection) next.endpoint = connection.endpoint
|
if (connection) next.endpoint = connection.endpoint
|
||||||
}
|
}
|
||||||
const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id)
|
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<ResolvedModel |
|
|||||||
const registry = await load()
|
const registry = await load()
|
||||||
const targetId = id === undefined ? registry.selectedModelId : id
|
const targetId = id === undefined ? registry.selectedModelId : id
|
||||||
if (!targetId) return null
|
if (!targetId) return null
|
||||||
const model = registry.models.find(model => 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 (!model) throw new Error('所选模型不存在,请重新选择模型')
|
||||||
|
if (!modelProfileApi(model)) throw new Error('此模型协议暂不支持')
|
||||||
const budget = resolveModelTokenLimits(model)
|
const budget = resolveModelTokenLimits(model)
|
||||||
if (budget.error) throw new Error(budget.error)
|
if (budget.error) throw new Error(budget.error)
|
||||||
const profile = viewOf(model)
|
const profile = viewOf(model)
|
||||||
const legacy = await getGatewayConfig()
|
const legacy = await getGatewayConfig()
|
||||||
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model) : undefined
|
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model, model.apiPreference) : undefined
|
||||||
const resolved = verified && verified.checkedAt >= (model.endpoint?.checkedAt ?? 0) ? verified : model.endpoint
|
// Old or hand-edited routes must not override an explicit API preference or protocol family.
|
||||||
?? (model.protocol ? cachedConnection(model.baseUrl, model.protocol, readKey(model).key)?.endpoint ?? modelEndpoint(model.baseUrl, inferModelApi(model.baseUrl, model.protocol)) : undefined)
|
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 {
|
return {
|
||||||
profile,
|
profile,
|
||||||
config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key,
|
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<ResolvedModel |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchModels(input: ModelDiscoveryInput): Promise<ModelDiscoveryResult> {
|
export async function fetchModels(input: ModelDiscoveryInput): Promise<ModelDiscoveryResult> {
|
||||||
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
|
await writes
|
||||||
const registry = cache ?? await readRegistry(false), existing = registry.models.find(model => model.id === input.previousId)
|
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 : ''
|
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')
|
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. */
|
/** Test only the user-specified origin, negotiating paths/protocols before reporting success. */
|
||||||
@@ -309,12 +319,12 @@ export async function testModel(input: CustomModelInput): Promise<ModelTestResul
|
|||||||
try {
|
try {
|
||||||
const key = readKey(model).key
|
const key = readKey(model).key
|
||||||
const legacy = resolveOpenAIEndpoint(model.baseUrl)
|
const legacy = resolveOpenAIEndpoint(model.baseUrl)
|
||||||
const known = model.protocol ? model.endpoint ?? cachedConnection(model.baseUrl, model.protocol, key)?.endpoint : undefined
|
const known = model.protocol ? model.endpoint ?? cachedConnection(model.baseUrl, model.protocol, key, model.apiPreference)?.endpoint : undefined
|
||||||
// Re-testing an OpenAI route follows the current preference, including at a negotiated custom root.
|
// Re-testing an OpenAI route follows the current preference, including at a negotiated custom root.
|
||||||
// Stored routes continue to serve ordinary chat until a new generation probe succeeds.
|
// Stored routes continue to serve ordinary chat until a new generation probe succeeds.
|
||||||
const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses')
|
const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses')
|
||||||
? { ...known, api: inferModelApi(model.baseUrl, model.protocol) } : known
|
? { ...known, api: model.apiPreference ?? inferModelApi(model.baseUrl, model.protocol) } : known
|
||||||
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol), known].filter((value): value is ModelEndpoint => !!value)
|
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol, model.apiPreference), known].filter((value): value is ModelEndpoint => !!value)
|
||||||
: [{ ...legacy, checkedAt: 0 }]
|
: [{ ...legacy, checkedAt: 0 }]
|
||||||
const tried = new Set<string>(); let status = 0
|
const tried = new Set<string>(); let status = 0
|
||||||
for (const endpoint of candidates) {
|
for (const endpoint of candidates) {
|
||||||
@@ -336,7 +346,7 @@ export async function testModel(input: CustomModelInput): Promise<ModelTestResul
|
|||||||
? Array.isArray(value?.candidates) && value.candidates.length > 0 : Array.isArray(value?.choices) && value.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))
|
? Array.isArray(value?.candidates) && value.candidates.length > 0 : Array.isArray(value?.choices) && value.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))
|
||||||
if (!valid) continue
|
if (!valid) continue
|
||||||
const verified: ModelEndpoint = { api: endpoint.api, baseUrl: endpoint.baseUrl, checkedAt: Date.now(), verifiedBy: 'generation' }
|
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 }
|
return { ...result(true, '连接测试成功'), endpoint: verified }
|
||||||
}
|
}
|
||||||
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { app } from 'electron'
|
|||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { modelBridgeHistoryDirectory } from './model-bridge/paths'
|
||||||
import type { ChatMessage, SessionMeta } from '@shared/types'
|
import type { ChatMessage, SessionMeta } from '@shared/types'
|
||||||
import { applyAssistantEvent } from '@shared/message-parts'
|
import { applyAssistantEvent } from '@shared/message-parts'
|
||||||
import type { SessionContext } from '@shared/workspaces'
|
import type { SessionContext } from '@shared/workspaces'
|
||||||
@@ -238,6 +239,8 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
|||||||
const store = await load()
|
const store = await load()
|
||||||
store.sessions = store.sessions.filter((s) => s.id !== sessionId)
|
store.sessions = store.sessions.filter((s) => s.id !== sessionId)
|
||||||
await persist()
|
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. */
|
/** Keep the abandoned branch on disk before replacing the active conversation. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, ModelProtocol } from '@shared/models'
|
import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, SupportedModelApi } from '@shared/models'
|
||||||
import { normalizeModelAddress } from '@shared/model-endpoint'
|
import { modelProfileApi } from '@shared/model-support'
|
||||||
|
import { apiProtocol, normalizeModelAddress } from '@shared/model-endpoint'
|
||||||
import { resolveModelTokenLimits } from '@shared/model-token-limits'
|
import { resolveModelTokenLimits } from '@shared/model-token-limits'
|
||||||
import { modelLabel as t, sourceModelIcons } from './model-settings-source'
|
import { modelLabel as t, sourceModelIcons } from './model-settings-source'
|
||||||
import SourceSelect, { type SourceSelectGroup } from './source-select/SourceSelect'
|
import SourceSelect, { type SourceSelectGroup } from './source-select/SourceSelect'
|
||||||
@@ -53,7 +54,7 @@ export function parseModelTokenLimit(value: string): number | undefined {
|
|||||||
function defaultInput(providerId = 'custom'): CustomModelInput {
|
function defaultInput(providerId = 'custom'): CustomModelInput {
|
||||||
return { id: '', name: '', providerId: 'custom', model: '', baseUrl: '', authMode: 'api-key', apiKey: '',
|
return { id: '', name: '', providerId: 'custom', model: '', baseUrl: '', authMode: 'api-key', apiKey: '',
|
||||||
supportsToolCall: false, supportsImages: false, supportsReasoning: false, onlyReasoning: false,
|
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 {
|
function Icon({ kind }: { kind: 'close' | 'edit' | 'delete' | 'link' | 'eye' | 'eyeClosed' | 'model' | 'down' | 'check' | 'loading' }): ReactNode {
|
||||||
const source = sourceModelIcons[kind]
|
const source = sourceModelIcons[kind]
|
||||||
@@ -92,7 +93,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
|
|||||||
const update = (patch: Partial<CustomModelInput>): void => {
|
const update = (patch: Partial<CustomModelInput>): void => {
|
||||||
if ('apiKey' in patch) keyEdited.current = true
|
if ('apiKey' in patch) keyEdited.current = true
|
||||||
Object.keys(patch).forEach(key => dirty.current.add(key))
|
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
|
queryGeneration.current++; setFetching(false); setRemoteModels([]); patch.endpoint = undefined
|
||||||
}
|
}
|
||||||
setInput((current) => ({ ...current, ...patch })); setError(''); setTestError('')
|
setInput((current) => ({ ...current, ...patch })); setError(''); setTestError('')
|
||||||
@@ -105,7 +106,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
|
|||||||
setModelListRequest(0)
|
setModelListRequest(0)
|
||||||
queryGeneration.current++; applyMetadata.current = !model; dirty.current.clear(); setRemoteModels([]); setFetching(false)
|
queryGeneration.current++; applyMetadata.current = !model; dirty.current.clear(); setRemoteModels([]); setFetching(false)
|
||||||
const budget = model ? resolveModelTokenLimits(model) : undefined
|
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)
|
setShowKey(false); setError(''); setTestError(''); setOverwrite(null); setOpen(true)
|
||||||
if (model) setFailed((current) => { const next = new Set(current); next.delete(model.id); return next })
|
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 }
|
if (!isModelEndpointValid(input.baseUrl) || !input.baseUrl.trim()) { setError('请填写有效的接口域名'); return }
|
||||||
const request = ++queryGeneration.current; setFetching(true); setError('')
|
const request = ++queryGeneration.current; setFetching(true); setError('')
|
||||||
try {
|
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
|
if (request !== queryGeneration.current) return
|
||||||
setRemoteModels(result.models); setInput(current => ({ ...current, endpoint: current.endpoint?.verifiedBy === 'generation' ? current.endpoint : result.endpoint }))
|
setRemoteModels(result.models); setInput(current => ({ ...current, endpoint: current.endpoint?.verifiedBy === 'generation' ? current.endpoint : result.endpoint }))
|
||||||
setNotice(result.models.length ? `已获取 ${result.models.length} 个模型,可选择或手动输入` : '接口返回空列表,可以手动输入模型名称')
|
setNotice(result.models.length ? `已获取 ${result.models.length} 个模型,可选择或手动输入` : '接口返回空列表,可以手动输入模型名称')
|
||||||
@@ -179,7 +180,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
|
|||||||
const request = queryGeneration.current
|
const request = queryGeneration.current
|
||||||
try {
|
try {
|
||||||
const connectionChanged = !editing || data.model !== editing.model || normalizeModelAddress(data.baseUrl) !== normalizeModelAddress(editing.baseUrl)
|
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') {
|
if (connectionChanged && data.endpoint?.verifiedBy !== 'generation') {
|
||||||
const result = await onTest(data)
|
const result = await onTest(data)
|
||||||
if (request !== queryGeneration.current) return
|
if (request !== queryGeneration.current) return
|
||||||
@@ -230,7 +231,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
|
|||||||
<div className={cx('editor-body')}>
|
<div className={cx('editor-body')}>
|
||||||
{error && <div className="model-ui-error" role="alert">{error}</div>}
|
{error && <div className="model-ui-error" role="alert">{error}</div>}
|
||||||
<div className={cx('field-grid') + ' ' + cx('field-grid--provider')}><Field name="provider" title="供应商"><SourceSelect key={'provider-' + (editing?.id ?? 'new') + '-' + provider.id} value={provider.id} onChange={() => undefined} options={providerOptions} fullWidth ariaLabel="供应商" className={cx('provider-select')} /></Field>
|
<div className={cx('field-grid') + ' ' + cx('field-grid--provider')}><Field name="provider" title="供应商"><SourceSelect key={'provider-' + (editing?.id ?? 'new') + '-' + provider.id} value={provider.id} onChange={() => undefined} options={providerOptions} fullWidth ariaLabel="供应商" className={cx('provider-select')} /></Field>
|
||||||
<div className={cx('field')}><label className={cx('label')}>接口协议</label><SourceSelect value={input.protocol ?? 'anthropic'} onChange={value => update({ protocol: value as ModelProtocol })} options={[{ value: 'anthropic', label: 'Anthropic Messages' }]} ariaLabel="接口协议" fullWidth /></div>
|
<div className={cx('field')}><label className={cx('label')}>接口协议</label><SourceSelect value={input.apiPreference ?? modelProfileApi(input) ?? 'anthropic-messages'} onChange={value => 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 /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cx('model-form-fields')}>
|
<div className={cx('model-form-fields')}>
|
||||||
<Field name="endpoint"><Input aria-label={label('fields.endpoint')} value={input.baseUrl} invalid={!isModelEndpointValid(input.baseUrl)} onChange={(event) => update({ baseUrl: event.target.value })} placeholder="api.example.com 或 https://api.example.com" /><div className={cx('hint')}>填写域名即可,兼容已有完整地址。保存新接口时会发送简短测试,自动验证协议和路径。</div></Field>
|
<Field name="endpoint"><Input aria-label={label('fields.endpoint')} value={input.baseUrl} invalid={!isModelEndpointValid(input.baseUrl)} onChange={(event) => update({ baseUrl: event.target.value })} placeholder="api.example.com 或 https://api.example.com" /><div className={cx('hint')}>填写域名即可,兼容已有完整地址。保存新接口时会发送简短测试,自动验证协议和路径。</div></Field>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { CustomModelProfile } from '@shared/models'
|
import type { CustomModelProfile } from '@shared/models'
|
||||||
import type { ThinkingLevel } from '@shared/workspaces'
|
import type { ThinkingLevel } from '@shared/workspaces'
|
||||||
|
import { isSupportedModelProfile, modelProfileApi } from '@shared/model-support'
|
||||||
import { modelThinkingLevel } from '@shared/model-thinking'
|
import { modelThinkingLevel } from '@shared/model-thinking'
|
||||||
import { ModelSelectorImpl, ModelSelectorRuntimeProvider, type ModelEffort, type ModelOption, type ModelSelection } from './source.mjs'
|
import { ModelSelectorImpl, ModelSelectorRuntimeProvider, type ModelEffort, type ModelOption, type ModelSelection } from './source.mjs'
|
||||||
import { SourceHeaderEditIcon } from '../../../components/SourceChatHeaderIcons'
|
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))
|
const efforts = (model.supportedEfforts ?? []).filter((value): value is ModelEffort => (EFFORTS as string[]).includes(value))
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
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,
|
// 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.
|
// which is what the original conversation selector shows for these models.
|
||||||
supportsReasoning: !!model.supportsReasoning,
|
supportsReasoning: !!model.supportsReasoning,
|
||||||
onlyReasoning: !!model.onlyReasoning,
|
onlyReasoning: !!model.onlyReasoning,
|
||||||
canDisableThinking: model.canDisableThinking !== false,
|
canDisableThinking: model.canDisableThinking !== false,
|
||||||
...(model.defaultEffort && (EFFORTS as string[]).includes(model.defaultEffort) ? { defaultEffort: model.defaultEffort as ModelEffort } : {}),
|
...(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.
|
// Preserve the historical Anthropic UI; new APIs only advertise declared effort levels.
|
||||||
supportedEfforts: model.metadata ? efforts : efforts.length ? efforts : EFFORTS,
|
supportedEfforts: modelProfileApi(model) === 'anthropic-messages' && !model.metadata && !efforts.length ? EFFORTS : efforts,
|
||||||
maxInputTokens: model.contextWindowTokens ?? model.maxInputTokens
|
maxInputTokens: model.contextWindowTokens ?? model.maxInputTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,8 +69,8 @@ export default function SourceModelSelector({ models, selectedId, label, disable
|
|||||||
useEffect(() => { setDraft(selection) }, [selection])
|
useEffect(() => { setDraft(selection) }, [selection])
|
||||||
const commit = useCallback(async (next: ModelSelection): Promise<void> => {
|
const commit = useCallback(async (next: ModelSelection): Promise<void> => {
|
||||||
const target = models.find((model) => model.id === next.id)
|
const target = models.find((model) => model.id === next.id)
|
||||||
if (!target || target.protocol !== 'anthropic') {
|
if (!target || !isSupportedModelProfile(target)) {
|
||||||
setError('首版仅支持 Anthropic/Claude 兼容模型')
|
setError('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 模型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (lock.current) return
|
if (lock.current) return
|
||||||
|
|||||||
@@ -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 {
|
export function normalizeModelAddress(raw: string): string {
|
||||||
const value = raw.trim()
|
const value = raw.trim()
|
||||||
@@ -37,9 +37,9 @@ export function endpointUrl(endpoint: ModelEndpoint, operation: 'models' | 'gene
|
|||||||
return url.href
|
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 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]
|
: protocol === 'openai' ? [inferred, inferred === 'openai-responses' ? 'openai-completions' : 'openai-responses'] : [inferred]
|
||||||
const seen = new Set<string>(), result: ModelEndpoint[] = []
|
const seen = new Set<string>(), result: ModelEndpoint[] = []
|
||||||
for (const api of apis) {
|
for (const api of apis) {
|
||||||
|
|||||||
@@ -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<CustomModelProfile, 'protocol' | 'apiPreference' | 'endpoint' | 'baseUrl'>): 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<CustomModelProfile, 'protocol' | 'apiPreference' | 'endpoint' | 'baseUrl'>): boolean {
|
||||||
|
try { return modelProfileApi(profile) !== undefined } catch { return false }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { GatewayConfig } from './types'
|
|||||||
|
|
||||||
export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini'
|
export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini'
|
||||||
export type ModelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai'
|
export type ModelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai'
|
||||||
|
export type SupportedModelApi = Exclude<ModelApi, 'google-generative-ai'>
|
||||||
export interface ModelEndpoint { api: ModelApi; baseUrl: string; checkedAt: number; verifiedBy?: 'models' | 'generation' }
|
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 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 }
|
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
|
price?: ModelPrice; temperature?: boolean
|
||||||
}
|
}
|
||||||
export interface ModelMetadataStatus { fetchedAt: string; sha256: string; providers: number; models: number; source: string }
|
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 DiscoveredModel { id: string; name: string }
|
||||||
export interface ModelDiscoveryResult { models: DiscoveredModel[]; endpoint: ModelEndpoint }
|
export interface ModelDiscoveryResult { models: DiscoveredModel[]; endpoint: ModelEndpoint }
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export interface CustomModelProfile {
|
|||||||
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
||||||
authMode?: 'api-key' | 'none'
|
authMode?: 'api-key' | 'none'
|
||||||
protocol?: ModelProtocol
|
protocol?: ModelProtocol
|
||||||
|
apiPreference?: SupportedModelApi
|
||||||
endpoint?: ModelEndpoint
|
endpoint?: ModelEndpoint
|
||||||
metadata?: ModelMetadata
|
metadata?: ModelMetadata
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
|||||||
Reference in new issue
Block a user