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,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 type { CustomModelInput } from '../src/shared/models'
|
||||
import type * as Registry from '../src/main/models'
|
||||
import { cachedConnection, cachedGeneration, rememberConnection, rememberGeneration } from '../src/main/model-connection'
|
||||
import { modelProfileApi, isSupportedModelProfile } from '../src/shared/model-support'
|
||||
import { endpointCandidates, inferModelApi } from '../src/shared/model-endpoint'
|
||||
|
||||
const STATE_KEY = '__YCODE_MODEL_REGISTRY_TEST__'
|
||||
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
||||
const previous = globals[STATE_KEY]
|
||||
const realFetch = globalThis.fetch
|
||||
// This suite only contacts its loopback fake; registry discovery on fixture domains is blocked.
|
||||
globalThis.fetch = ((url, init) => {
|
||||
if (new URL(String(url)).hostname !== '127.0.0.1') return Promise.reject(new Error('offline fixture'))
|
||||
return realFetch(url, init)
|
||||
}) as typeof fetch
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-model-registry-'))
|
||||
const state = { userData: join(temp, 'userData'), encryption: true }
|
||||
globals[STATE_KEY] = state
|
||||
@@ -23,6 +31,7 @@ const legacy = { baseUrl: 'https://legacy.example/v1', model: 'legacy-model', te
|
||||
const reload = (): Promise<typeof Registry> => import(`${pathToFileURL(outfile).href}?instance=${++importNumber}`)
|
||||
const input = (id: string, extra: Partial<CustomModelInput> = {}): CustomModelInput => ({
|
||||
id, name: `Name ${id}`, providerId: 'custom', model: `model-${id}`, baseUrl: 'https://provider.example/v1',
|
||||
protocol: 'openai', apiPreference: extra.baseUrl?.includes('/responses') ? 'openai-responses' : 'openai-completions',
|
||||
apiKey: ' new-key\n\t with-space ', supportsToolCall: true, supportsImages: true, ...extra
|
||||
})
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
@@ -120,6 +129,19 @@ try {
|
||||
assert.equal(inferModelApi('https://api.anthropic.com', 'auto'), 'anthropic-messages')
|
||||
assert.equal(inferModelApi('https://generativelanguage.googleapis.com', 'auto'), 'google-generative-ai')
|
||||
|
||||
assert.equal(modelProfileApi({ baseUrl: 'https://legacy.example' }), 'anthropic-messages')
|
||||
assert.equal(isSupportedModelProfile({ baseUrl: 'https://legacy.example', protocol: 'gemini' }), false)
|
||||
for (const apiPreference of ['openai-completions', 'openai-responses'] as const) {
|
||||
const route = { api: apiPreference, baseUrl: 'https://cache.invalid/v1/' + apiPreference, checkedAt: Date.now() }
|
||||
rememberConnection('https://cache.invalid', 'openai', 'private-cache-key', { models: [], endpoint: route }, apiPreference)
|
||||
rememberGeneration('https://cache.invalid', 'openai', 'private-cache-key', 'same-model', route, apiPreference)
|
||||
}
|
||||
for (const apiPreference of ['openai-completions', 'openai-responses'] as const) {
|
||||
assert.equal(cachedConnection('https://cache.invalid', 'openai', 'private-cache-key', apiPreference)?.endpoint.api, apiPreference)
|
||||
assert.equal(cachedGeneration('https://cache.invalid', 'openai', 'private-cache-key', 'same-model', apiPreference)?.api, apiPreference)
|
||||
assert.equal(cachedGeneration('https://cache.invalid', 'openai', 'changed-key', 'same-model', apiPreference), undefined)
|
||||
assert(endpointCandidates('https://cache.invalid', 'openai', apiPreference).every(route => route.api === apiPreference))
|
||||
}
|
||||
let responseMode = 'success'
|
||||
let responsePayload: unknown = undefined
|
||||
const requests: Array<{ path: string; auth?: string; data: Record<string, unknown> }> = []
|
||||
@@ -127,8 +149,9 @@ try {
|
||||
let body = ''
|
||||
request.on('data', chunk => { body += chunk })
|
||||
request.on('end', () => {
|
||||
requests.push({ path: request.url ?? '', auth: request.headers.authorization, data: JSON.parse(body) as Record<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')
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) { response.end(JSON.stringify({ data: [{ id: 'fake-model' }] })); return }
|
||||
if (responseMode === 'auth') { response.statusCode = 401; response.end(JSON.stringify({ error: 'private-key-echo new-keywith-space' })); return }
|
||||
if (responseMode === 'not-found') { response.statusCode = 404; response.end(JSON.stringify({ error: 'private-endpoint-echo' })); return }
|
||||
if (responseMode === 'malformed') { response.end('not JSON'); return }
|
||||
@@ -150,6 +173,29 @@ try {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
const endpoint = `http://127.0.0.1:${address.port}/v1`
|
||||
for (const apiPreference of ['openai-completions', 'openai-responses'] as const) {
|
||||
const found = await registry.fetchModels({ baseUrl: endpoint, protocol: 'openai', apiPreference, apiKey: 'new-keywith-space' })
|
||||
assert.equal(found.endpoint.api, apiPreference, 'shared models URL must not collapse API identity')
|
||||
assert.deepEqual(found.models.map(model => model.id), ['fake-model'])
|
||||
const saved = input('persist-' + apiPreference, { baseUrl: endpoint, apiPreference, endpoint: found.endpoint })
|
||||
await registry.saveModel(saved)
|
||||
registry = await reload()
|
||||
const restored = await registry.resolveModel(saved.id)
|
||||
assert.equal(restored?.profile.apiPreference, apiPreference)
|
||||
assert.equal(restored?.config.api, apiPreference)
|
||||
assert(!JSON.stringify(await registry.listModels()).includes('new-keywith-space'))
|
||||
await registry.saveModel({ ...saved, previousId: saved.id, apiKey: '' })
|
||||
assert.equal((await registry.resolveModel(saved.id))?.config.apiKey, 'new-keywith-space')
|
||||
}
|
||||
await assert.rejects(() => registry.saveModel(input('gemini', { protocol: 'gemini', apiPreference: undefined })), /仅支持/)
|
||||
await assert.rejects(() => registry.saveModel(input('mismatch', { protocol: 'anthropic', apiPreference: 'openai-responses' })), /仅支持/)
|
||||
await assert.rejects(() => registry.saveModel(input('stale-route', { baseUrl: endpoint, apiPreference: 'openai-responses', endpoint: { api: 'openai-completions', baseUrl: endpoint, checkedAt: 0 } })), /不一致/)
|
||||
responsePayload = { type: 'message', content: [{ type: 'text', text: 'OK' }] }
|
||||
const legacyProbe = await registry.testModel(input('legacy-probe', { baseUrl: endpoint, protocol: undefined, apiPreference: undefined }))
|
||||
assert.equal(legacyProbe.endpoint?.api, 'anthropic-messages')
|
||||
assert.equal(requests.at(-1)?.path, '/v1/messages')
|
||||
responsePayload = undefined
|
||||
requests.length = 0
|
||||
const testInput = input('unsaved-test', { baseUrl: endpoint })
|
||||
const beforeTest = await readFile(modelsFile, 'utf8')
|
||||
const successful = await registry.testModel(testInput)
|
||||
@@ -224,15 +270,15 @@ try {
|
||||
}
|
||||
responsePayload = undefined
|
||||
responseMode = 'negotiate-both'
|
||||
for (const protocol of ['openai', 'auto'] as const) {
|
||||
for (const protocol of ['openai'] as const) {
|
||||
const start = requests.length
|
||||
const negotiated = await registry.testModel(input('prefer-responses-' + protocol, { baseUrl: endpoint, protocol }))
|
||||
const negotiated = await registry.testModel(input('prefer-responses-' + protocol, { baseUrl: endpoint, protocol, apiPreference: undefined }))
|
||||
assert.equal(negotiated.success, true)
|
||||
assert.equal(negotiated.endpoint?.api, 'openai-responses', 'Responses wins when both OpenAI APIs are supported')
|
||||
assert.deepEqual(requests.slice(start).map(request => request.path), ['/v1/responses'], 'a successful Responses probe must not call Chat')
|
||||
}
|
||||
const previousChat = { api: 'openai-completions' as const, baseUrl: endpoint + '/custom', checkedAt: Date.now() - 1000, verifiedBy: 'generation' as const }
|
||||
const previousInput = input('previous-chat', { baseUrl: endpoint, protocol: 'openai', endpoint: previousChat })
|
||||
const previousInput = input('previous-chat', { baseUrl: endpoint, protocol: 'openai', apiPreference: undefined, endpoint: previousChat })
|
||||
const startRetest = requests.length
|
||||
const retested = await registry.testModel(previousInput)
|
||||
assert.equal(retested.endpoint?.api, 'openai-responses', 're-testing a stored Chat route follows the new priority')
|
||||
@@ -244,10 +290,20 @@ try {
|
||||
assert.equal(requests.at(-1)?.path, '/v1/chat/completions')
|
||||
responseMode = 'negotiate-chat-only'
|
||||
const startFallback = requests.length
|
||||
const chatFallback = await registry.testModel(input('chat-only-protocol', { baseUrl: endpoint, protocol: 'openai' }))
|
||||
const chatFallback = await registry.testModel(input('chat-only-protocol', { baseUrl: endpoint, protocol: 'openai', apiPreference: undefined }))
|
||||
assert.equal(chatFallback.success, true)
|
||||
assert.equal(chatFallback.endpoint?.api, 'openai-completions')
|
||||
assert.deepEqual(requests.slice(startFallback).map(request => request.path), ['/v1/responses', '/v1/chat/completions'], 'unsupported Responses falls back to Chat')
|
||||
const explicitStart = requests.length
|
||||
const noFallback = await registry.testModel(input('explicit-responses-only', { baseUrl: endpoint, apiPreference: 'openai-responses' }))
|
||||
assert.equal(noFallback.success, false)
|
||||
assert(requests.slice(explicitStart).every(request => !request.path.includes('chat/completions')), 'explicit Responses cannot fall back silently')
|
||||
responseMode = 'negotiate-both'
|
||||
for (const apiPreference of ['openai-completions', 'openai-responses'] as const) {
|
||||
const probe = input('cache-identity', { baseUrl: endpoint, apiPreference })
|
||||
const tested = await registry.testModel(probe)
|
||||
assert.equal(tested.endpoint?.api, apiPreference)
|
||||
}
|
||||
responseMode = 'auth'
|
||||
const authFailure = await registry.testModel(testInput)
|
||||
assert.equal(authFailure.success, false)
|
||||
@@ -283,7 +339,7 @@ try {
|
||||
try {
|
||||
assert.match((await registry.testModel(testInput)).message, /超时/)
|
||||
} finally { globalThis.fetch = originalFetch }
|
||||
const largeModel = input('large-sol', { model: 'gpt-6.1-sol', baseUrl: endpoint, protocol: 'openai',
|
||||
const largeModel = input('large-sol', { model: 'gpt-6.1-sol', baseUrl: endpoint, protocol: 'openai', apiPreference: 'openai-responses',
|
||||
endpoint: { api: 'openai-responses', baseUrl: endpoint, checkedAt: Date.now(), verifiedBy: 'generation' },
|
||||
maxInputTokens: 500000, maxOutputTokens: 128000, contextWindowTokens: 272000 })
|
||||
await registry.saveModel(largeModel)
|
||||
@@ -333,9 +389,10 @@ try {
|
||||
registry = await reload()
|
||||
await assert.rejects(() => registry.listModels(), /损坏/)
|
||||
assert.equal(await readFile(modelsFile, 'utf8'), '{malformed', 'corrupt registries must never be silently overwritten')
|
||||
console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, total/output budgets, legacy budget migration, derived input/reload/reduction, catalog limits, Responses priority, explicit Chat override, Chat fallback, stored-route retesting, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures')
|
||||
console.log('PASS model registry: migration, encrypted CRUD, concurrent writes, persistence, selection, total/output budgets, legacy budget migration, derived input/reload/reduction, catalog limits, explicit API persistence/cache isolation/discovery, legacy Anthropic, Gemini rejection, Responses priority, explicit Chat override, Chat fallback, stored-route retesting, Chat/Responses HTTP schema, incomplete reasoning, blank-secret reuse and secret-safe failures')
|
||||
} finally {
|
||||
if (server) await new Promise<void>(resolve => server!.close(() => resolve()))
|
||||
globalThis.fetch = realFetch
|
||||
globals[STATE_KEY] = previous
|
||||
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 { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -8,6 +9,7 @@ import { build } from 'esbuild'
|
||||
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
||||
|
||||
interface SessionStoreApi {
|
||||
deleteSession(sessionId: string): Promise<void>
|
||||
listSessions(): Promise<SessionMeta[]>
|
||||
createSession(title?: string): Promise<SessionMeta>
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
@@ -265,6 +267,16 @@ async function main(): Promise<void> {
|
||||
assert.deepEqual(afterRecovery, inMemory,
|
||||
'session:messages should remain consistent after the write queue recovers')
|
||||
|
||||
const deletedHistory = modelBridgeHistoryDirectory(state.userData, recovered.id)
|
||||
const retainedHistory = modelBridgeHistoryDirectory(state.userData, targetSession.id)
|
||||
await mkdir(deletedHistory, { recursive: true })
|
||||
await mkdir(retainedHistory, { recursive: true })
|
||||
await writeFile(join(deletedHistory, 'route.json'), '{}')
|
||||
await writeFile(join(retainedHistory, 'route.json'), '{}')
|
||||
await coldModule.sessions.deleteSession(recovered.id)
|
||||
await assert.rejects(readFile(join(deletedHistory, 'route.json')), /ENOENT/)
|
||||
assert.equal(await readFile(join(retainedHistory, 'route.json'), 'utf8'), '{}', 'deleting one session preserves another bridge history')
|
||||
assert.ok(!modelBridgeHistoryDirectory(state.userData, '../escape').includes('..'), 'session identifiers are hashed, not path segments')
|
||||
console.log('session-store-regression: all checks passed')
|
||||
} finally {
|
||||
if (previous === undefined) delete globals[STATE_KEY]
|
||||
|
||||
Reference in new issue
Block a user