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
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user