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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { BridgeError, record, type ConversionContext, type Json } from './protocol'
|
||||
|
||||
const MAX_BYTES = 16 * 1024 * 1024
|
||||
const queues = new Map<string, Promise<void>>()
|
||||
function failure(): BridgeError { return new BridgeError(502, 'Responses reasoning history could not be saved or read. Restore the local history file or start a new conversation.') }
|
||||
function validItem(x: unknown): boolean {
|
||||
if (!record(x)) return false
|
||||
if (x.type === 'reasoning') return typeof x.id === 'string' && (x.encrypted_content === undefined || typeof x.encrypted_content === 'string') && Array.isArray(x.summary) && x.summary.every((s: unknown) => record(s) && s.type === 'summary_text' && typeof s.text === 'string')
|
||||
if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string'
|
||||
return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string')
|
||||
}
|
||||
async function load(file: string): Promise<Map<string, Json[] | null>> {
|
||||
try {
|
||||
const stat = await lstat(file)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure()
|
||||
const value = JSON.parse(await readFile(file, 'utf8'))
|
||||
if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure()
|
||||
const entries = new Map<string, Json[] | null>()
|
||||
for (const entry of value.entries) {
|
||||
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || entries.has(entry[0])) throw failure()
|
||||
if (!/^[a-f0-9]{64}:[a-f0-9]{64}$/.test(entry[0])) throw failure()
|
||||
const items = entry[1]
|
||||
if (items !== null && (!Array.isArray(items) || !items.every(validItem))) throw failure()
|
||||
entries.set(entry[0], items)
|
||||
}
|
||||
return entries
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
|
||||
throw failure()
|
||||
}
|
||||
}
|
||||
/** Only the trusted main-process caller supplies this path. No upstream credential is serialized. */
|
||||
export async function createConversionContext(historyFile?: string): Promise<ConversionContext> {
|
||||
if (!historyFile) return { responsesHistory: new Map() }
|
||||
await queues.get(historyFile)
|
||||
const context: ConversionContext = { responsesHistory: await load(historyFile) }
|
||||
context.persist = async () => {
|
||||
const snapshot = new Map(context.responsesHistory)
|
||||
const previous = queues.get(historyFile) ?? Promise.resolve()
|
||||
const pending = previous.catch(() => {}).then(async () => {
|
||||
let temp: string | undefined
|
||||
try {
|
||||
const merged = await load(historyFile)
|
||||
for (const [key, value] of snapshot) {
|
||||
const existing = merged.get(key)
|
||||
merged.set(key, existing === null || existing && JSON.stringify(existing) !== JSON.stringify(value) ? null : value)
|
||||
}
|
||||
if (merged.size > 1024) throw failure()
|
||||
const data = JSON.stringify({ version: 1, entries: [...merged] })
|
||||
if (Buffer.byteLength(data) > MAX_BYTES) throw failure()
|
||||
await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 })
|
||||
temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp`
|
||||
await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
||||
await rename(temp, historyFile); temp = undefined
|
||||
for (const [key, value] of merged) {
|
||||
const current = context.responsesHistory.get(key)
|
||||
context.responsesHistory.set(key, current === null || current && JSON.stringify(current) !== JSON.stringify(value) ? null : value)
|
||||
}
|
||||
} catch { throw failure() }
|
||||
finally { if (temp) await unlink(temp).catch(() => {}) }
|
||||
})
|
||||
queues.set(historyFile, pending)
|
||||
try { await pending }
|
||||
finally { if (queues.get(historyFile) === pending) queues.delete(historyFile) }
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { ResolvedModel } from '../../shared/models'
|
||||
import { endpointUrl, modelHeaders } from '../../shared/model-endpoint'
|
||||
import { BridgeError, convertRequest, convertResponse, record, type ConversionContext, type Json } from './protocol'
|
||||
import { streamResponse } from './stream'
|
||||
import { createConversionContext } from './history'
|
||||
|
||||
const BODY_LIMIT = 16 * 1024 * 1024
|
||||
function json(response: ServerResponse, status: number, body: Json): void {
|
||||
if (response.destroyed || response.writableEnded) return
|
||||
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body))
|
||||
}
|
||||
async function readBody(request: IncomingMessage): Promise<Json> {
|
||||
const chunks: Buffer[] = []; let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length
|
||||
if (size > BODY_LIMIT) throw new BridgeError(413, 'Messages request exceeds 16 MiB limit')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
try { const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (!record(body)) throw new Error(); return body }
|
||||
catch { throw new BridgeError(400, 'Invalid JSON request') }
|
||||
}
|
||||
async function readUpstream(response: Response): Promise<Json> {
|
||||
if (!response.body) throw new BridgeError(502, 'Empty upstream response')
|
||||
const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read(); if (next.done) break
|
||||
size += next.value.byteLength
|
||||
if (size > BODY_LIMIT * 2) throw new BridgeError(502, 'Upstream response exceeds limit')
|
||||
chunks.push(next.value)
|
||||
}
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
if (!record(value)) throw new Error()
|
||||
return value
|
||||
} catch (error) { if (error instanceof BridgeError) throw error; throw new BridgeError(502, 'Invalid upstream JSON response') }
|
||||
finally { await reader.cancel().catch(() => {}); reader.releaseLock() }
|
||||
}
|
||||
export interface ModelBridgeLease { baseUrl: string; apiKey: string; close(): Promise<void> }
|
||||
|
||||
/** One frozen route per SDK query. No shared mutable current-model route or credential logging. */
|
||||
export async function acquireModelBridge(source: ResolvedModel, options: { historyFile?: string } = {}): Promise<ModelBridgeLease> {
|
||||
const model: ResolvedModel = structuredClone(source)
|
||||
const api = model.config.api
|
||||
if (api !== 'openai-completions' && api !== 'openai-responses') throw new Error('Protocol bridge supports only OpenAI Chat Completions and Responses')
|
||||
const endpoint = { api, baseUrl: model.config.baseUrl.replace(/\/+$/, ''), checkedAt: 0 }
|
||||
const url = endpointUrl(endpoint, 'generate')
|
||||
const parsed = new URL(url)
|
||||
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error('Invalid bridge upstream endpoint')
|
||||
const apiKey = randomBytes(32).toString('hex'), expected = Buffer.from(apiKey)
|
||||
const controllers = new Set<AbortController>()
|
||||
const requests = new Set<Promise<void>>()
|
||||
const context: ConversionContext = await createConversionContext(api === 'openai-responses' ? options.historyFile : undefined)
|
||||
let closed = false, port = 0
|
||||
const server = createServer(async (request, response) => {
|
||||
let finishRequest!: () => void
|
||||
const requestFinished = new Promise<void>(resolve => { finishRequest = resolve })
|
||||
requests.add(requestFinished)
|
||||
const controller = new AbortController()
|
||||
controllers.add(controller)
|
||||
const abort = () => controller.abort()
|
||||
request.once('aborted', abort)
|
||||
response.once('close', abort)
|
||||
const timeout = setTimeout(abort, 10 * 60 * 1000); timeout.unref()
|
||||
try {
|
||||
if (closed) throw new BridgeError(503, 'Model bridge lease is closed')
|
||||
if (request.headers.host !== `127.0.0.1:${port}`) throw new BridgeError(403, 'Invalid loopback host')
|
||||
if (request.headers.origin) throw new BridgeError(403, 'Browser origins are not permitted')
|
||||
const supplied = request.headers['x-api-key'] ?? request.headers.authorization?.replace(/^Bearer /, '')
|
||||
const actual = Buffer.from(typeof supplied === 'string' ? supplied : '')
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new BridgeError(401, 'Invalid bridge token')
|
||||
if (request.method !== 'POST') throw new BridgeError(405, 'Only POST is supported')
|
||||
// Anthropic SDK may attach its beta query marker; no other query/routing fields are accepted.
|
||||
const path = request.url?.replace(/\?beta=true$/, '')
|
||||
if (path !== '/v1/messages' && path !== '/v1/messages/count_tokens') throw new BridgeError(404, 'Unknown bridge route')
|
||||
if (!request.headers['content-type']?.startsWith('application/json')) throw new BridgeError(415, 'JSON content type required')
|
||||
if (Number(request.headers['content-length']) > BODY_LIMIT) throw new BridgeError(413, 'Messages request exceeds 16 MiB limit')
|
||||
const body = await readBody(request)
|
||||
const requestContext: ConversionContext = { responsesHistory: context.responsesHistory, persist: context.persist }
|
||||
if (path.endsWith('/count_tokens')) {
|
||||
// Validate the same supported content before returning a deliberately labeled estimate.
|
||||
convertRequest({ ...body, max_tokens: body.max_tokens ?? 1 }, model, requestContext)
|
||||
response.setHeader('x-ycode-token-count-source', 'conservative-estimate-utf8-bytes')
|
||||
json(response, 200, { input_tokens: Buffer.byteLength(JSON.stringify({ system: body.system, messages: body.messages, tools: body.tools }), 'utf8'), estimated: true, source: 'conservative-estimate-utf8-bytes' })
|
||||
return
|
||||
}
|
||||
const payload = convertRequest(body, model, requestContext)
|
||||
if (controller.signal.aborted) return
|
||||
const upstream = await fetch(url, { method: 'POST', headers: modelHeaders(api, model.config.authMode === 'none' ? '' : model.config.apiKey), body: JSON.stringify(payload), signal: controller.signal, redirect: 'error' })
|
||||
if (!upstream.ok) {
|
||||
await upstream.body?.cancel()
|
||||
// Never expose upstream response bodies: they may echo credentials or request content.
|
||||
throw new BridgeError(upstream.status === 429 ? 429 : upstream.status === 401 || upstream.status === 403 ? 401 : 502, `Upstream ${api} request failed (HTTP ${upstream.status})`)
|
||||
}
|
||||
if (payload.stream) {
|
||||
if (!upstream.headers.get('content-type')?.includes('text/event-stream')) { await upstream.body?.cancel(); throw new BridgeError(502, 'Upstream did not return an SSE stream') }
|
||||
await streamResponse(upstream, response, model.config.model, api === 'openai-responses', requestContext)
|
||||
} else {
|
||||
const converted = convertResponse(await readUpstream(upstream), model.config.model, api === 'openai-responses', requestContext)
|
||||
await context.persist?.()
|
||||
json(response, 200, converted)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!response.headersSent && !response.destroyed) {
|
||||
const status = error instanceof BridgeError ? error.status : controller.signal.aborted ? 504 : 502
|
||||
json(response, status, { type: 'error', error: { type: status < 500 ? 'invalid_request_error' : 'api_error', message: error instanceof BridgeError ? error.message : controller.signal.aborted ? 'Model bridge request canceled or timed out' : 'Model bridge upstream request failed' } })
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout); controller.abort(); controllers.delete(controller)
|
||||
request.off('aborted', abort); response.off('close', abort)
|
||||
requests.delete(requestFinished); finishRequest()
|
||||
}
|
||||
})
|
||||
server.requestTimeout = 60_000; server.headersTimeout = 15_000; server.keepAliveTimeout = 1_000
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
|
||||
})
|
||||
port = (server.address() as AddressInfo).port
|
||||
let closing: Promise<void> | undefined
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, apiKey, close() {
|
||||
return closing ??= (async () => {
|
||||
closed = true; for (const controller of controllers) controller.abort()
|
||||
const stopped = new Promise<void>(resolve => server.close(() => resolve()))
|
||||
server.closeAllConnections()
|
||||
await Promise.all([stopped, ...requests])
|
||||
context.responsesHistory.clear()
|
||||
})()
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Session IDs never become path components; deleting a session removes its bridge-only history. */
|
||||
export function modelBridgeHistoryDirectory(userData: string, sessionId: string): string {
|
||||
return join(userData, 'model-bridge-history', createHash('sha256').update(sessionId).digest('hex'))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { ResolvedModel } from '../../shared/models'
|
||||
import { openAICompletionTokenField } from '../../shared/model-endpoint'
|
||||
|
||||
/** Protocol-only JSON: never execute tools or accept request-controlled routing. */
|
||||
export type Json = Record<string, any>
|
||||
export class BridgeError extends Error {
|
||||
constructor(public status: number, message: string) { super(message) }
|
||||
}
|
||||
export function requireValue(ok: unknown, message: string): asserts ok {
|
||||
if (!ok) throw new BridgeError(400, message)
|
||||
}
|
||||
export function record(value: unknown): value is Json { return !!value && typeof value === 'object' && !Array.isArray(value) }
|
||||
function text(value: unknown, label: string): string { requireValue(typeof value === 'string', `Invalid ${label}`); return value }
|
||||
function blocks(content: unknown): Json[] {
|
||||
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||
requireValue(Array.isArray(content) && content.every(record), 'Unsupported message content')
|
||||
return content
|
||||
}
|
||||
function imageUrl(b: Json): string {
|
||||
const s = b.source
|
||||
requireValue(record(s), 'Invalid image source')
|
||||
if (s.type === 'base64') {
|
||||
requireValue(/^image\/(png|jpeg|gif|webp)$/.test(s.media_type) && typeof s.data === 'string' && /^[A-Za-z0-9+/\r\n]*={0,2}$/.test(s.data), 'Invalid image data')
|
||||
return `data:${s.media_type};base64,${s.data}`
|
||||
}
|
||||
requireValue(s.type === 'url' && typeof s.url === 'string' && /^https?:\/\//.test(s.url), 'Unsupported image source')
|
||||
return s.url
|
||||
}
|
||||
export interface ConversionContext { responsesHistory: Map<string, Json[] | null>; persist?: () => Promise<void>; responsePrefix?: string }
|
||||
/** Ignore SDK cache hints and normalize adjacent text blocks, never a reasoning-text-only key. */
|
||||
export function historyKey(content: Json[]): string {
|
||||
const normalized: Json[] = []
|
||||
for (const b of content) {
|
||||
requireValue(b.type !== 'thinking' || !b.signature, 'Signed or foreign thinking history cannot be translated')
|
||||
const item = b.type === 'text' ? { type: b.type, text: b.text } : b.type === 'thinking' ? { type: b.type, thinking: b.thinking } : b.type === 'tool_use' ? { type: b.type, id: b.id, name: b.name, input: b.input } : b.type === 'image' ? { type: b.type, source: b.source } : b.type === 'tool_result' ? { type: b.type, tool_use_id: b.tool_use_id, is_error: !!b.is_error, content: JSON.parse(historyKey(blocks(b.content ?? ''))) } : b
|
||||
const previous = normalized.at(-1)
|
||||
if (previous && previous.type === item.type && (item.type === 'text' || item.type === 'thinking')) previous[item.type] += item[item.type]
|
||||
else normalized.push(item)
|
||||
}
|
||||
return JSON.stringify(normalized)
|
||||
}
|
||||
function replayKey(prefix: string, content: Json[]): string {
|
||||
return createHash('sha256').update(prefix).digest('hex') + ':' + createHash('sha256').update(historyKey(content)).digest('hex')
|
||||
}
|
||||
export function convertRequest(body: Json, model: ResolvedModel, context: ConversionContext): Json {
|
||||
const { config: c, profile: p } = model, responses = c.api === 'openai-responses'
|
||||
requireValue(Array.isArray(body.messages), 'messages must be an array')
|
||||
for (const key of ['mcp_servers', 'container', 'context_management']) requireValue(body[key] === undefined, `Unsupported Messages field: ${key}`)
|
||||
const input: Json[] = []
|
||||
const prefix: Json[] = []
|
||||
context.responsePrefix = undefined
|
||||
if (body.system !== undefined) {
|
||||
const system = blocks(body.system)
|
||||
requireValue(system.every(b => b.type === 'text' && typeof b.text === 'string'), 'Only text system blocks are supported')
|
||||
if (system.length) input.push({ role: 'system', content: system.map(b => b.text).join('\n\n') })
|
||||
}
|
||||
for (const message of body.messages) {
|
||||
requireValue(record(message) && ['user', 'assistant'].includes(message.role), 'Invalid message role')
|
||||
const role = message.role, content = blocks(message.content)
|
||||
const prior = JSON.stringify(prefix)
|
||||
prefix.push({ role, content: JSON.parse(historyKey(content)) })
|
||||
if (responses && role === 'assistant') {
|
||||
const key = replayKey(prior, content), saved = context.responsesHistory.get(key)
|
||||
requireValue(saved !== null, 'Ambiguous Responses reasoning history; start a new conversation')
|
||||
if (saved) { input.push(...saved); continue }
|
||||
// A recognizable response with a different prefix is a compaction/fork boundary,
|
||||
// not permission to discard opaque reasoning that has no visible thinking block.
|
||||
if ([...context.responsesHistory.keys()].some(existing => existing.endsWith(key.slice(64)))) throw new BridgeError(400, 'Responses reasoning history changed after compaction or fork; start a new conversation')
|
||||
}
|
||||
let parts: Json[] = [], calls: Json[] = [], reasoning = ''
|
||||
const toolImages: Json[] = []
|
||||
const flush = () => {
|
||||
if (parts.length || calls.length || reasoning) {
|
||||
input.push({ role, content: parts.length ? parts : '', ...(!responses && calls.length ? { tool_calls: calls } : {}), ...(!responses && reasoning ? { reasoning_content: reasoning } : {}) })
|
||||
parts = []; calls = []; reasoning = ''
|
||||
}
|
||||
}
|
||||
for (const b of content) {
|
||||
switch (b.type) {
|
||||
case 'text': parts.push({ type: responses ? role === 'assistant' ? 'output_text' : 'input_text' : 'text', text: text(b.text, 'text') }); break
|
||||
case 'image':
|
||||
requireValue(role === 'user', 'Assistant image history is unsupported')
|
||||
requireValue(p.supportsImages !== false, 'Selected model does not support images')
|
||||
parts.push(responses ? { type: 'input_image', image_url: imageUrl(b), detail: 'auto' } : { type: 'image_url', image_url: { url: imageUrl(b) } }); break
|
||||
case 'tool_use': {
|
||||
requireValue(role === 'assistant' && record(b.input), 'Invalid tool_use')
|
||||
const id = text(b.id, 'tool id'), name = text(b.name, 'tool name'), args = JSON.stringify(b.input)
|
||||
if (responses) { flush(); input.push({ type: 'function_call', call_id: id, name, arguments: args }) }
|
||||
else calls.push({ id, type: 'function', function: { name, arguments: args } })
|
||||
break
|
||||
}
|
||||
case 'tool_result': {
|
||||
requireValue(role === 'user', 'tool_result must be a user message'); flush()
|
||||
const result = blocks(b.content ?? '')
|
||||
requireValue(result.every(x => x.type === 'text' && typeof x.text === 'string' || x.type === 'image'), 'Only text and image tool results are supported')
|
||||
const output = result.filter(x => x.type === 'text').map(x => x.text).join('\n')
|
||||
for (const image of result.filter(x => x.type === 'image')) {
|
||||
requireValue(p.supportsImages !== false, 'Selected model does not support images')
|
||||
// Chat tool messages cannot contain image_url. Preserve the actual image in a
|
||||
// following user message, explicitly associated with its original tool call.
|
||||
toolImages.push({ type: responses ? 'input_text' : 'text', text: `Image from tool result ${text(b.tool_use_id, 'tool result id')}:` })
|
||||
toolImages.push(responses ? { type: 'input_image', image_url: imageUrl(image), detail: 'auto' } : { type: 'image_url', image_url: { url: imageUrl(image) } })
|
||||
}
|
||||
const value = b.is_error ? `Tool error:\n${output}` : output
|
||||
input.push(responses ? { type: 'function_call_output', call_id: text(b.tool_use_id, 'tool result id'), output: value } : { role: 'tool', tool_call_id: text(b.tool_use_id, 'tool result id'), content: value })
|
||||
break
|
||||
}
|
||||
case 'thinking':
|
||||
requireValue(role === 'assistant' && !b.signature, 'Signed or foreign thinking history cannot be translated')
|
||||
if (responses) {
|
||||
throw new BridgeError(400, 'Responses reasoning history is not available in this lease; start a new conversation')
|
||||
} else reasoning += text(b.thinking, 'thinking')
|
||||
break
|
||||
default: throw new BridgeError(400, `Unsupported Messages content type: ${String(b.type).slice(0, 60)}`)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if (toolImages.length) input.push({ role: 'user', content: toolImages })
|
||||
}
|
||||
context.responsePrefix = JSON.stringify(prefix)
|
||||
const output: Json = { model: c.model, [responses ? 'input' : 'messages']: input, stream: body.stream === true }
|
||||
if (responses) { output.store = false; output.include = ['reasoning.encrypted_content'] }
|
||||
else if (output.stream) output.stream_options = { include_usage: true }
|
||||
const requestedMax = body.max_tokens
|
||||
requireValue(Number.isInteger(requestedMax) && requestedMax > 0, 'max_tokens must be a positive integer')
|
||||
const max = c.maxOutputTokens ? Math.min(c.maxOutputTokens, requestedMax) : requestedMax
|
||||
output[responses ? 'max_output_tokens' : openAICompletionTokenField(c.model)] = max
|
||||
if (c.temperatureSupported !== false) output.temperature = typeof body.temperature === 'number' ? body.temperature : c.temperature
|
||||
if (body.top_p !== undefined) output.top_p = body.top_p
|
||||
if (body.stop_sequences?.length) {
|
||||
requireValue(!responses, 'Responses does not support stop_sequences')
|
||||
output.stop = body.stop_sequences
|
||||
}
|
||||
if (body.tools !== undefined) {
|
||||
requireValue(Array.isArray(body.tools), 'tools must be an array')
|
||||
requireValue(p.supportsToolCall !== false || !body.tools.length, 'Selected model does not support tools')
|
||||
output.tools = body.tools.map((tool: Json) => {
|
||||
requireValue(record(tool) && (!tool.type || tool.type === 'custom') && typeof tool.name === 'string' && record(tool.input_schema), 'Only custom function tools are supported')
|
||||
const fn = { name: tool.name, description: tool.description, parameters: tool.input_schema }
|
||||
return responses ? { type: 'function', ...fn, strict: false } : { type: 'function', function: fn }
|
||||
})
|
||||
}
|
||||
if (body.tool_choice) {
|
||||
const choice = body.tool_choice
|
||||
requireValue(record(choice) && ['auto', 'any', 'none', 'tool'].includes(choice.type), 'Unsupported tool_choice')
|
||||
output.tool_choice = choice.type === 'tool' ? responses ? { type: 'function', name: text(choice.name, 'tool choice') } : { type: 'function', function: { name: text(choice.name, 'tool choice') } } : choice.type === 'any' ? 'required' : choice.type
|
||||
if (choice.disable_parallel_tool_use !== undefined) output.parallel_tool_calls = !choice.disable_parallel_tool_use
|
||||
}
|
||||
// The lease contains the effective UI/metadata decision. SDK-specific thinking budgets
|
||||
// are never forwarded as if they were OpenAI effort values.
|
||||
if (c.supportsReasoning && c.thinkingLevel && c.thinkingLevel !== 'off' && c.thinkingLevel !== 'auto') {
|
||||
const effort = c.thinkingLevel
|
||||
requireValue(!p.supportedEfforts?.length || p.supportedEfforts.includes(effort), 'Selected model does not support this reasoning effort')
|
||||
if (responses) output.reasoning = { effort, summary: 'auto' }
|
||||
else output.reasoning_effort = effort
|
||||
} else if (c.supportsReasoning && c.thinkingLevel === 'off' && p.canDisableThinking !== false) {
|
||||
if (responses) output.reasoning = { effort: 'none' }
|
||||
else output.reasoning_effort = 'none'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function usageFrom(value: Json | undefined, responses: boolean): Json {
|
||||
if (!value) return {}
|
||||
const result: Json = {}
|
||||
const input = value[responses ? 'input_tokens' : 'prompt_tokens'], output = value[responses ? 'output_tokens' : 'completion_tokens']
|
||||
if (Number.isFinite(input)) result.input_tokens = input
|
||||
if (Number.isFinite(output)) result.output_tokens = output
|
||||
// OpenAI prompt/input totals already include cached tokens; subtract to match Anthropic.
|
||||
const cached = value[responses ? 'input_tokens_details' : 'prompt_tokens_details']?.cached_tokens
|
||||
if (Number.isFinite(cached) && Number.isFinite(input) && cached <= input) { result.input_tokens = input - cached; result.cache_read_input_tokens = cached }
|
||||
return result
|
||||
}
|
||||
export function parseArguments(value: unknown): Json {
|
||||
try { const parsed = JSON.parse(text(value, 'tool arguments')); requireValue(record(parsed), 'Tool arguments must be an object'); return parsed }
|
||||
catch { throw new BridgeError(502, 'Upstream returned invalid tool arguments') }
|
||||
}
|
||||
export function stopReason(reason: unknown): string {
|
||||
if (reason === 'length' || reason === 'max_output_tokens') return 'max_tokens'
|
||||
if (reason === 'tool_calls' || reason === 'function_call') return 'tool_use'
|
||||
if (reason === 'stop' || reason === 'completed' || reason === null) return 'end_turn'
|
||||
throw new BridgeError(502, 'Upstream returned an unsupported finish reason')
|
||||
}
|
||||
export function convertResponse(data: Json, model: string, responses: boolean, context: ConversionContext): Json {
|
||||
const content: Json[] = []
|
||||
let stop: string
|
||||
if (responses) {
|
||||
requireValue(Array.isArray(data.output), 'Invalid Responses output')
|
||||
if (data.status !== 'completed' && data.status !== 'incomplete') throw new BridgeError(502, 'Upstream response did not complete')
|
||||
for (const item of data.output) {
|
||||
if (item.type === 'message') for (const part of item.content ?? []) {
|
||||
if (part.type !== 'output_text') throw new BridgeError(502, 'Unsupported upstream output content')
|
||||
content.push({ type: 'text', text: part.text })
|
||||
}
|
||||
else if (item.type === 'function_call') content.push({ type: 'tool_use', id: item.call_id, name: item.name, input: parseArguments(item.arguments) })
|
||||
else if (item.type === 'reasoning') {
|
||||
const thinking = (item.summary ?? []).map((x: Json) => { if (x.type !== 'summary_text') throw new BridgeError(502, 'Unsupported reasoning summary'); return x.text }).join('')
|
||||
if (thinking) { content.push({ type: 'thinking', thinking }) }
|
||||
// Hidden reasoning is retained with the complete assistant response, not a fabricated signature.
|
||||
} else throw new BridgeError(502, 'Unsupported upstream output item')
|
||||
}
|
||||
stop = data.status === 'incomplete' ? stopReason(data.incomplete_details?.reason) : content.some(x => x.type === 'tool_use') ? 'tool_use' : 'end_turn'
|
||||
} else {
|
||||
const choice = data.choices?.[0], message = choice?.message
|
||||
requireValue(record(message), 'Invalid Chat Completions response')
|
||||
if (message.refusal) throw new BridgeError(502, 'Upstream refused the request')
|
||||
if (message.reasoning_content) content.push({ type: 'thinking', thinking: message.reasoning_content })
|
||||
if (message.content) { requireValue(typeof message.content === 'string', 'Unsupported upstream message content'); content.push({ type: 'text', text: message.content }) }
|
||||
for (const call of message.tool_calls ?? []) content.push({ type: 'tool_use', id: call.id, name: call.function.name, input: parseArguments(call.function.arguments) })
|
||||
stop = stopReason(choice.finish_reason)
|
||||
}
|
||||
if (responses && data.output.some((x: Json) => x.type === 'reasoning')) {
|
||||
const key = replayKey(context.responsePrefix ?? '[]', content), previous = context.responsesHistory.get(key)
|
||||
const saved = previous === null || previous && JSON.stringify(previous) !== JSON.stringify(data.output) ? null : data.output
|
||||
if (!context.responsesHistory.has(key) && context.responsesHistory.size >= 1024) throw new BridgeError(502, 'Responses reasoning replay limit reached; start a new conversation')
|
||||
if (Buffer.byteLength(JSON.stringify([...context.responsesHistory, [key, saved]])) > 16 * 1024 * 1024) throw new BridgeError(502, 'Responses reasoning history exceeds its storage limit; start a new conversation')
|
||||
context.responsesHistory.set(key, saved)
|
||||
}
|
||||
return { id: data.id || 'msg_bridge', type: 'message', role: 'assistant', model, content, stop_reason: stop, stop_sequence: null, usage: usageFrom(data.usage, responses) }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import { BridgeError, convertResponse, historyKey, parseArguments, record, stopReason, usageFrom, type ConversionContext, type Json } from './protocol'
|
||||
|
||||
/** Incremental UTF-8 and SSE framing; handles CRLF split across reads and multi-line data. */
|
||||
export async function* readSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<Json | '[DONE]'> {
|
||||
const reader = body.getReader(), decoder = new TextDecoder()
|
||||
let buffer = '', data: string[] = [], eventSize = 0
|
||||
const parse = (): Json | '[DONE]' | undefined => {
|
||||
if (!data.length) return
|
||||
const value = data.join('\n'); data = []; eventSize = 0
|
||||
if (value === '[DONE]') return value
|
||||
try { const obj = JSON.parse(value); if (!record(obj)) throw new Error(); return obj }
|
||||
catch { throw new BridgeError(502, 'Invalid upstream SSE JSON') }
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })
|
||||
if (buffer.length > 8 * 1024 * 1024) throw new BridgeError(502, 'Upstream SSE event exceeds limit')
|
||||
let newline: number
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, ''); buffer = buffer.slice(newline + 1)
|
||||
if (!line) { const event = parse(); if (event !== undefined) yield event }
|
||||
else if (line.startsWith('data:')) {
|
||||
eventSize += line.length
|
||||
if (eventSize > 8 * 1024 * 1024) throw new BridgeError(502, 'Upstream SSE event exceeds limit')
|
||||
data.push(line.slice(5).replace(/^ /, ''))
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
if (buffer.startsWith('data:')) data.push(buffer.slice(5).replace(/^ /, '').replace(/\r$/, ''))
|
||||
const event = parse(); if (event !== undefined) yield event
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock() }
|
||||
}
|
||||
|
||||
class AnthropicEvents {
|
||||
private next = 0
|
||||
private contentBytes = 0
|
||||
private blocks = new Map<string, { index: number; type: string; value: string; closed: boolean; meta: Json }>()
|
||||
constructor(private response: ServerResponse, model: string) {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' })
|
||||
this.send('message_start', { message: { id: `msg_bridge_${Date.now()}`, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: {} } })
|
||||
}
|
||||
send(type: string, fields: Json = {}): void { if (this.response.writableLength > 8 * 1024 * 1024) throw new BridgeError(502, 'Downstream stream buffer exceeds limit'); if (!this.response.destroyed) this.response.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}\n\n`) }
|
||||
add(key: string, type: 'text' | 'thinking' | 'tool_use', delta: string, meta: Json = {}): void {
|
||||
let block = this.blocks.get(key)
|
||||
if (!block) {
|
||||
if (this.next >= 1024) throw new BridgeError(502, 'Upstream emitted too many content blocks')
|
||||
block = { index: this.next++, type, value: '', closed: false, meta }; this.blocks.set(key, block)
|
||||
this.send('content_block_start', { index: block.index, content_block: type === 'tool_use' ? { type, id: meta.id, name: meta.name, input: {} } : { type, [type]: '' } })
|
||||
}
|
||||
if (block.closed) throw new BridgeError(502, 'Upstream resumed a closed content block')
|
||||
this.contentBytes += Buffer.byteLength(delta)
|
||||
if (this.contentBytes > 32 * 1024 * 1024) throw new BridgeError(502, 'Upstream stream content exceeds limit')
|
||||
block.value += delta
|
||||
if (block.value.length > 16 * 1024 * 1024) throw new BridgeError(502, 'Upstream content block exceeds limit')
|
||||
if (delta) this.send('content_block_delta', { index: block.index, delta: type === 'tool_use' ? { type: 'input_json_delta', partial_json: delta } : { type: `${type}_delta`, [type]: delta } })
|
||||
}
|
||||
close(key: string): void {
|
||||
const block = this.blocks.get(key)
|
||||
if (block && !block.closed) {
|
||||
if (block.type === 'tool_use') parseArguments(block.value)
|
||||
this.send('content_block_stop', { index: block.index }); block.closed = true
|
||||
}
|
||||
}
|
||||
content(): Json[] {
|
||||
return [...this.blocks.values()].map(b => b.type === 'tool_use' ? { type: b.type, id: b.meta.id, name: b.meta.name, input: parseArguments(b.value) } : { type: b.type, [b.type]: b.value })
|
||||
}
|
||||
finish(stop: string, usage: Json): void {
|
||||
for (const key of this.blocks.keys()) this.close(key)
|
||||
this.send('message_delta', { delta: { stop_reason: stop, stop_sequence: null }, usage })
|
||||
this.send('message_stop'); this.response.end()
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamResponse(upstream: Response, response: ServerResponse, model: string, responses: boolean, context: ConversionContext): Promise<void> {
|
||||
if (!upstream.body) throw new BridgeError(502, 'Upstream has no stream body')
|
||||
const events = new AnthropicEvents(response, model)
|
||||
let completed = false, usage: Json = {}, finish: unknown
|
||||
const tools = new Map<number, { id: string; name: string; args: string; started: boolean }>()
|
||||
const items = new Map<number, Json>()
|
||||
try {
|
||||
for await (const frame of readSSE(upstream.body)) {
|
||||
if (frame === '[DONE]') { if (!responses && finish !== undefined) completed = true; break }
|
||||
if (frame.error || frame.type === 'error' || frame.type === 'response.failed') throw new BridgeError(502, 'Upstream reported a streaming error')
|
||||
if (!responses) {
|
||||
if (frame.usage) usage = usageFrom(frame.usage, false)
|
||||
for (const choice of frame.choices ?? []) {
|
||||
if (choice.index !== undefined && choice.index !== 0) throw new BridgeError(502, 'Multiple upstream choices are unsupported')
|
||||
const delta = choice.delta ?? {}
|
||||
if (delta.refusal || delta.audio || delta.function_call) throw new BridgeError(502, 'Unsupported upstream stream content')
|
||||
if (delta.reasoning_content) events.add('thinking', 'thinking', delta.reasoning_content)
|
||||
if (delta.content) events.add('text', 'text', delta.content)
|
||||
for (const call of delta.tool_calls ?? []) {
|
||||
const index = call.index
|
||||
if (!Number.isInteger(index)) throw new BridgeError(502, 'Invalid upstream tool index')
|
||||
const tool = tools.get(index) ?? { id: '', name: '', args: '', started: false }
|
||||
if (tool.started && (call.id && call.id !== tool.id || call.function?.name)) throw new BridgeError(502, 'Upstream changed an active tool identity')
|
||||
if (call.id) tool.id = call.id
|
||||
if (call.function?.name) tool.name += call.function.name
|
||||
const args = call.function?.arguments ?? ''
|
||||
tool.args += args
|
||||
if (!tool.started && tool.id && tool.name && tool.args) { events.add(`tool:${index}`, 'tool_use', tool.args, tool); tool.started = true }
|
||||
else if (tool.started) events.add(`tool:${index}`, 'tool_use', args, tool)
|
||||
tools.set(index, tool)
|
||||
}
|
||||
if (choice.finish_reason !== null && choice.finish_reason !== undefined) finish = choice.finish_reason
|
||||
}
|
||||
} else {
|
||||
const key = `item:${frame.output_index}`
|
||||
switch (frame.type) {
|
||||
case 'response.output_item.added':
|
||||
items.set(frame.output_index, frame.item)
|
||||
if (frame.item.type === 'function_call') events.add(key, 'tool_use', '', { id: frame.item.call_id, name: frame.item.name })
|
||||
else if (!['message', 'reasoning'].includes(frame.item.type)) throw new BridgeError(502, 'Unsupported upstream output item')
|
||||
break
|
||||
case 'response.output_text.delta': events.add(`${key}:text:${frame.content_index}`, 'text', frame.delta); break
|
||||
case 'response.reasoning_summary_text.delta': events.add(`${key}:thinking`, 'thinking', frame.delta); break
|
||||
case 'response.function_call_arguments.delta': {
|
||||
const item = items.get(frame.output_index)
|
||||
if (!item || item.type !== 'function_call') throw new BridgeError(502, 'Tool arguments without a tool item')
|
||||
events.add(key, 'tool_use', frame.delta, { id: item.call_id, name: item.name }); break
|
||||
}
|
||||
case 'response.output_text.done': events.close(`${key}:text:${frame.content_index}`); break
|
||||
case 'response.output_item.done':
|
||||
if (frame.item.type === 'function_call') events.close(key)
|
||||
if (frame.item.type === 'reasoning') events.close(`${key}:thinking`)
|
||||
break
|
||||
case 'response.refusal.delta': case 'response.refusal.done': throw new BridgeError(502, 'Upstream refused the request')
|
||||
case 'response.completed': case 'response.incomplete': {
|
||||
const converted = convertResponse(frame.response, model, true, context)
|
||||
if (historyKey(events.content()) !== historyKey(converted.content)) throw new BridgeError(502, 'Upstream stream content does not match its completed response')
|
||||
await context.persist?.()
|
||||
finish = converted.stop_reason; usage = converted.usage; completed = true; break
|
||||
}
|
||||
case 'response.created': case 'response.in_progress': case 'response.queued':
|
||||
case 'response.content_part.added': case 'response.content_part.done':
|
||||
case 'response.reasoning_summary_part.added': case 'response.reasoning_summary_part.done':
|
||||
case 'response.reasoning_summary_text.done': case 'response.function_call_arguments.done': break
|
||||
default: throw new BridgeError(502, 'Unsupported Responses stream event')
|
||||
}
|
||||
if (completed) break
|
||||
}
|
||||
}
|
||||
if (!completed) throw new BridgeError(502, 'Upstream stream ended before its terminal event')
|
||||
for (const tool of tools.values()) if (!tool.started) throw new BridgeError(502, 'Incomplete upstream tool call')
|
||||
events.finish(responses ? finish as string : stopReason(finish), usage)
|
||||
} catch (error) {
|
||||
try { events.send('error', { error: { type: 'api_error', message: error instanceof BridgeError ? error.message : 'Upstream stream failed' } }) } catch { response.destroy() }
|
||||
response.end()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { 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
@@ -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: '上游请求频率或额度受限,请稍后重试' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CustomModelProfile, SupportedModelApi } from './models'
|
||||
import { apiProtocol, inferModelApi } from './model-endpoint'
|
||||
|
||||
export function supportedModelApi(value: unknown): value is SupportedModelApi {
|
||||
return value === 'anthropic-messages' || value === 'openai-completions' || value === 'openai-responses'
|
||||
}
|
||||
|
||||
/** Missing protocol is the historical Anthropic configuration, not automatic negotiation. */
|
||||
export function modelProfileApi(profile: Pick<CustomModelProfile, 'protocol' | 'apiPreference' | 'endpoint' | 'baseUrl'>): SupportedModelApi | undefined {
|
||||
const protocol = profile.protocol ?? (profile.apiPreference ? apiProtocol(profile.apiPreference) : 'anthropic')
|
||||
if (protocol !== 'anthropic' && protocol !== 'openai') return undefined
|
||||
if (profile.apiPreference !== undefined) return supportedModelApi(profile.apiPreference) && apiProtocol(profile.apiPreference) === protocol ? profile.apiPreference : undefined
|
||||
if (profile.endpoint && supportedModelApi(profile.endpoint.api) && apiProtocol(profile.endpoint.api) === protocol) return profile.endpoint.api
|
||||
if (protocol === 'anthropic') return 'anthropic-messages'
|
||||
const api = inferModelApi(profile.baseUrl, protocol)
|
||||
return supportedModelApi(api) ? api : undefined
|
||||
}
|
||||
|
||||
export function isSupportedModelProfile(profile: Pick<CustomModelProfile, 'protocol' | 'apiPreference' | 'endpoint' | 'baseUrl'>): boolean {
|
||||
try { return modelProfileApi(profile) !== undefined } catch { return false }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { GatewayConfig } from './types'
|
||||
|
||||
export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini'
|
||||
export type 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
|
||||
|
||||
Reference in new issue
Block a user