feat(models): bridge Chat Completions and Responses through Claude runtime

This commit is contained in:
Codex committed 2026-10-21 18:19:03 +09:00
1 parent 8fcfb69408
commit d773961168
21 files changed
+1346 -68

No files matched your search

+83 -7
View File
@@ -1,4 +1,8 @@
import { privateRuntimeEnvironment } from '../../private-runtimes'
import { acquireModelBridge } from '../../model-bridge'
import { modelBridgeHistoryDirectory } from '../../model-bridge/paths'
import { modelProfileApi } from '@shared/model-support'
import { modelThinkingLevel } from '@shared/model-thinking'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
@@ -201,7 +205,7 @@ export function claudeRuntimeInfo(): { available: boolean; exePath: string | nul
}
}
function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv {
function modelEnvironment(model: ResolvedModel, bridge?: { baseUrl: string; apiKey: string }): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
CLAUDE_CONFIG_DIR: join(app.getPath('userData'), 'claude-code'),
@@ -248,6 +252,10 @@ function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv {
} else {
delete env.ANTHROPIC_API_KEY
}
if (bridge) {
env.ANTHROPIC_BASE_URL = bridge.baseUrl
env.ANTHROPIC_API_KEY = bridge.apiKey
}
const contextWindow = model.profile.contextWindowTokens ?? model.profile.maxInputTokens
if (contextWindow && Number.isFinite(contextWindow) && contextWindow > 0) {
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.trunc(contextWindow))
@@ -328,6 +336,8 @@ export class ClaudeKernel {
private consuming: Promise<void> | null = null
private active: ActiveTurn | null = null
private starting = false
private startup: Promise<void> | null = null
private bridge: Awaited<ReturnType<typeof acquireModelBridge>> | null = null
private permissionUpdates: Promise<void> = Promise.resolve()
private policyTransitions = 0
private policyRevision = 0
@@ -345,6 +355,8 @@ export class ClaudeKernel {
private agentMode: AgentMode
constructor(private readonly options: ClaudeKernelOptions) {
// A running kernel must never adopt an edited registry object mid-turn.
this.options = { ...options, model: structuredClone(options.model) }
this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => {
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
} })
@@ -363,7 +375,23 @@ export class ClaudeKernel {
}
async ensureStarted(): Promise<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.draining
if (this.policyError) throw this.policyError
@@ -380,6 +408,24 @@ export class ClaudeKernel {
const newSessionId = !resumeCurrent && this.options.forceNewSession ? randomUUID() : undefined
const requestedSessionId = forkSessionId ?? newSessionId
if (requestedSessionId) this.currentSessionId = requestedSessionId
const api = modelProfileApi(this.options.model.profile)
if (!api) throw new Error('当前模型协议尚未支持')
if (api !== 'anthropic-messages' && !this.bridge) {
const bridgeModel = structuredClone(this.options.model)
bridgeModel.config.thinkingLevel = modelThinkingLevel(bridgeModel.profile, this.options.thinkingLevel)
const historyIdentity = createHash('sha256').update(JSON.stringify([
this.options.sessionId, bridgeModel.profile.id, bridgeModel.config.api,
bridgeModel.config.baseUrl, bridgeModel.config.model,
createHash('sha256').update(bridgeModel.config.apiKey ?? '').digest('hex')
])).digest('hex')
const bridge = await acquireModelBridge(bridgeModel, {
historyFile: join(modelBridgeHistoryDirectory(app.getPath('userData'), this.options.sessionId), `${historyIdentity}.json`)
})
if (this.closed) { await bridge.close(); throw new Error('Claude 内核已释放') }
this.bridge = bridge
}
const childEnvironment = await privateRuntimeEnvironment(modelEnvironment(this.options.model, this.bridge ?? undefined))
if (this.closed) throw new Error('Claude 内核已释放')
const instance = query({
prompt: input,
options: {
@@ -524,7 +570,9 @@ export class ClaudeKernel {
? { resume: this.options.resumeSessionId }
: { sessionId: newSessionId ?? this.currentSessionId }),
stderr: (data) => {
if (data.trim()) console.error(`[claude] ${data.trim()}`)
const safe = [this.options.model.config.apiKey, this.bridge?.apiKey].filter(Boolean)
.reduce<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.instance = instance
this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode)
const consuming = this.consume(instance)
const consuming = this.consume(instance, this.bridge)
this.consuming = consuming
void consuming.finally(() => {
if (this.consuming === consuming) this.consuming = null
@@ -553,13 +601,17 @@ export class ClaudeKernel {
}
}
private async consume(instance: Query): Promise<void> {
private async consume(instance: Query, bridge: Awaited<ReturnType<typeof acquireModelBridge>> | null): Promise<void> {
try {
for await (const message of instance) await this.handleMessage(message)
} catch (error) {
this.failActive(error instanceof Error ? error : new Error(String(error)))
if (!this.closed) console.error('Claude Code process stopped', error)
} finally {
if (bridge && this.bridge === bridge) {
this.bridge = null
await bridge.close()
}
this.failActive(new Error('Claude Code 进程已退出'))
if (this.instance === instance) {
instance.close()
@@ -951,7 +1003,14 @@ export class ClaudeKernel {
if (this.policyError) throw this.policyError
const input = this.input
if (!input) throw new Error('Claude Code 输入流未就绪')
if (request.signal.aborted) throw abortError()
if (request.signal.aborted) {
// Cancellation can arrive while the bridge/runtime is starting. Retire
// that producer before rejecting, without permanently closing this kernel.
this.processAbort?.abort()
this.instance?.close()
await this.consuming?.catch(() => undefined)
throw abortError()
}
return new Promise<ClaudeTurnResult>((resolve, reject) => {
const active: ActiveTurn = {
request,
@@ -1180,6 +1239,9 @@ export class ClaudeKernel {
this.input?.close()
this.input = null
this.processAbort?.abort()
const bridge = this.bridge
this.bridge = null
await bridge?.close()
const instance = this.instance
try { await this.instance?.interrupt() } catch { /* the process may already be gone */ }
instance?.close()
@@ -1219,7 +1281,21 @@ function kernelFingerprint(options: ClaudeKernelOptions): string {
modelId: options.model.profile.id,
model: options.model.config.model,
baseUrl: options.model.config.baseUrl,
apiKey: options.model.config.apiKey,
// Keep only a non-reversible revision in the cache identity, never the key itself.
credentialRevision: createHash('sha256').update(options.model.config.apiKey ?? '').digest('hex'),
api: options.model.config.api,
profileApi: modelProfileApi(options.model.profile),
endpoint: options.model.profile.endpoint,
capabilities: {
tools: options.model.profile.supportsToolCall, images: options.model.profile.supportsImages,
reasoning: options.model.profile.supportsReasoning, efforts: options.model.profile.supportedEfforts,
defaultEffort: options.model.profile.defaultEffort, onlyReasoning: options.model.profile.onlyReasoning,
canDisableThinking: options.model.profile.canDisableThinking,
contextWindow: options.model.profile.contextWindowTokens, maxInput: options.model.profile.maxInputTokens,
maxOutput: options.model.profile.maxOutputTokens, budget: options.model.profile.reasoningBudget,
temperature: options.model.profile.temperature
},
bridgeVersion: 1,
authMode: options.model.profile.authMode,
thinkingLevel: options.thinkingLevel,
systemContext: options.systemContext
+4 -6
View File
@@ -1,3 +1,4 @@
import { isSupportedModelProfile } from '@shared/model-support'
/** Main-process chat turns backed by one persistent Claude Code kernel per session. */
import type { WebContents } from 'electron'
import { randomUUID } from 'node:crypto'
@@ -85,10 +86,7 @@ export async function disposeSessionKernel(sessionId: string): Promise<void> {
}
function isSupportedClaudeModel(model: Awaited<ReturnType<typeof models.resolveModel>>): boolean {
if (!model) return false
return model.config.api === 'anthropic-messages' ||
model.profile.protocol === 'anthropic' ||
/\/messages\/?$/i.test(model.config.baseUrl ?? '')
return Boolean(model && isSupportedModelProfile(model.profile))
}
function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxInputTokens?: number } }): number | undefined {
@@ -124,7 +122,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode })
const selectedModel = await models.resolveModel(context.modelId)
if (!selectedModel) throw new Error('请先添加并选择模型')
if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
if (!isSupportedClaudeModel(selectedModel)) throw new Error('当前模型协议尚未支持')
const workspaceRoot = await sessionRoot(sessionId)
if (payload.skillIds !== undefined && (!Array.isArray(payload.skillIds) || payload.skillIds.some(id => typeof id !== 'string') || payload.skillIds.length > 100)) throw new Error('技能引用无效')
if (payload.mcpServerIds !== undefined && (!Array.isArray(payload.mcpServerIds) || payload.mcpServerIds.some(id => typeof id !== 'string') || payload.mcpServerIds.length > 100)) throw new Error('MCP 服务引用无效')
@@ -419,7 +417,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
const context = await sessions.getSessionContext(sessionId)
const model = await models.resolveModel(context.modelId)
if (!model) throw new Error('请先添加并选择模型')
if (!isSupportedClaudeModel(model)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
if (!isSupportedClaudeModel(model)) throw new Error('当前模型协议尚未支持')
const claudeSession = await sessions.getClaudeSession(sessionId)
if (!claudeSession.sessionId) throw new Error('Claude 会话尚未建立,无法回退文件')
const workspaceRoot = await sessionRoot(sessionId)
+70
View File
@@ -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
}
+132
View File
@@ -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()
})()
} }
}
+7
View File
@@ -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'))
}
+221
View File
@@ -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) }
}
+156
View File
@@ -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
}
}
+14 -14
View File
@@ -1,24 +1,24 @@
import { createHash } from 'node:crypto'
import { endpointCandidates, endpointUrl, modelHeaders, normalizeModelAddress } from '@shared/model-endpoint'
import type { DiscoveredModel, ModelDiscoveryResult, ModelEndpoint, ModelProtocol } from '@shared/models'
import type { DiscoveredModel, ModelDiscoveryResult, ModelEndpoint, ModelProtocol, SupportedModelApi } from '@shared/models'
const discovered = new Map<string, { result: ModelDiscoveryResult; 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')
export function cachedGeneration(address: string, protocol: ModelProtocol, key: string, model: string): ModelEndpoint | undefined {
const value = generations.get(identity(address, protocol, key) + ':' + model)
const identity = (address: string, protocol: ModelProtocol, key: string, apiPreference?: SupportedModelApi): string => createHash('sha256').update(JSON.stringify([normalizeModelAddress(address), protocol, apiPreference, key])).digest('hex')
export function cachedGeneration(address: string, protocol: ModelProtocol, key: string, model: string, apiPreference?: SupportedModelApi): ModelEndpoint | undefined {
const value = generations.get(identity(address, protocol, key, apiPreference) + ':' + model)
return value && value.expires > Date.now() ? value.endpoint : undefined
}
export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint): void {
generations.set(identity(address, protocol, key) + ':' + model, { endpoint, expires: Date.now() + 10 * 60 * 1000 })
export function rememberGeneration(address: string, protocol: ModelProtocol, key: string, model: string, endpoint: ModelEndpoint, apiPreference?: SupportedModelApi): void {
generations.set(identity(address, protocol, key, apiPreference) + ':' + model, { endpoint, expires: Date.now() + 10 * 60 * 1000 })
if (generations.size > 100) generations.delete(generations.keys().next().value!)
}
export function cachedConnection(address: string, protocol: ModelProtocol, key: string): ModelDiscoveryResult | undefined {
const value = discovered.get(identity(address, protocol, key))
export function cachedConnection(address: string, protocol: ModelProtocol, key: string, apiPreference?: SupportedModelApi): ModelDiscoveryResult | undefined {
const value = discovered.get(identity(address, protocol, key, apiPreference))
return value && value.expires > Date.now() ? value.result : undefined
}
export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult): void {
discovered.set(identity(address, protocol, key), { result, expires: Date.now() + 10 * 60 * 1000 })
export function rememberConnection(address: string, protocol: ModelProtocol, key: string, result: ModelDiscoveryResult, apiPreference?: SupportedModelApi): void {
discovered.set(identity(address, protocol, key, apiPreference), { result, expires: Date.now() + 10 * 60 * 1000 })
if (discovered.size > 100) discovered.delete(discovered.keys().next().value!)
}
@@ -60,12 +60,12 @@ function parseModels(value: unknown, endpoint: ModelEndpoint): { models: Discove
return { models, next: typeof data.nextPageToken === 'string' ? data.nextPageToken : data.has_more && typeof data.last_id === 'string' ? data.last_id : undefined }
}
export async function discoverModelConnection(address: string, protocol: ModelProtocol, key: string, refresh = false): Promise<ModelDiscoveryResult> {
const cached = !refresh && cachedConnection(address, protocol, key)
export async function discoverModelConnection(address: string, protocol: ModelProtocol, key: string, refresh = false, apiPreference?: SupportedModelApi): Promise<ModelDiscoveryResult> {
const cached = !refresh && cachedConnection(address, protocol, key, apiPreference)
if (cached) return cached
const attempts = new Set<string>(), timeout = AbortSignal.timeout(15000)
let status: number | undefined, authDenied = false
for (const endpoint of endpointCandidates(address, protocol)) {
for (const endpoint of endpointCandidates(address, protocol, apiPreference)) {
const url = endpointUrl(endpoint, 'models'), headers = modelHeaders(endpoint.api, key), signature = url + JSON.stringify(headers)
if (attempts.has(signature)) continue
attempts.add(signature)
@@ -97,7 +97,7 @@ export async function discoverModelConnection(address: string, protocol: ModelPr
}
const models = [...new Map(rows.slice(0, 5000).map(model => [model.id, model])).values()].sort((a, b) => a.id.localeCompare(b.id))
const result: ModelDiscoveryResult = { models, endpoint: { ...endpoint, checkedAt: Date.now(), verifiedBy: 'models' } }
rememberConnection(address, protocol, key, result)
rememberConnection(address, protocol, key, result, apiPreference)
return result
}
throw Error(authDenied ? '鉴权失败,请检查 API Key 和协议' : `接口未提供可用的模型列表${status ? `(HTTP ${status})` : ''},可以手动输入模型名称`)
+26 -16
View File
@@ -4,6 +4,7 @@ import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel, ModelDiscoveryInput, ModelDiscoveryResult, ModelEndpoint } from '@shared/models'
import { modelProfileApi, supportedModelApi } from '@shared/model-support'
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint'
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
@@ -151,15 +152,16 @@ function normalize(input: CustomModelInput, existing?: StoredModel, testing = fa
const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '')
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
if (input.protocol !== undefined && input.protocol !== 'anthropic') throw new Error('首版仅支持 Anthropic 协议')
next.protocol = 'anthropic'
next.protocol = input.protocol ?? existing?.protocol ?? (input.apiPreference ? apiProtocol(input.apiPreference) : 'anthropic')
next.apiPreference = input.apiPreference ?? (next.protocol === existing?.protocol ? existing?.apiPreference : undefined)
if (!modelProfileApi(next)) throw new Error('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 协议')
if (input.tokenBudgetVersion !== undefined && input.tokenBudgetVersion !== 2) throw new Error('模型上下文配置版本无效')
if (input.tokenBudgetVersion === 2) next.tokenBudgetVersion = 2
const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && !suppliedKey ? existing.endpoint : undefined)
const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && next.apiPreference === existing?.apiPreference && !suppliedKey && !input.clearApiKey && authMode === (existing.authMode ?? 'api-key') ? existing.endpoint : undefined)
if (configuredEndpoint) {
if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(configuredEndpoint.api)) throw new Error('协商协议无效')
if (!supportedModelApi(configuredEndpoint.api)) throw new Error('协商协议无效')
const root = normalizeModelAddress(configuredEndpoint.baseUrl)
if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== 'anthropic') throw new Error('协商地址与填写的域名或协议不一致')
if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== next.protocol || next.apiPreference !== undefined && configuredEndpoint.api !== next.apiPreference) throw new Error('协商地址与填写的域名或协议不一致')
next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt,
...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) }
}
@@ -229,7 +231,7 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise<M
}
if (next.protocol && !next.endpoint) {
// 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
}
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 targetId = id === undefined ? registry.selectedModelId : id
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 (!modelProfileApi(model)) throw new Error('此模型协议暂不支持')
const budget = resolveModelTokenLimits(model)
if (budget.error) throw new Error(budget.error)
const profile = viewOf(model)
const legacy = await getGatewayConfig()
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model) : undefined
const resolved = verified && verified.checkedAt >= (model.endpoint?.checkedAt ?? 0) ? verified : model.endpoint
?? (model.protocol ? cachedConnection(model.baseUrl, model.protocol, readKey(model).key)?.endpoint ?? modelEndpoint(model.baseUrl, inferModelApi(model.baseUrl, model.protocol)) : undefined)
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model, model.apiPreference) : undefined
// Old or hand-edited routes must not override an explicit API preference or protocol family.
const usable = (route?: ModelEndpoint): ModelEndpoint | undefined => route && supportedModelApi(route.api)
&& apiProtocol(route.api) === model.protocol && (!model.apiPreference || route.api === model.apiPreference)
&& new URL(route.baseUrl).origin === new URL(model.baseUrl).origin ? route : undefined
const savedEndpoint = usable(model.endpoint), generation = usable(verified)
const resolved = generation && generation.checkedAt >= (savedEndpoint?.checkedAt ?? 0) ? generation : savedEndpoint
?? usable(cachedConnection(model.baseUrl, model.protocol, readKey(model).key, model.apiPreference)?.endpoint)
?? modelEndpoint(model.baseUrl, model.apiPreference ?? inferModelApi(model.baseUrl, model.protocol))
return {
profile,
config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key,
@@ -286,12 +296,12 @@ export async function resolveModel(id?: string | null): Promise<ResolvedModel |
}
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
const registry = cache ?? await readRegistry(false), existing = registry.models.find(model => model.id === input.previousId)
const key = input.authMode === 'none' ? '' : typeof input.apiKey === 'string' && input.apiKey.trim() ? input.apiKey.replace(/\s/gu, '') : existing ? readKey(existing).key : ''
if (!key && input.authMode !== 'none') throw new Error('请输入 API Key')
return discoverModelConnection(input.baseUrl, 'anthropic', key, true)
return discoverModelConnection(input.baseUrl, input.protocol ?? (input.apiPreference ? apiProtocol(input.apiPreference) : 'anthropic'), key, true, input.apiPreference)
}
/** Test only the user-specified origin, negotiating paths/protocols before reporting success. */
@@ -309,12 +319,12 @@ export async function testModel(input: CustomModelInput): Promise<ModelTestResul
try {
const key = readKey(model).key
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.
// Stored routes continue to serve ordinary chat until a new generation probe succeeds.
const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses')
? { ...known, api: inferModelApi(model.baseUrl, model.protocol) } : known
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol), known].filter((value): value is ModelEndpoint => !!value)
? { ...known, api: model.apiPreference ?? inferModelApi(model.baseUrl, model.protocol) } : known
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol, model.apiPreference), known].filter((value): value is ModelEndpoint => !!value)
: [{ ...legacy, checkedAt: 0 }]
const tried = new Set<string>(); let status = 0
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'))
if (!valid) continue
const verified: ModelEndpoint = { api: endpoint.api, baseUrl: endpoint.baseUrl, checkedAt: Date.now(), verifiedBy: 'generation' }
if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified)
if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified, model.apiPreference)
return { ...result(true, '连接测试成功'), endpoint: verified }
}
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
+3
View File
@@ -9,6 +9,7 @@ import { app } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { modelBridgeHistoryDirectory } from './model-bridge/paths'
import type { ChatMessage, SessionMeta } from '@shared/types'
import { applyAssistantEvent } from '@shared/message-parts'
import type { SessionContext } from '@shared/workspaces'
@@ -238,6 +239,8 @@ export async function deleteSession(sessionId: string): Promise<void> {
const store = await load()
store.sessions = store.sessions.filter((s) => s.id !== sessionId)
await persist()
// The caller retires the kernel before deletion; no late response may recreate this cache.
await fs.rm(modelBridgeHistoryDirectory(app.getPath('userData'), sessionId), { recursive: true, force: true })
}
/** Keep the abandoned branch on disk before replacing the active conversation. */
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, ModelProtocol } from '@shared/models'
import { normalizeModelAddress } from '@shared/model-endpoint'
import type { CustomModelInput, CustomModelProfile, ModelTestResult, DiscoveredModel, SupportedModelApi } from '@shared/models'
import { modelProfileApi } from '@shared/model-support'
import { apiProtocol, normalizeModelAddress } from '@shared/model-endpoint'
import { resolveModelTokenLimits } from '@shared/model-token-limits'
import { modelLabel as t, sourceModelIcons } from './model-settings-source'
import SourceSelect, { type SourceSelectGroup } from './source-select/SourceSelect'
@@ -53,7 +54,7 @@ export function parseModelTokenLimit(value: string): number | undefined {
function defaultInput(providerId = 'custom'): CustomModelInput {
return { id: '', name: '', providerId: 'custom', model: '', baseUrl: '', authMode: 'api-key', apiKey: '',
supportsToolCall: false, supportsImages: false, supportsReasoning: false, onlyReasoning: false,
canDisableThinking: true, defaultEffort: '', supportedEfforts: [], protocol: 'anthropic', useCustomProtocol: false }
canDisableThinking: true, defaultEffort: '', supportedEfforts: [], protocol: 'anthropic', apiPreference: 'anthropic-messages', useCustomProtocol: false }
}
function Icon({ kind }: { kind: 'close' | 'edit' | 'delete' | 'link' | 'eye' | 'eyeClosed' | 'model' | 'down' | 'check' | 'loading' }): ReactNode {
const source = sourceModelIcons[kind]
@@ -92,7 +93,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
const update = (patch: Partial<CustomModelInput>): void => {
if ('apiKey' in patch) keyEdited.current = true
Object.keys(patch).forEach(key => dirty.current.add(key))
if (['baseUrl', 'apiKey', 'protocol', 'authMode'].some(key => key in patch)) {
if (['baseUrl', 'apiKey', 'protocol', 'apiPreference', 'authMode'].some(key => key in patch)) {
queryGeneration.current++; setFetching(false); setRemoteModels([]); patch.endpoint = undefined
}
setInput((current) => ({ ...current, ...patch })); setError(''); setTestError('')
@@ -105,7 +106,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
setModelListRequest(0)
queryGeneration.current++; applyMetadata.current = !model; dirty.current.clear(); setRemoteModels([]); setFetching(false)
const budget = model ? resolveModelTokenLimits(model) : undefined
setEditing(model ?? null); setInput(model ? { ...model, tokenBudgetVersion: 2, providerId: 'custom', protocol: 'anthropic', apiKey: '', previousId: model.id } : defaultInput()); setContextTokens(model ? String(budget?.contextWindowTokens ?? model.contextWindowTokens ?? model.maxInputTokens ?? '') : ''); setOutputTokens(model?.maxOutputTokens !== undefined ? String(model.maxOutputTokens) : '')
setEditing(model ?? null); setInput(model ? { ...model, tokenBudgetVersion: 2, providerId: 'custom', protocol: model.protocol ?? (model.apiPreference ? apiProtocol(model.apiPreference) : 'anthropic'), apiPreference: modelProfileApi(model), apiKey: '', previousId: model.id } : defaultInput()); setContextTokens(model ? String(budget?.contextWindowTokens ?? model.contextWindowTokens ?? model.maxInputTokens ?? '') : ''); setOutputTokens(model?.maxOutputTokens !== undefined ? String(model.maxOutputTokens) : '')
setShowKey(false); setError(''); setTestError(''); setOverwrite(null); setOpen(true)
if (model) setFailed((current) => { const next = new Set(current); next.delete(model.id); return next })
}
@@ -144,7 +145,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
if (!isModelEndpointValid(input.baseUrl) || !input.baseUrl.trim()) { setError('请填写有效的接口域名'); return }
const request = ++queryGeneration.current; setFetching(true); setError('')
try {
const result = await window.ycode.models.fetch({ baseUrl: input.baseUrl, apiKey: input.apiKey, previousId: editing?.id, protocol: input.protocol, authMode: credentialMode() })
const result = await window.ycode.models.fetch({ baseUrl: input.baseUrl, apiKey: input.apiKey, previousId: editing?.id, protocol: input.protocol, apiPreference: input.apiPreference, authMode: credentialMode() })
if (request !== queryGeneration.current) return
setRemoteModels(result.models); setInput(current => ({ ...current, endpoint: current.endpoint?.verifiedBy === 'generation' ? current.endpoint : result.endpoint }))
setNotice(result.models.length ? `已获取 ${result.models.length} 个模型,可选择或手动输入` : '接口返回空列表,可以手动输入模型名称')
@@ -179,7 +180,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
const request = queryGeneration.current
try {
const connectionChanged = !editing || data.model !== editing.model || normalizeModelAddress(data.baseUrl) !== normalizeModelAddress(editing.baseUrl)
|| data.protocol !== (editing.protocol ?? 'anthropic') || !!data.apiKey || data.authMode !== (editing.authMode ?? 'api-key')
|| data.apiPreference !== modelProfileApi(editing) || data.protocol !== (editing.protocol ?? 'anthropic') || !!data.apiKey || data.authMode !== (editing.authMode ?? 'api-key')
if (connectionChanged && data.endpoint?.verifiedBy !== 'generation') {
const result = await onTest(data)
if (request !== queryGeneration.current) return
@@ -230,7 +231,7 @@ export default function ModelSettingsPanel({ models, createRequest = 0, onCreate
<div className={cx('editor-body')}>
{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')}><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 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>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CustomModelProfile } from '@shared/models'
import type { ThinkingLevel } from '@shared/workspaces'
import { isSupportedModelProfile, modelProfileApi } from '@shared/model-support'
import { modelThinkingLevel } from '@shared/model-thinking'
import { ModelSelectorImpl, ModelSelectorRuntimeProvider, type ModelEffort, type ModelOption, type ModelSelection } from './source.mjs'
import { SourceHeaderEditIcon } from '../../../components/SourceChatHeaderIcons'
@@ -21,15 +22,15 @@ function toOption(model: CustomModelProfile): ModelOption {
const efforts = (model.supportedEfforts ?? []).filter((value): value is ModelEffort => (EFFORTS as string[]).includes(value))
return {
id: model.id,
name: (model.name || model.model || model.id) + (model.protocol === 'anthropic' ? '' : '(不支持)'),
name: (model.name || model.model || model.id) + (isSupportedModelProfile(model) ? '' : '(不支持)'),
// No icon/iconUrl: the recovered resolveModelIcon then falls back to the name-based brand registry,
// which is what the original conversation selector shows for these models.
supportsReasoning: !!model.supportsReasoning,
onlyReasoning: !!model.onlyReasoning,
canDisableThinking: model.canDisableThinking !== false,
...(model.defaultEffort && (EFFORTS as string[]).includes(model.defaultEffort) ? { defaultEffort: model.defaultEffort as ModelEffort } : {}),
// An unlisted effort set falls back to the levels this host can persist.
supportedEfforts: model.metadata ? efforts : efforts.length ? efforts : EFFORTS,
// Preserve the historical Anthropic UI; new APIs only advertise declared effort levels.
supportedEfforts: modelProfileApi(model) === 'anthropic-messages' && !model.metadata && !efforts.length ? EFFORTS : efforts,
maxInputTokens: model.contextWindowTokens ?? model.maxInputTokens
}
}
@@ -68,8 +69,8 @@ export default function SourceModelSelector({ models, selectedId, label, disable
useEffect(() => { setDraft(selection) }, [selection])
const commit = useCallback(async (next: ModelSelection): Promise<void> => {
const target = models.find((model) => model.id === next.id)
if (!target || target.protocol !== 'anthropic') {
setError('首版仅支持 Anthropic/Claude 兼容模型')
if (!target || !isSupportedModelProfile(target)) {
setError('仅支持 Anthropic Messages、OpenAI Chat Completions 和 Responses 模型')
return
}
if (lock.current) return
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ModelApi, ModelEndpoint, ModelProtocol } from './models'
import type { ModelApi, ModelEndpoint, ModelProtocol, SupportedModelApi } from './models'
export function normalizeModelAddress(raw: string): string {
const value = raw.trim()
@@ -37,9 +37,9 @@ export function endpointUrl(endpoint: ModelEndpoint, operation: 'models' | 'gene
return url.href
}
export function endpointCandidates(address: string, protocol: ModelProtocol = 'auto'): ModelEndpoint[] {
export function endpointCandidates(address: string, protocol: ModelProtocol = 'auto', apiPreference?: SupportedModelApi): ModelEndpoint[] {
const normalized = normalizeModelAddress(address), inferred = inferModelApi(normalized, protocol)
const apis: ModelApi[] = protocol === 'auto' ? [inferred, 'openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
const apis: ModelApi[] = apiPreference ? [apiPreference] : protocol === 'auto' ? [inferred, 'openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
: protocol === 'openai' ? [inferred, inferred === 'openai-responses' ? 'openai-completions' : 'openai-responses'] : [inferred]
const seen = new Set<string>(), result: ModelEndpoint[] = []
for (const api of apis) {
+21
View File
@@ -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 }
}
+3 -1
View File
@@ -2,6 +2,7 @@ import type { GatewayConfig } from './types'
export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini'
export type ModelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai'
export type SupportedModelApi = Exclude<ModelApi, 'google-generative-ai'>
export interface ModelEndpoint { api: ModelApi; baseUrl: string; checkedAt: number; verifiedBy?: 'models' | 'generation' }
export interface ModelPrice { input: number; output: number; cacheRead?: number; cacheWrite?: number; minContext?: number; maxContext?: number; crossedTiers?: boolean }
export interface ModelReasoningOption { type: 'effort' | 'budget_tokens' | 'toggle'; values?: string[]; min?: number; max?: number }
@@ -16,7 +17,7 @@ export interface ModelMetadata {
price?: ModelPrice; temperature?: boolean
}
export interface ModelMetadataStatus { fetchedAt: string; sha256: string; providers: number; models: number; source: string }
export interface ModelDiscoveryInput { baseUrl: string; protocol?: ModelProtocol; apiKey?: string; previousId?: string; authMode?: 'api-key' | 'none' }
export interface ModelDiscoveryInput { baseUrl: string; protocol?: ModelProtocol; apiPreference?: SupportedModelApi; apiKey?: string; previousId?: string; authMode?: 'api-key' | 'none' }
export interface DiscoveredModel { id: string; name: string }
export interface ModelDiscoveryResult { models: DiscoveredModel[]; endpoint: ModelEndpoint }
@@ -30,6 +31,7 @@ export interface CustomModelProfile {
keyStorage: 'encrypted' | 'plaintext' | 'none'
authMode?: 'api-key' | 'none'
protocol?: ModelProtocol
apiPreference?: SupportedModelApi
endpoint?: ModelEndpoint
metadata?: ModelMetadata
contextWindowTokens?: number