char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
5.6 KiB186 linesraw
1/**
2 * ToolsService - Stateless server tools API layer
3 *
4 * Fetches the server's /tools listing and streams tool execution results.
5 * No reactive state; consumed by toolsStore.
6 */
7
8import { base } from '$app/paths';
9import { API_TOOLS, HEADERS } from '$lib/constants';
10import { ToolResponseField } from '$lib/enums';
11import type { ServerToolInfo, ToolExecutionResult } from '$lib/types';
12import { apiFetch } from '$lib/utils';
13import { getJsonHeaders } from '$lib/utils/api-headers';
14import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
15
16export class ToolsService {
17	/**
18	 * Execute a server tool on the server.
19	 *
20	 * @param cwd - Working directory for the tool call, sent as the
21	 * x-tool-cwd request header. The server resolves relative paths
22	 * against it; the model cannot override it.
23	 */
24	static async executeTool(
25		toolName: string,
26		params: Record<string, unknown>,
27		signal?: AbortSignal,
28		cwd?: string
29	): Promise<ToolExecutionResult> {
30		const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
31			body: JSON.stringify({ params, tool: toolName }),
32			headers: cwd ? { [HEADERS.X_TOOL_CWD_HEADER]: cwd } : undefined,
33			method: 'POST',
34			signal
35		});
36
37		if (ToolResponseField.ERROR in result) {
38			return { content: String(result[ToolResponseField.ERROR]), isError: true };
39		}
40
41		if (ToolResponseField.PLAIN_TEXT in result) {
42			return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false };
43		}
44
45		return { content: JSON.stringify(result), isError: false };
46	}
47
48	/**
49	 * Execute a server tool and return the raw JSON response. Unlike
50	 * executeTool, this preserves structured fields (e.g. file_glob_search's
51	 * `entries` and `base`) that the flattened ToolExecutionResult drops.
52	 *
53	 * @param respType - sent as the x-resp-type request header. Only read_file
54	 * honors it, with `base64` to get the raw bytes instead of decoded text.
55	 */
56	static async executeToolRaw(
57		toolName: string,
58		params: Record<string, unknown>,
59		signal?: AbortSignal,
60		cwd?: string,
61		respType?: string
62	): Promise<Record<string, unknown>> {
63		const headers: Record<string, string> = {};
64
65		if (cwd) headers[HEADERS.X_TOOL_CWD_HEADER] = cwd;
66
67		if (respType) headers[HEADERS.X_RESP_TYPE_HEADER] = respType;
68
69		return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
70			body: JSON.stringify({ params, tool: toolName }),
71			headers: Object.keys(headers).length > 0 ? headers : undefined,
72			method: 'POST',
73			signal
74		});
75	}
76
77	/**
78	 * Fetch the list of server tools from the server.
79	 *
80	 * @returns Array of tool definitions in OpenAI-compatible format
81	 */
82	static async list(): Promise<ServerToolInfo[]> {
83		return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
84	}
85
86	/**
87	 * Stream a server tool's output chunks from the server. The server
88	 * `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
89	 * events followed by a terminal `data: {"done": true}` (optionally with
90	 * `error`). Yields the chunk string for each partial event.
91	 *
92	 * The terminal event's `error` field, if present, is yielded as a final
93	 * synthetic chunk prefixed with an error marker so the accumulated content
94	 * already carries the failure context for the caller.
95	 *
96	 * Throws synchronously if the server rejects the request (e.g. tool does
97	 * not support streaming, or 4xx/5xx response). The HTTP fetch goes through
98	 * a minimal text/event-stream reader since the chat SSE parser in
99	 * chat.service.ts embeds extra resume logic that is unnecessary here.
100	 */
101	static async *streamTool(
102		toolName: string,
103		params: Record<string, unknown>,
104		signal?: AbortSignal,
105		cwd?: string
106	): AsyncGenerator<ToolStreamEvent> {
107		const headers = getJsonHeaders();
108
109		if (cwd) headers[HEADERS.X_TOOL_CWD_HEADER] = cwd;
110
111		const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
112			body: JSON.stringify({ params, stream: true, tool: toolName }),
113			headers,
114			method: 'POST',
115			signal
116		});
117
118		if (!response.ok || !response.body) {
119			const detail = await formatNonOkResponse(response);
120
121			throw new Error(detail);
122		}
123
124		const iterator = parseSseJsonStream<ToolServerEvent>(response, signal);
125
126		while (true) {
127			const next: IteratorResult<SseJsonEvent<ToolServerEvent>> = await iterator.next();
128
129			if (next.done) return;
130
131			const event = next.value.data;
132
133			if (event.chunk !== undefined) {
134				yield { chunk: event.chunk, done: false };
135			}
136
137			if (event.done) {
138				yield { chunk: null, done: true, error: event.error };
139
140				return;
141			}
142		}
143	}
144}
145
146/**
147 * One event from streaming a tool's output.
148 *   - During execution: `chunk` is a non-empty text fragment, `done: false`.
149 *   - On terminal event: `done: true`, `error` populated if the call failed,
150 *     and `chunk` is null.
151 */
152export interface ToolStreamEvent {
153	chunk: string | null;
154	done: boolean;
155	error?: string;
156}
157
158/** Wire shape of one SSE event from `POST /tools?stream=true`. */
159interface ToolServerEvent {
160	chunk?: string;
161	done?: boolean;
162	error?: string;
163}
164
165async function formatNonOkResponse(response: Response): Promise<string> {
166	const status = `${response.status} ${response.statusText}`.trim();
167
168	try {
169		const errBody = (await response.clone().json()) as { error?: string; message?: string };
170
171		if (errBody?.error) return `${status}: ${errBody.error}`;
172
173		if (errBody?.message) return `${status}: ${errBody.message}`;
174	} catch (error) {
175		console.error('[tools] Non-JSON error response, falling back to raw text:', error);
176		try {
177			const text = await response.text();
178
179			if (text.trim()) return `${status}: ${text.trim()}`;
180		} catch (error) {
181			console.error('[tools] Failed to read error response as text:', error);
182		}
183	}
184
185	return status || `HTTP ${response.status}`;
186}