Files
Kokorone/apps/api/src/llm/claude-provider.service.ts
T

102 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from "@nestjs/common";
import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse } from "./types.js";
import { buildMessages } from "./prompt-builder.js";
import { extractActions } from "./action-markup.js";
interface OpenAiCompatibleChoice {
message?: { content?: string };
delta?: { content?: string };
}
interface OpenAiCompatibleResponse {
choices?: OpenAiCompatibleChoice[];
}
function baseUrl(): string {
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
}
function apiKey(): string {
const key = process.env.CLAUDE_API_KEY;
if (!key) {
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
}
return key;
}
function model(): string {
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
}
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
const res = await fetch(`${baseUrl()}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey()}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
}
return res;
}
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
@Injectable()
export class ClaudeProvider implements LLMProvider {
async generate(context: GenerationContext): Promise<GeneratedResponse> {
const res = await requestChatCompletion({
model: model(),
messages: buildMessages(context),
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const text = json.choices?.[0]?.message?.content ?? "";
if (!text) {
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
}
return { text, actions: extractActions(text) };
}
async *stream(context: GenerationContext): AsyncIterable<string> {
const res = await requestChatCompletion({
model: model(),
messages: buildMessages(context),
stream: true,
});
if (!res.body) {
throw new Error("ClaudeProvider 串流回應沒有 body");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice("data:".length).trim();
if (payload === "[DONE]") return;
let chunk: OpenAiCompatibleResponse;
try {
chunk = JSON.parse(payload);
} catch {
continue;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) yield delta;
}
}
}
}