feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,169 @@
import { Readable } from 'node:stream';
import type { Response as ExpressResponse } from 'express';
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { upstreamError } from '../errors.js';
import { extractApiErrorMessage } from '../http.js';
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
type CompletionRequest = {
model?: string;
stream?: boolean;
messages: ChatMessage[];
};
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/u, '');
}
function buildCompletionUrl(baseUrl: string) {
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
}
export class UpstreamLlmClient {
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
private resolveModel(model?: string) {
return model?.trim() || this.config.llm.model;
}
private buildHeaders() {
if (!this.config.llm.apiKey) {
throw upstreamError('服务端缺少 LLM_API_KEY');
}
return {
Authorization: `Bearer ${this.config.llm.apiKey}`,
'Content-Type': 'application/json',
};
}
async requestCompletion(body: CompletionRequest, signal?: AbortSignal) {
const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model: this.resolveModel(body.model),
}),
signal,
});
if (!response.ok) {
const rawText = await response.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
}
return response;
}
async requestMessageContent(params: {
systemPrompt: string;
userPrompt: string;
model?: string;
signal?: AbortSignal;
}) {
const response = await this.requestCompletion(
{
model: params.model,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
params.signal,
);
const rawText = await response.text();
const parsed = JSON.parse(rawText) as {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
const content = parsed.choices?.[0]?.message?.content?.trim();
if (!content) {
throw upstreamError('LLM 返回内容为空');
}
return content;
}
async forwardCompletion(body: Record<string, unknown>, response: ExpressResponse) {
const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model:
typeof body.model === 'string' && body.model.trim()
? body.model
: this.config.llm.model,
}),
});
if (!upstreamResponse.ok) {
const rawText = await upstreamResponse.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
}
response.status(upstreamResponse.status);
response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
);
if (!upstreamResponse.body) {
response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
}
async forwardSseText(params: {
systemPrompt: string;
userPrompt: string;
response: ExpressResponse;
model?: string;
}) {
const upstreamResponse = await this.requestCompletion({
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
});
params.response.status(upstreamResponse.status);
params.response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8',
);
params.response.setHeader('Cache-Control', 'no-cache');
params.response.setHeader('Connection', 'keep-alive');
params.response.setHeader('X-Accel-Buffering', 'no');
if (!upstreamResponse.body) {
params.response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
}
}