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
+30
View File
@@ -0,0 +1,30 @@
export type LlmProviderKind = "mock" | "openai";
export type LlmConfig = {
provider: LlmProviderKind;
baseUrl: string | null;
apiKey: string | null;
model: string;
};
export function getLlmConfig(): LlmConfig {
const providerEnv = process.env.LLM_PROVIDER?.trim().toLowerCase();
const baseUrl = process.env.LLM_BASE_URL?.trim() || null;
const apiKey = process.env.LLM_API_KEY?.trim() || null;
const model = process.env.LLM_MODEL?.trim() || "llama3.2";
if (providerEnv === "mock") {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
if (!baseUrl) {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
return { provider: "openai", baseUrl, apiKey, model };
}
export function isLlmConfigured(): boolean {
const config = getLlmConfig();
return config.provider === "openai" && Boolean(config.baseUrl);
}
+29
View File
@@ -0,0 +1,29 @@
import { createMockLlmClient } from "./mock";
import { createOpenAiCompatibleClient } from "./openai-compatible";
import { getLlmConfig } from "./config";
import type { LlmClient } from "./types";
export type {
AgentToolDefinition,
ChatCompletionRequest,
ChatCompletionResult,
ChatMessage,
LlmClient,
} from "./types";
export { getLlmConfig, isLlmConfigured } from "./config";
export { createMockLlmClient } from "./mock";
export function createLlmClient(override?: LlmClient): LlmClient {
if (override) return override;
const config = getLlmConfig();
if (config.provider === "mock" || !config.baseUrl) {
return createMockLlmClient();
}
return createOpenAiCompatibleClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
function lastUserText(messages: ChatCompletionRequest["messages"]): string {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role === "user" && message.content) {
return message.content.toLowerCase();
}
}
return "";
}
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
return messages.some((message) => message.role === "tool");
}
export function createMockLlmClient(): LlmClient {
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const userText = lastUserText(request.messages);
if (hadToolResults(request.messages)) {
return {
message: {
role: "assistant",
content: "Done — I updated your household data using the API.",
},
finishReason: "stop",
};
}
if (userText.includes("milk") && request.tools?.length) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_add_item",
type: "function",
function: {
name: "add_list_item",
arguments: JSON.stringify({
listType: "shopping",
text: "milk",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
if (userText.includes("calendar") && request.tools?.length) {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 1);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_list_events",
type: "function",
function: {
name: "list_events",
arguments: JSON.stringify({
from: from.toISOString(),
to: to.toISOString(),
}),
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: {
role: "assistant",
content:
"I'm the famapp assistant (mock provider). Ask me to add items or check the calendar.",
},
finishReason: "stop",
};
},
};
}
+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,
};
},
};
}
+41
View File
@@ -0,0 +1,41 @@
export type ChatRole = "system" | "user" | "assistant" | "tool";
export type ChatToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage = {
role: ChatRole;
content: string | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
name?: string;
};
export type AgentToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ChatCompletionRequest = {
messages: ChatMessage[];
tools?: AgentToolDefinition[];
};
export type ChatCompletionResult = {
message: ChatMessage;
finishReason: string | null;
};
export interface LlmClient {
chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult>;
}