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:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user