Add OpenAI-compatible LLM client service (v0.19.0)
CI / build + unit (pull_request) Successful in 1m14s
CI / build + unit (pull_request) Successful in 1m14s
LlmService wraps the local OpenAI-compatible server (default http://192.168.1.70:1111/v1, no auth) with native fetch only — no new dependencies. listModels() -> data[].id; chat(messages, opts?) -> choices[0].message.content (non-2xx throws with status + body snippet); summarize(text) = chat() with a concise-summary system prompt. Env: LLM_API_BASE / LLM_MODEL (qwen3.8-27b) / LLM_TIMEOUT_MS (60000, AbortSignal.timeout). Plain feature module wired into app.module.ts; docs synced (AGENTS.md, README env list).
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/** A single chat message in OpenAI chat-completions shape. */
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmService {
|
||||
// read at construction, like other services' env config
|
||||
private readonly apiBase =
|
||||
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
||||
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
||||
// local 27B model can be slow — generous default
|
||||
private readonly timeoutMs = parseInt(
|
||||
process.env.LLM_TIMEOUT_MS ?? '60000',
|
||||
10,
|
||||
);
|
||||
|
||||
/** GET {base}/models → the `data[].id` strings. */
|
||||
async listModels(): Promise<string[]> {
|
||||
const res = await fetch(`${this.apiBase}/models`, {
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`listModels failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
return (body.data ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
/** POST {base}/chat/completions → `choices[0].message.content`. */
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
opts?: { maxTokens?: number; temperature?: number },
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${this.apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: this.model, messages, ...opts }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`chat failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chat failed: no choices[0].message.content in response');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Thin wrapper over chat(): the stated primary use of this service. */
|
||||
async summarize(text: string): Promise<string> {
|
||||
return this.chat([
|
||||
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||
{ role: 'user', content: text },
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user