feat: migrate runtime backend to node server
This commit is contained in:
169
server-node/src/services/llmClient.ts
Normal file
169
server-node/src/services/llmClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user