chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 自建模型网关(OpenAI 兼容)。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 持有上游 baseUrl / apiKey(永不进入渲染进程)
|
||||
* 2. 按配置地址选择 Chat Completions / Responses + SSE 流式
|
||||
* 3. 把上游的增量 delta 组装成「文本增量」与「完整工具调用」两类输出
|
||||
*
|
||||
* 设计约束:网关只做协议适配与组装,不含业务策略;
|
||||
* 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。
|
||||
*/
|
||||
|
||||
import type { GatewayConfig, ToolSpec } from '@shared/types'
|
||||
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
||||
import { parseSse } from './sse'
|
||||
import { buildResponsesRequest, parseResponsesStream } from './responses'
|
||||
|
||||
/** 上游 wire 格式的消息 */
|
||||
export interface WireMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string | null | Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }>
|
||||
tool_calls?: WireToolCall[]
|
||||
tool_call_id?: string
|
||||
}
|
||||
|
||||
export interface WireToolCall {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: { name: string; arguments: string }
|
||||
}
|
||||
|
||||
/** 网关输出的增量事件 */
|
||||
export type GatewayDelta =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool_calls'; calls: WireToolCall[] }
|
||||
| { type: 'finish'; reason: string | null }
|
||||
|
||||
export interface GatewayStreamRequest {
|
||||
messages: WireMessage[]
|
||||
tools?: ToolSpec[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class GatewayError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly body?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'GatewayError'
|
||||
}
|
||||
}
|
||||
|
||||
export class Gateway {
|
||||
constructor(private readonly config: GatewayConfig) {}
|
||||
|
||||
async *stream(req: GatewayStreamRequest): AsyncGenerator<GatewayDelta, void, void> {
|
||||
if (!this.config.apiKey && this.config.authMode !== 'none') {
|
||||
throw new GatewayError('未配置 API Key,请先在设置中填写')
|
||||
}
|
||||
if (!this.config.baseUrl) {
|
||||
throw new GatewayError('未配置上游地址(baseUrl)')
|
||||
}
|
||||
|
||||
let endpoint: ReturnType<typeof resolveOpenAIEndpoint>
|
||||
try { endpoint = resolveOpenAIEndpoint(this.config.baseUrl) } catch { throw new GatewayError('上游地址格式无效') }
|
||||
const responses = endpoint.api === 'openai-responses'
|
||||
const body: Record<string, unknown> = responses ? buildResponsesRequest(this.config, req) : {
|
||||
model: this.config.model,
|
||||
messages: req.messages,
|
||||
stream: true,
|
||||
temperature: this.config.temperature
|
||||
}
|
||||
if (!responses && this.config.maxOutputTokens) body.max_tokens = this.config.maxOutputTokens
|
||||
if (!responses && req.tools && req.tools.length > 0) {
|
||||
body.tools = req.tools
|
||||
body.tool_choice = 'auto'
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(endpoint.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
...(this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : {})
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: req.signal
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') throw err
|
||||
throw new GatewayError(responses ? '无法连接 Responses 上游' : `无法连接上游:${(err as Error).message}`)
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
// A Responses provider can echo request credentials in arbitrary error bodies.
|
||||
const text = responses ? undefined : await safeReadText(res)
|
||||
if (responses) await res.body?.cancel().catch(() => {})
|
||||
throw new GatewayError(
|
||||
responses ? `Responses 上游返回 HTTP ${res.status}` : `上游返回 ${res.status} ${res.statusText}`.trim(),
|
||||
res.status,
|
||||
text
|
||||
)
|
||||
}
|
||||
|
||||
if (responses) {
|
||||
yield* parseResponsesStream(res.body, (message) => new GatewayError(message), req.signal)
|
||||
return
|
||||
}
|
||||
|
||||
// 累加器:按 index 归并流式工具调用分片
|
||||
const acc = new Map<number, { id: string; name: string; args: string }>()
|
||||
let finishReason: string | null = null
|
||||
|
||||
for await (const evt of parseSse(res.body)) {
|
||||
if (evt.data === '[DONE]') break
|
||||
|
||||
let payload: any
|
||||
try {
|
||||
payload = JSON.parse(evt.data)
|
||||
} catch {
|
||||
continue // 非 JSON 的心跳/注释行直接跳过
|
||||
}
|
||||
|
||||
if (payload?.error) {
|
||||
throw new GatewayError(
|
||||
`上游错误:${payload.error.message ?? JSON.stringify(payload.error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const choice = payload?.choices?.[0]
|
||||
if (!choice) continue
|
||||
const delta = choice.delta ?? {}
|
||||
|
||||
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
||||
yield { type: 'text', text: delta.content }
|
||||
}
|
||||
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
|
||||
yield { type: 'reasoning', text: delta.reasoning_content }
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const piece of delta.tool_calls) {
|
||||
const idx = typeof piece.index === 'number' ? piece.index : 0
|
||||
const slot = acc.get(idx) ?? { id: '', name: '', args: '' }
|
||||
if (piece.id) slot.id = piece.id
|
||||
if (piece.function?.name) slot.name = piece.function.name
|
||||
if (piece.function?.arguments) slot.args += piece.function.arguments
|
||||
acc.set(idx, slot)
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason
|
||||
}
|
||||
|
||||
const calls: WireToolCall[] = [...acc.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([idx, slot]) => ({
|
||||
id: slot.id || `call_${idx}_${Date.now()}`,
|
||||
type: 'function' as const,
|
||||
function: { name: slot.name, arguments: slot.args || '{}' }
|
||||
}))
|
||||
|
||||
if (calls.length > 0) yield { type: 'tool_calls', calls }
|
||||
yield { type: 'finish', reason: finishReason }
|
||||
}
|
||||
}
|
||||
|
||||
async function safeReadText(res: Response): Promise<string> {
|
||||
try {
|
||||
return (await res.text()).slice(0, 2000)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { GatewayConfig } from '@shared/types'
|
||||
import type { GatewayDelta, GatewayStreamRequest, WireMessage, WireToolCall } from './index'
|
||||
import { parseSse } from './sse'
|
||||
|
||||
type InputContent = { type: 'input_text'; text: string } | { type: 'input_image'; image_url: string; detail: 'auto' }
|
||||
type InputItem = { role: 'system' | 'user' | 'assistant'; content: string | InputContent[] }
|
||||
| { type: 'function_call'; call_id: string; name: string; arguments: string }
|
||||
| { type: 'function_call_output'; call_id: string; output: string | InputContent[] }
|
||||
|
||||
function content(value: WireMessage['content']): string | InputContent[] {
|
||||
if (!Array.isArray(value)) return value ?? ''
|
||||
return value.map(part => part.type === 'text'
|
||||
? { type: 'input_text', text: part.text }
|
||||
: { type: 'input_image', image_url: part.image_url.url, detail: 'auto' })
|
||||
}
|
||||
|
||||
/** Stateless history replay; call_id is the public function-call ID, never the output item's ID. */
|
||||
export function responsesInput(messages: WireMessage[]): InputItem[] {
|
||||
const input: InputItem[] = []
|
||||
for (const message of messages) {
|
||||
if (message.role === 'tool') {
|
||||
if (!message.tool_call_id) throw new Error('工具结果缺少调用 ID,无法转换为 Responses 输入')
|
||||
input.push({ type: 'function_call_output', call_id: message.tool_call_id, output: content(message.content) })
|
||||
continue
|
||||
}
|
||||
if (message.content !== null || !message.tool_calls?.length) input.push({ role: message.role, content: content(message.content) })
|
||||
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) {
|
||||
input.push({ type: 'function_call', call_id: call.id, name: call.function.name, arguments: call.function.arguments })
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export function buildResponsesRequest(config: GatewayConfig, request: GatewayStreamRequest): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = { model: config.model, input: responsesInput(request.messages), stream: true,
|
||||
temperature: config.temperature, store: false }
|
||||
if (config.maxOutputTokens) body.max_output_tokens = config.maxOutputTokens
|
||||
if (request.tools?.length) {
|
||||
body.tools = request.tools.map(tool => ({ type: 'function', name: tool.function.name, description: tool.function.description,
|
||||
parameters: tool.function.parameters, strict: false }))
|
||||
body.tool_choice = 'auto'
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
const object = (value: unknown): Json => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Json : {}
|
||||
const string = (value: unknown): string => typeof value === 'string' ? value : ''
|
||||
const index = (value: unknown): number | undefined => typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined
|
||||
interface Call { outputIndex?: number; itemId: string; callId: string; name: string; args: string; final: boolean; status?: string }
|
||||
|
||||
/** OpenAI Responses typed SSE, adapted to the existing GatewayDelta contract.
|
||||
* Only completed responses expose executable calls. Partial/failed/truncated streams throw:
|
||||
* runTurn ignores finish.reason, so returning normally would incorrectly mark them done.
|
||||
* Source: https://developers.openai.com/api/reference/resources/responses/streaming-events
|
||||
*/
|
||||
export async function* parseResponsesStream(stream: ReadableStream<Uint8Array>, error: (message: string) => Error,
|
||||
signal?: AbortSignal): AsyncGenerator<GatewayDelta, void, void> {
|
||||
const calls: Call[] = [], parts = new Map<string, string>(), outputAliases = new Map<number, string>()
|
||||
let completed = false
|
||||
const abort = (): void => { if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new DOMException('请求已取消', 'AbortError') }
|
||||
const itemKey = (payload: Json): string => {
|
||||
const i = index(payload.output_index), id = string(payload.item_id)
|
||||
if (id && i !== undefined) {
|
||||
const before = outputAliases.get(i) ?? `index:${i}`
|
||||
if (before !== id) for (const [key, value] of parts) if (key.startsWith(before + '|')) { parts.set(id + key.slice(before.length), value); parts.delete(key) }
|
||||
outputAliases.set(i, id)
|
||||
}
|
||||
return id || (i !== undefined ? outputAliases.get(i) ?? `index:${i}` : 'unattributed')
|
||||
}
|
||||
const callSlot = (payload: Json, item: Json = {}): Call => {
|
||||
const i = index(payload.output_index), itemId = string(item.id) || string(payload.item_id), callId = string(item.call_id)
|
||||
let slot = calls.find(entry => !!itemId && entry.itemId === itemId || !!callId && entry.callId === callId || i !== undefined && entry.outputIndex === i)
|
||||
if (!slot) { slot = { outputIndex: i, itemId, callId, name: '', args: '', final: false }; calls.push(slot) }
|
||||
if (i !== undefined) slot.outputIndex = i
|
||||
if (itemId) slot.itemId = itemId
|
||||
if (callId) slot.callId = callId
|
||||
if (typeof item.name === 'string') slot.name = item.name
|
||||
if (typeof item.status === 'string') slot.status = item.status
|
||||
return slot
|
||||
}
|
||||
// Done/item/completed snapshots contain the entire value: emit only an unseen suffix.
|
||||
// Divergent snapshots cannot be represented as deltas without corrupting visible text.
|
||||
function* text(payload: Json, kind: 'text' | 'reasoning', value: string, snapshot = false): Generator<GatewayDelta> {
|
||||
const key = `${itemKey(payload)}|${kind}|${index(kind === 'reasoning' ? payload.summary_index : payload.content_index) ?? 0}`
|
||||
const prior = parts.get(key) ?? ''
|
||||
if (snapshot && !value.startsWith(prior)) throw error('Responses 上游输出快照与流式内容不一致')
|
||||
const delta = snapshot ? value.slice(prior.length) : value
|
||||
parts.set(key, snapshot ? value : prior + value)
|
||||
if (delta) yield { type: kind, text: delta }
|
||||
}
|
||||
function* output(payload: Json, item: Json, final: boolean): Generator<GatewayDelta> {
|
||||
const keyed = { ...payload, item_id: item.id ?? payload.item_id }
|
||||
itemKey(keyed)
|
||||
if (item.type === 'function_call') {
|
||||
const slot = callSlot(keyed, item)
|
||||
if (final && item.status === undefined) slot.status = 'completed'
|
||||
if (typeof item.arguments === 'string') {
|
||||
if (final) { slot.args = item.arguments; slot.final = true }
|
||||
else if (!slot.args) slot.args = item.arguments
|
||||
}
|
||||
return
|
||||
}
|
||||
if (item.type === 'message' && Array.isArray(item.content)) {
|
||||
for (let i = 0; i < item.content.length; i++) {
|
||||
const part = object(item.content[i])
|
||||
if (part.type === 'output_text') yield* text({ ...keyed, content_index: i }, 'text', string(part.text), true)
|
||||
else if (part.type === 'refusal') yield* text({ ...keyed, content_index: i }, 'text', string(part.refusal), true)
|
||||
}
|
||||
} else if (item.type === 'reasoning' && Array.isArray(item.summary)) {
|
||||
for (let i = 0; i < item.summary.length; i++) {
|
||||
const part = object(item.summary[i])
|
||||
if (part.type === 'summary_text') yield* text({ ...keyed, summary_index: i }, 'reasoning', string(part.text), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
abort()
|
||||
try {
|
||||
for await (const event of parseSse(stream)) {
|
||||
abort()
|
||||
if (event.data === '[DONE]') break
|
||||
let payload: Json
|
||||
try { payload = object(JSON.parse(event.data)) } catch { continue }
|
||||
const type = string(payload.type) || event.event
|
||||
// Never include a raw provider message/body: some gateways echo supplied credentials.
|
||||
if (type === 'error' || type === 'response.failed' || payload.error) throw error('Responses 上游生成失败')
|
||||
if (type === 'response.incomplete') {
|
||||
const reason = string(object(object(payload.response).incomplete_details).reason)
|
||||
throw error(reason === 'max_output_tokens' ? 'Responses 上游达到输出长度限制,回复未完成' : reason === 'content_filter' ? 'Responses 上游过滤了部分内容,回复未完成' : 'Responses 上游回复未完成')
|
||||
}
|
||||
if (type === 'response.output_text.delta') yield* text(payload, 'text', string(payload.delta))
|
||||
else if (type === 'response.output_text.done') yield* text(payload, 'text', string(payload.text), true)
|
||||
else if (type === 'response.refusal.delta') yield* text(payload, 'text', string(payload.delta))
|
||||
else if (type === 'response.refusal.done') yield* text(payload, 'text', string(payload.refusal), true)
|
||||
else if (type === 'response.reasoning_summary_text.delta') yield* text(payload, 'reasoning', string(payload.delta))
|
||||
else if (type === 'response.reasoning_summary_text.done') yield* text(payload, 'reasoning', string(payload.text), true)
|
||||
else if (type === 'response.function_call_arguments.delta') {
|
||||
const slot = callSlot(payload)
|
||||
if (!slot.final) slot.args += string(payload.delta)
|
||||
} else if (type === 'response.function_call_arguments.done') {
|
||||
const slot = callSlot(payload)
|
||||
if (typeof payload.arguments === 'string') { slot.args = payload.arguments; slot.final = true }
|
||||
} else if (type === 'response.output_item.added' || type === 'response.output_item.done') yield* output(payload, object(payload.item), type.endsWith('.done'))
|
||||
else if (type === 'response.content_part.done') {
|
||||
const part = object(payload.part)
|
||||
if (part.type === 'output_text') yield* text(payload, 'text', string(part.text), true)
|
||||
else if (part.type === 'refusal') yield* text(payload, 'text', string(part.refusal), true)
|
||||
} else if (type === 'response.completed') {
|
||||
const response = object(payload.response)
|
||||
if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态')
|
||||
for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true)
|
||||
completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
abort()
|
||||
} catch (cause) {
|
||||
abort()
|
||||
if (cause instanceof Error && cause.name === 'AbortError') throw cause
|
||||
// Preserve adapter errors; transport errors contain no provider diagnostics or credentials.
|
||||
if (cause instanceof Error && cause.name === 'GatewayError') throw cause
|
||||
throw error('Responses 上游连接中断,回复未完成')
|
||||
} finally {
|
||||
// parseSse releases its reader before returning. Stop a lingering HTTP body on early terminal events.
|
||||
await stream.cancel().catch(() => {})
|
||||
}
|
||||
if (!completed) throw error('Responses 流提前结束,未收到完成事件')
|
||||
const executable: WireToolCall[] = calls.sort((a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)).map(slot => {
|
||||
if (!slot.callId || !slot.name || !slot.final || slot.status && slot.status !== 'completed') throw error('Responses 上游工具调用不完整')
|
||||
try { JSON.parse(slot.args) } catch { throw error('Responses 上游工具参数不是有效 JSON') }
|
||||
return { id: slot.callId, type: 'function', function: { name: slot.name, arguments: slot.args } }
|
||||
})
|
||||
if (executable.length) yield { type: 'tool_calls', calls: executable }
|
||||
yield { type: 'finish', reason: executable.length ? 'tool_calls' : 'stop' }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 极简 SSE(text/event-stream)解析器。
|
||||
*
|
||||
* 只依赖 Web 标准的 ReadableStream,可直接消费 fetch 响应体。
|
||||
* 上游既可能是标准 SSE,也可能在末尾缺少空行,因此以「块边界」为准做收尾。
|
||||
*/
|
||||
|
||||
export interface SseEvent {
|
||||
event?: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export async function* parseSse(
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): AsyncGenerator<SseEvent, void, void> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let index: number
|
||||
// 事件之间以空行分隔,兼容 \r\n 与 \n
|
||||
while ((index = findBoundary(buffer)) !== -1) {
|
||||
const raw = buffer.slice(0, index)
|
||||
buffer = buffer.slice(index).replace(/^(\r?\n){2}/, '')
|
||||
const parsed = parseBlock(raw)
|
||||
if (parsed) yield parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾:流结束时可能残留最后一个事件
|
||||
buffer += decoder.decode()
|
||||
const tail = parseBlock(buffer)
|
||||
if (tail) yield tail
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function findBoundary(buffer: string): number {
|
||||
const a = buffer.indexOf('\n\n')
|
||||
const b = buffer.indexOf('\r\n\r\n')
|
||||
if (a === -1) return b
|
||||
if (b === -1) return a
|
||||
return Math.min(a, b)
|
||||
}
|
||||
|
||||
function parseBlock(block: string): SseEvent | null {
|
||||
const lines = block.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
let event: string | undefined
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith(':')) continue
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^ /, ''))
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return null
|
||||
return { event, data: dataLines.join('\n') }
|
||||
}
|
||||
Reference in new issue
Block a user