feat(agent): add llm assistant chat with api tools (task 88)

OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
This commit is contained in:
ginnoir
2026-07-04 19:46:09 -05:00
parent 04ae809e07
commit 4a924a4107
19 changed files with 956 additions and 6 deletions
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
type OpenAiMessage = {
role: string;
content: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
name?: string;
};
export function createOpenAiCompatibleClient(options: {
baseUrl: string;
apiKey: string | null;
model: string;
}): LlmClient {
const completionsUrl = `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const body = {
model: options.model,
messages: request.messages as OpenAiMessage[],
tools: request.tools,
tool_choice: request.tools?.length ? "auto" : undefined,
};
const response = await fetch(completionsUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`LLM request failed (${response.status}): ${detail.slice(0, 400)}`);
}
const payload = (await response.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: OpenAiMessage;
}>;
};
const choice = payload.choices?.[0];
const message = choice?.message;
if (!message) {
throw new Error("LLM response missing message");
}
return {
message: {
role: message.role as ChatCompletionResult["message"]["role"],
content: message.content,
tool_calls: message.tool_calls,
tool_call_id: message.tool_call_id,
name: message.name,
},
finishReason: choice.finish_reason ?? null,
};
},
};
}