170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
// 大模型请求封装:OpenAI Chat Completions 协议,流式读取,把分片拼成一条完整的 assistant 消息。
|
||
// 只依赖 Node 自带的 fetch。
|
||
import type { AssistantMessage, Message, ToolCall, ToolSpec, Usage } from '../shared/types.ts';
|
||
import { setTimeout as delay } from 'node:timers/promises';
|
||
|
||
export type Provider = {
|
||
displayName: string;
|
||
api: 'openai-completions';
|
||
baseURL: string;
|
||
apiKey: string;
|
||
model: string;
|
||
contextWindow?: number;
|
||
maxOutputTokens?: number;
|
||
inputModalities?: readonly string[];
|
||
};
|
||
|
||
export type TransportEvent =
|
||
| { type: 'http.request'; attempt: number; body: string }
|
||
| { type: 'http.response'; attempt: number; status: number }
|
||
| { type: 'http.error'; attempt: number; error: string };
|
||
|
||
export type ChatRequest = {
|
||
messages: Message[];
|
||
tools: ToolSpec[];
|
||
signal?: AbortSignal;
|
||
onDelta?: (delta: { content?: string; reasoning?: string }) => void;
|
||
onTransport?: (event: TransportEvent) => void;
|
||
toolChoice?: string;
|
||
};
|
||
|
||
export type ChatResult = {
|
||
message: AssistantMessage;
|
||
reasoning: string;
|
||
usage: Usage | null;
|
||
finishReason: string | null;
|
||
ttftMs: number | null; // 首个分片到达的耗时
|
||
attempt: number;
|
||
};
|
||
|
||
export type LLM = { provider: Provider; chat(request: ChatRequest): Promise<ChatResult> };
|
||
|
||
// 网关对单个令牌有 RPM 限制(实测 50 次/分钟),429 时退避重试。
|
||
const RETRY_DELAYS_MS = [1000, 3000, 8000, 15000, 30000];
|
||
|
||
export function createLLM(provider: Provider): LLM {
|
||
async function open(request: ChatRequest) {
|
||
for (let attempt = 1; ; attempt += 1) {
|
||
const body = JSON.stringify({
|
||
model: provider.model,
|
||
messages: request.messages,
|
||
tools: request.tools,
|
||
temperature: 0,
|
||
stream: true,
|
||
stream_options: { include_usage: true },
|
||
max_tokens: provider.maxOutputTokens,
|
||
tool_choice: request.toolChoice === undefined || request.toolChoice === 'none'
|
||
? request.toolChoice
|
||
: { type: 'function', function: { name: request.toolChoice } },
|
||
});
|
||
if (provider.apiKey && body.includes(provider.apiKey)) throw new Error('请求内容含有传输凭据。');
|
||
let failure: string;
|
||
try {
|
||
request.onTransport?.({ type: 'http.request', attempt, body });
|
||
const response = await fetch(`${provider.baseURL}/chat/completions`, {
|
||
method: 'POST',
|
||
redirect: 'error',
|
||
signal: request.signal,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${provider.apiKey}`,
|
||
},
|
||
body,
|
||
});
|
||
request.onTransport?.({ type: 'http.response', attempt, status: response.status });
|
||
if (response.ok) return { response, attempt };
|
||
await response.body?.cancel();
|
||
failure = `模型网关返回 HTTP ${response.status}`;
|
||
const retryable = response.status === 429 || response.status >= 500;
|
||
if (!retryable) throw new Error(failure);
|
||
} catch (error) {
|
||
if (request.signal?.aborted) throw error;
|
||
if (error instanceof Error && error.message.startsWith('模型网关返回')) throw error;
|
||
failure = `连接模型网关失败:${error instanceof Error ? error.message : String(error)}`;
|
||
request.onTransport?.({ type: 'http.error', attempt, error: failure });
|
||
}
|
||
const waitMs = RETRY_DELAYS_MS[attempt - 1];
|
||
if (waitMs === undefined) throw new Error(failure);
|
||
await delay(waitMs, undefined, { signal: request.signal });
|
||
}
|
||
}
|
||
|
||
return {
|
||
provider,
|
||
async chat(request) {
|
||
const startedAt = performance.now();
|
||
const { response, attempt } = await open(request);
|
||
|
||
let content = '';
|
||
let reasoning = '';
|
||
let usage: Usage | null = null;
|
||
let finishReason: string | null = null;
|
||
let ttftMs: number | null = null;
|
||
const toolCalls: ToolCall[] = [];
|
||
|
||
for await (const chunk of readServerSentEvents(response)) {
|
||
if (chunk.error) throw new Error('模型网关在流中返回错误');
|
||
if (ttftMs === null) ttftMs = Math.round(performance.now() - startedAt);
|
||
if (chunk.usage) usage = normalizeUsage(chunk.usage);
|
||
const choice = chunk.choices?.[0];
|
||
if (!choice) continue;
|
||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||
const delta = choice.delta ?? {};
|
||
if (delta.reasoning_content) {
|
||
reasoning += delta.reasoning_content;
|
||
request.onDelta?.({ reasoning: delta.reasoning_content });
|
||
}
|
||
if (delta.content) {
|
||
content += delta.content;
|
||
request.onDelta?.({ content: delta.content });
|
||
}
|
||
// 工具调用按 index 分片到达:第一片带 id 和函数名,后续分片只追加参数字符串。
|
||
for (const part of delta.tool_calls ?? []) {
|
||
const call = (toolCalls[part.index] ??= {
|
||
id: '',
|
||
type: 'function',
|
||
function: { name: '', arguments: '' },
|
||
});
|
||
if (part.id) call.id = part.id;
|
||
if (part.function?.name) call.function.name += part.function.name;
|
||
if (part.function?.arguments) call.function.arguments += part.function.arguments;
|
||
}
|
||
}
|
||
|
||
const message: AssistantMessage = { role: 'assistant', content };
|
||
const calls = toolCalls.filter(Boolean);
|
||
if (calls.length) message.tool_calls = calls;
|
||
if (finishReason === null) throw new Error('模型响应流提前结束');
|
||
return { message, reasoning, usage, finishReason, ttftMs, attempt };
|
||
},
|
||
};
|
||
}
|
||
|
||
async function* readServerSentEvents(response: Response): AsyncGenerator<any> {
|
||
if (!response.body) throw new Error('模型网关没有返回响应体');
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
for await (const bytes of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||
buffer += decoder.decode(bytes, { stream: true });
|
||
let newline: number;
|
||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||
const line = buffer.slice(0, newline).trim();
|
||
buffer = buffer.slice(newline + 1);
|
||
if (!line.startsWith('data:')) continue;
|
||
const data = line.slice(5).trim();
|
||
if (data === '[DONE]') return;
|
||
yield JSON.parse(data);
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeUsage(raw: any): Usage {
|
||
return {
|
||
prompt_tokens: raw.prompt_tokens ?? 0,
|
||
completion_tokens: raw.completion_tokens ?? 0,
|
||
total_tokens: raw.total_tokens ?? 0,
|
||
cached_tokens: raw.prompt_tokens_details?.cached_tokens,
|
||
reasoning_tokens: raw.completion_tokens_details?.reasoning_tokens,
|
||
};
|
||
}
|