@tanstack-ai-browser/apfel 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/dist/adapter.d.ts +98 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +665 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/tool-calls.d.ts +32 -0
- package/dist/tool-calls.d.ts.map +1 -0
- package/dist/tool-calls.js +123 -0
- package/dist/tool-repair.d.ts +56 -0
- package/dist/tool-repair.d.ts.map +1 -0
- package/dist/tool-repair.js +252 -0
- package/package.json +53 -3
- package/src/adapter.ts +868 -0
- package/src/index.ts +16 -0
- package/src/tool-calls.ts +134 -0
- package/src/tool-repair.ts +321 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TanStack AI text adapter for Apple's on-device Foundation Model, served
|
|
3
|
+
* by apfel's OpenAI-compatible server on the user's Mac. No backend
|
|
4
|
+
* involved: the browser streams straight from it. Hand it to `chat()`
|
|
5
|
+
* like any hosted model's adapter: `chat()` runs tools and the agent loop,
|
|
6
|
+
* and each `chatStream` call here is one completion from apfel, streamed
|
|
7
|
+
* back as AG-UI events.
|
|
8
|
+
*/
|
|
9
|
+
import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai";
|
|
10
|
+
import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
11
|
+
import { JSON_TOOL_CALLS_MARKER, createToolCallSplitter, parseJsonToolCalls } from "./tool-calls.js";
|
|
12
|
+
import { matchToolName, repairToolArguments } from "./tool-repair.js";
|
|
13
|
+
/** Where apfel's server listens by default. */
|
|
14
|
+
export const APFEL_BASE_URL = "http://127.0.0.1:11434/v1";
|
|
15
|
+
/** The models apfel serves: Apple's on-device Foundation Model. */
|
|
16
|
+
export const APFEL_TEXT_MODELS = ["apple-foundationmodel"];
|
|
17
|
+
/**
|
|
18
|
+
* A tool result for the model. A failed tool gives its error message, so the
|
|
19
|
+
* model can see what went wrong and try again.
|
|
20
|
+
*/
|
|
21
|
+
function toolResultText(m) {
|
|
22
|
+
const text = contentText(m.content);
|
|
23
|
+
if (m.error)
|
|
24
|
+
return `Error: ${m.error}`;
|
|
25
|
+
return text && text !== "null" ? text : "The tool returned nothing.";
|
|
26
|
+
}
|
|
27
|
+
function contentText(content) {
|
|
28
|
+
if (typeof content === "string")
|
|
29
|
+
return content;
|
|
30
|
+
return (content ?? [])
|
|
31
|
+
.map((part) => (part.type === "text" ? part.content : ""))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The conversation in OpenAI's chat format. Tool calls and results from
|
|
36
|
+
* earlier turns are left out; their answers stay. With them in the history,
|
|
37
|
+
* Apple's model writes its next tool call as broken JSON text most of the
|
|
38
|
+
* time (4 of 12 calls worked, against 12 of 12 without). The current turn's
|
|
39
|
+
* calls and results are kept: the model answers from them.
|
|
40
|
+
*/
|
|
41
|
+
function toOpenAIMessages(messages) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const currentTurn = messages.map((m) => m.role).lastIndexOf("user");
|
|
44
|
+
for (const [i, m] of messages.entries()) {
|
|
45
|
+
const earlierToolStep = i < currentTurn &&
|
|
46
|
+
(m.role === "tool" || (m.role === "assistant" && m.toolCalls?.length));
|
|
47
|
+
const content = contentText(m.content);
|
|
48
|
+
if (earlierToolStep) {
|
|
49
|
+
if (m.role === "assistant" && content) {
|
|
50
|
+
out.push({ role: "assistant", content });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (m.role === "tool") {
|
|
54
|
+
out.push({
|
|
55
|
+
role: "tool",
|
|
56
|
+
tool_call_id: m.toolCallId ?? "",
|
|
57
|
+
content: toolResultText(m)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else if (m.role === "assistant" && m.toolCalls?.length) {
|
|
61
|
+
out.push({
|
|
62
|
+
role: "assistant",
|
|
63
|
+
content: content || null,
|
|
64
|
+
tool_calls: m.toolCalls.map((call) => ({
|
|
65
|
+
id: call.id,
|
|
66
|
+
type: "function",
|
|
67
|
+
function: call.function
|
|
68
|
+
}))
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else if (content) {
|
|
72
|
+
out.push({ role: m.role === "user" ? "user" : "assistant", content });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Tools as OpenAI function tools; `inputSchema` is already JSON Schema. */
|
|
78
|
+
function toOpenAITools(tools) {
|
|
79
|
+
return (tools ?? []).map((tool) => ({
|
|
80
|
+
type: "function",
|
|
81
|
+
function: {
|
|
82
|
+
name: tool.name,
|
|
83
|
+
description: tool.description,
|
|
84
|
+
parameters: tool.inputSchema ?? {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {},
|
|
87
|
+
required: []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `tool_choice` for the request. "auto" is apfel's default and is left out,
|
|
94
|
+
* so a plain request is sent exactly as before.
|
|
95
|
+
*/
|
|
96
|
+
function toolChoice(choice) {
|
|
97
|
+
if (!choice || choice === "auto")
|
|
98
|
+
return {};
|
|
99
|
+
if (typeof choice === "string")
|
|
100
|
+
return { tool_choice: choice };
|
|
101
|
+
return {
|
|
102
|
+
tool_choice: { type: "function", function: { name: choice.name } }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** The system prompts as one system message, if there are any. */
|
|
106
|
+
function systemMessages(prompts) {
|
|
107
|
+
const system = normalizeSystemPrompts(prompts)
|
|
108
|
+
.map((p) => p.content)
|
|
109
|
+
.join("\n\n");
|
|
110
|
+
return system ? [{ role: "system", content: system }] : [];
|
|
111
|
+
}
|
|
112
|
+
/** Yields the `data:` payloads of an SSE response body. */
|
|
113
|
+
async function* sseData(body) {
|
|
114
|
+
const reader = body.getReader();
|
|
115
|
+
const decoder = new TextDecoder();
|
|
116
|
+
let buffer = "";
|
|
117
|
+
try {
|
|
118
|
+
for (;;) {
|
|
119
|
+
const { value, done } = await reader.read();
|
|
120
|
+
if (done)
|
|
121
|
+
break;
|
|
122
|
+
buffer += decoder.decode(value, { stream: true });
|
|
123
|
+
let newline;
|
|
124
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
125
|
+
const line = buffer.slice(0, newline).trim();
|
|
126
|
+
buffer = buffer.slice(newline + 1);
|
|
127
|
+
if (line.startsWith("data:"))
|
|
128
|
+
yield line.slice(5).trim();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
reader.releaseLock();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A tool call's JSON arguments, or undefined if they aren't a JSON object. */
|
|
137
|
+
function parseArguments(json) {
|
|
138
|
+
try {
|
|
139
|
+
const args = JSON.parse(json);
|
|
140
|
+
return args && typeof args === "object" && !Array.isArray(args)
|
|
141
|
+
? args
|
|
142
|
+
: undefined;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** The message of apfel's `{"error": {"message"}}` body, or the raw body. */
|
|
149
|
+
async function errorBody(response) {
|
|
150
|
+
const raw = await response.text().catch(() => "");
|
|
151
|
+
try {
|
|
152
|
+
const message = JSON.parse(raw).error?.message;
|
|
153
|
+
if (message)
|
|
154
|
+
return message;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Not JSON: the raw body says what it says.
|
|
158
|
+
}
|
|
159
|
+
return raw.slice(0, 200);
|
|
160
|
+
}
|
|
161
|
+
/** apfel's token counts as TanStack AI usage, with the call's timings. */
|
|
162
|
+
function usageOf(usage, details) {
|
|
163
|
+
if (usage?.prompt_tokens === undefined ||
|
|
164
|
+
usage.completion_tokens === undefined) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
const result = buildBaseUsage({
|
|
168
|
+
promptTokens: usage.prompt_tokens,
|
|
169
|
+
completionTokens: usage.completion_tokens,
|
|
170
|
+
totalTokens: usage.prompt_tokens + usage.completion_tokens
|
|
171
|
+
});
|
|
172
|
+
const cachedTokens = usage.prompt_tokens_details?.cached_tokens;
|
|
173
|
+
if (cachedTokens)
|
|
174
|
+
result.promptTokensDetails = { cachedTokens };
|
|
175
|
+
if (details && Object.keys(details).length > 0) {
|
|
176
|
+
result.providerUsageDetails = details;
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
181
|
+
const unreachable = (baseURL) => `Couldn't reach apfel at ${baseURL}.`;
|
|
182
|
+
let ids = 0;
|
|
183
|
+
const nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`;
|
|
184
|
+
export class ApfelTextAdapter extends BaseTextAdapter {
|
|
185
|
+
kind = "text";
|
|
186
|
+
name = "apfel";
|
|
187
|
+
provider = "apfel";
|
|
188
|
+
api = "openai-completions";
|
|
189
|
+
inputModalities = ["text"];
|
|
190
|
+
baseURL;
|
|
191
|
+
headers;
|
|
192
|
+
constructor(config, model) {
|
|
193
|
+
super({}, model);
|
|
194
|
+
this.baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "");
|
|
195
|
+
this.headers = {
|
|
196
|
+
...config.defaultHeaders,
|
|
197
|
+
"Content-Type": "application/json"
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async *chatStream(options) {
|
|
201
|
+
const runId = options.runId ?? nextId("run");
|
|
202
|
+
const threadId = options.threadId;
|
|
203
|
+
const messageId = nextId("msg");
|
|
204
|
+
const reasoningId = nextId("reasoning");
|
|
205
|
+
const model = this.model;
|
|
206
|
+
const { logger } = options;
|
|
207
|
+
const signal = options.abortController?.signal ??
|
|
208
|
+
options.request?.signal ??
|
|
209
|
+
undefined;
|
|
210
|
+
const timestamp = () => Date.now();
|
|
211
|
+
const runError = (message) => ({
|
|
212
|
+
type: EventType.RUN_ERROR,
|
|
213
|
+
model,
|
|
214
|
+
timestamp: timestamp(),
|
|
215
|
+
message,
|
|
216
|
+
error: { message }
|
|
217
|
+
});
|
|
218
|
+
yield {
|
|
219
|
+
type: EventType.RUN_STARTED,
|
|
220
|
+
runId,
|
|
221
|
+
threadId,
|
|
222
|
+
model,
|
|
223
|
+
timestamp: timestamp(),
|
|
224
|
+
...(options.parentRunId && { parentRunId: options.parentRunId })
|
|
225
|
+
};
|
|
226
|
+
const tools = toOpenAITools(options.tools);
|
|
227
|
+
const toolNames = new Set(tools.map((tool) => tool.function.name));
|
|
228
|
+
const send = options.wrapFetch ? options.wrapFetch(fetch) : fetch;
|
|
229
|
+
const url = `${this.baseURL}/chat/completions`;
|
|
230
|
+
logger.request(`activity=chat provider=apfel model=${model} messages=${options.messages.length} tools=${tools.length} stream=true`, { provider: "apfel", model });
|
|
231
|
+
const sentAt = performance.now();
|
|
232
|
+
let response;
|
|
233
|
+
try {
|
|
234
|
+
response = await send(url, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: this.headers,
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
...options.modelOptions,
|
|
239
|
+
model,
|
|
240
|
+
stream: true,
|
|
241
|
+
// Token counts, for the stats.
|
|
242
|
+
stream_options: { include_usage: true },
|
|
243
|
+
messages: [
|
|
244
|
+
...systemMessages(options.systemPrompts),
|
|
245
|
+
...toOpenAIMessages(options.messages)
|
|
246
|
+
],
|
|
247
|
+
...(tools.length > 0 && {
|
|
248
|
+
tools,
|
|
249
|
+
...toolChoice(options.toolChoice)
|
|
250
|
+
})
|
|
251
|
+
}),
|
|
252
|
+
signal
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (signal?.aborted)
|
|
257
|
+
return;
|
|
258
|
+
logger.errors("apfel.chatStream fetch failed", {
|
|
259
|
+
error,
|
|
260
|
+
source: "apfel.chatStream"
|
|
261
|
+
});
|
|
262
|
+
yield runError(unreachable(this.baseURL));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!response.ok || !response.body) {
|
|
266
|
+
yield runError(`apfel returned HTTP ${response.status}: ${await errorBody(response)}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Reasoning (apfel sends none for Apple's model today) streams as
|
|
270
|
+
// reasoning events, then the answer as text up to any tool call the
|
|
271
|
+
// model wrote as text, which is held back and parsed at the end.
|
|
272
|
+
let reasoning = "none";
|
|
273
|
+
let textStarted = false;
|
|
274
|
+
let text = "";
|
|
275
|
+
const events = [];
|
|
276
|
+
const endReasoning = () => {
|
|
277
|
+
if (reasoning !== "open")
|
|
278
|
+
return;
|
|
279
|
+
reasoning = "closed";
|
|
280
|
+
events.push({
|
|
281
|
+
type: EventType.REASONING_MESSAGE_END,
|
|
282
|
+
messageId: reasoningId,
|
|
283
|
+
model,
|
|
284
|
+
timestamp: timestamp()
|
|
285
|
+
}, {
|
|
286
|
+
type: EventType.REASONING_END,
|
|
287
|
+
messageId: reasoningId,
|
|
288
|
+
model,
|
|
289
|
+
timestamp: timestamp()
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
const addReasoning = (delta) => {
|
|
293
|
+
if (reasoning === "none") {
|
|
294
|
+
events.push({
|
|
295
|
+
type: EventType.REASONING_START,
|
|
296
|
+
messageId: reasoningId,
|
|
297
|
+
model,
|
|
298
|
+
timestamp: timestamp()
|
|
299
|
+
}, {
|
|
300
|
+
type: EventType.REASONING_MESSAGE_START,
|
|
301
|
+
messageId: reasoningId,
|
|
302
|
+
role: "reasoning",
|
|
303
|
+
model,
|
|
304
|
+
timestamp: timestamp()
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
reasoning = "open";
|
|
308
|
+
events.push({
|
|
309
|
+
type: EventType.REASONING_MESSAGE_CONTENT,
|
|
310
|
+
messageId: reasoningId,
|
|
311
|
+
delta,
|
|
312
|
+
model,
|
|
313
|
+
timestamp: timestamp()
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
const addText = (delta) => {
|
|
317
|
+
if (!delta)
|
|
318
|
+
return;
|
|
319
|
+
endReasoning();
|
|
320
|
+
if (!textStarted) {
|
|
321
|
+
textStarted = true;
|
|
322
|
+
events.push({
|
|
323
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
324
|
+
messageId,
|
|
325
|
+
role: "assistant",
|
|
326
|
+
model,
|
|
327
|
+
timestamp: timestamp()
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
text += delta;
|
|
331
|
+
events.push({
|
|
332
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
333
|
+
messageId,
|
|
334
|
+
delta,
|
|
335
|
+
content: text,
|
|
336
|
+
model,
|
|
337
|
+
timestamp: timestamp()
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
const endText = () => {
|
|
341
|
+
if (!textStarted)
|
|
342
|
+
return;
|
|
343
|
+
textStarted = false;
|
|
344
|
+
events.push({
|
|
345
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
346
|
+
messageId,
|
|
347
|
+
model,
|
|
348
|
+
timestamp: timestamp()
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
// When apfel doesn't recognize a tool call, it passes it on as text;
|
|
352
|
+
// that part is held back and read once the reply ends.
|
|
353
|
+
const splitter = createToolCallSplitter(JSON_TOOL_CALLS_MARKER);
|
|
354
|
+
// Tool calls stream in pieces, by index.
|
|
355
|
+
const streamed = [];
|
|
356
|
+
// apfel streams a few tokens per chunk: the first chunk's share of the
|
|
357
|
+
// characters stands in for its share of the tokens.
|
|
358
|
+
const timing = { first: 0, last: 0, firstChars: 0, chars: 0 };
|
|
359
|
+
let usage;
|
|
360
|
+
let finishReason = null;
|
|
361
|
+
try {
|
|
362
|
+
for await (const data of sseData(response.body)) {
|
|
363
|
+
if (data === "[DONE]")
|
|
364
|
+
break;
|
|
365
|
+
const chunk = JSON.parse(data);
|
|
366
|
+
logger.provider("provider=apfel", { chunk });
|
|
367
|
+
if (chunk.error) {
|
|
368
|
+
throw new Error(chunk.error.message ?? "apfel sent an error.");
|
|
369
|
+
}
|
|
370
|
+
usage = chunk.usage ?? usage;
|
|
371
|
+
const choice = chunk.choices?.[0];
|
|
372
|
+
const delta = choice?.delta;
|
|
373
|
+
const chars = (delta?.content?.length ?? 0) +
|
|
374
|
+
(delta?.reasoning_content?.length ?? 0) +
|
|
375
|
+
(delta?.tool_calls ?? []).reduce((n, piece) => n +
|
|
376
|
+
(piece.function?.name?.length ?? 0) +
|
|
377
|
+
(piece.function?.arguments?.length ?? 0), 0);
|
|
378
|
+
if (chars > 0) {
|
|
379
|
+
timing.last = performance.now();
|
|
380
|
+
if (!timing.first) {
|
|
381
|
+
timing.first = timing.last;
|
|
382
|
+
timing.firstChars = chars;
|
|
383
|
+
}
|
|
384
|
+
timing.chars += chars;
|
|
385
|
+
}
|
|
386
|
+
if (delta?.reasoning_content)
|
|
387
|
+
addReasoning(delta.reasoning_content);
|
|
388
|
+
if (delta?.content)
|
|
389
|
+
addText(splitter.push(delta.content));
|
|
390
|
+
for (const piece of delta?.tool_calls ?? []) {
|
|
391
|
+
// apfel numbers its calls call_1, call_2… in every response; the
|
|
392
|
+
// adapter gives them ids of its own that are unique in the thread.
|
|
393
|
+
const call = (streamed[piece.index] ??= { name: "", arguments: "" });
|
|
394
|
+
call.name += piece.function?.name ?? "";
|
|
395
|
+
call.arguments += piece.function?.arguments ?? "";
|
|
396
|
+
}
|
|
397
|
+
if (choice?.finish_reason)
|
|
398
|
+
finishReason = choice.finish_reason;
|
|
399
|
+
yield* events.splice(0);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (signal?.aborted)
|
|
404
|
+
return;
|
|
405
|
+
logger.errors("apfel.chatStream stream failed", {
|
|
406
|
+
error,
|
|
407
|
+
source: "apfel.chatStream"
|
|
408
|
+
});
|
|
409
|
+
endReasoning();
|
|
410
|
+
endText();
|
|
411
|
+
yield* events.splice(0);
|
|
412
|
+
yield runError(`apfel's stream failed: ${errorText(error)}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const outputTokens = usage?.completion_tokens;
|
|
416
|
+
const details = timing.first
|
|
417
|
+
? {
|
|
418
|
+
firstTokenMs: timing.first - sentAt,
|
|
419
|
+
decodeMs: timing.last - timing.first,
|
|
420
|
+
...(outputTokens !== undefined && {
|
|
421
|
+
decodeTokens: (outputTokens * (timing.chars - timing.firstChars)) /
|
|
422
|
+
timing.chars
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
: {};
|
|
426
|
+
endReasoning();
|
|
427
|
+
const { text: rest, markup } = splitter.flush();
|
|
428
|
+
const fromText = markup && streamed.length === 0
|
|
429
|
+
? parseJsonToolCalls(markup, toolNames)
|
|
430
|
+
: [];
|
|
431
|
+
// Text that wasn't a readable tool call is shown after all.
|
|
432
|
+
addText(`${rest}${fromText.length > 0 ? "" : (markup ?? "")}`);
|
|
433
|
+
yield* events.splice(0);
|
|
434
|
+
// Calls to tools that don't exist are passed on too: `chat()` answers
|
|
435
|
+
// them with an "Unknown tool" error, so the model can answer with what
|
|
436
|
+
// it has instead of stopping there.
|
|
437
|
+
const calls = [
|
|
438
|
+
...streamed.filter((call) => call?.name),
|
|
439
|
+
...fromText.map((call) => ({
|
|
440
|
+
name: call.name,
|
|
441
|
+
arguments: JSON.stringify(call.args)
|
|
442
|
+
}))
|
|
443
|
+
].map((call) => ({
|
|
444
|
+
...call,
|
|
445
|
+
name: matchToolName(call.name, toolNames) ?? call.name
|
|
446
|
+
}));
|
|
447
|
+
let repairMs = 0;
|
|
448
|
+
for (const [index, call] of calls.entries()) {
|
|
449
|
+
const toolCallId = nextId("tool");
|
|
450
|
+
yield {
|
|
451
|
+
type: EventType.TOOL_CALL_START,
|
|
452
|
+
toolCallId,
|
|
453
|
+
toolCallName: call.name,
|
|
454
|
+
parentMessageId: messageId,
|
|
455
|
+
index,
|
|
456
|
+
model,
|
|
457
|
+
timestamp: timestamp()
|
|
458
|
+
};
|
|
459
|
+
const tool = options.tools?.find((t) => t.name === call.name);
|
|
460
|
+
const repairStart = performance.now();
|
|
461
|
+
const repaired = tool
|
|
462
|
+
? await this.repairArguments(tool, call.arguments, options, send, signal)
|
|
463
|
+
: undefined;
|
|
464
|
+
repairMs += performance.now() - repairStart;
|
|
465
|
+
if (signal?.aborted)
|
|
466
|
+
return;
|
|
467
|
+
const args = repaired ? JSON.stringify(repaired) : call.arguments || "{}";
|
|
468
|
+
const input = repaired ?? parseArguments(args);
|
|
469
|
+
yield {
|
|
470
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
471
|
+
toolCallId,
|
|
472
|
+
delta: args,
|
|
473
|
+
args,
|
|
474
|
+
model,
|
|
475
|
+
timestamp: timestamp()
|
|
476
|
+
};
|
|
477
|
+
yield {
|
|
478
|
+
type: EventType.TOOL_CALL_END,
|
|
479
|
+
toolCallId,
|
|
480
|
+
toolCallName: call.name,
|
|
481
|
+
args,
|
|
482
|
+
// Arguments that aren't JSON are left out; `chat()` reports them.
|
|
483
|
+
...(input && { input }),
|
|
484
|
+
model,
|
|
485
|
+
timestamp: timestamp()
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
endText();
|
|
489
|
+
yield* events.splice(0);
|
|
490
|
+
if (repairMs > 0)
|
|
491
|
+
details.toolRepairMs = repairMs;
|
|
492
|
+
const finishUsage = usageOf(usage, details);
|
|
493
|
+
yield {
|
|
494
|
+
type: EventType.RUN_FINISHED,
|
|
495
|
+
runId,
|
|
496
|
+
threadId,
|
|
497
|
+
model,
|
|
498
|
+
timestamp: timestamp(),
|
|
499
|
+
finishReason: calls.length > 0
|
|
500
|
+
? "tool_calls"
|
|
501
|
+
: finishReason === "tool_calls" || finishReason === null
|
|
502
|
+
? "stop"
|
|
503
|
+
: finishReason,
|
|
504
|
+
...(finishUsage && { usage: finishUsage })
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* A tool call's arguments, written again under the tool's schema with
|
|
509
|
+
* guided generation. Undefined if the tool takes no arguments or a request
|
|
510
|
+
* failed: the model's own arguments are used then.
|
|
511
|
+
*/
|
|
512
|
+
async repairArguments(tool, draft, options, send, signal) {
|
|
513
|
+
const system = normalizeSystemPrompts(options.systemPrompts)
|
|
514
|
+
.map((p) => p.content)
|
|
515
|
+
.join("\n\n");
|
|
516
|
+
const messages = toOpenAIMessages(options.messages);
|
|
517
|
+
const complete = async ({ instructions, schema, name }) => {
|
|
518
|
+
options.logger.request(`activity=tool-repair provider=apfel model=${this.model} tool=${tool.name} step=${name}`, { provider: "apfel", model: this.model });
|
|
519
|
+
const response = await send(`${this.baseURL}/chat/completions`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: this.headers,
|
|
522
|
+
body: JSON.stringify({
|
|
523
|
+
...options.modelOptions,
|
|
524
|
+
model: this.model,
|
|
525
|
+
// apfel takes one system message, and the conversation must end
|
|
526
|
+
// with the user or a tool result: the instructions go first.
|
|
527
|
+
messages: [
|
|
528
|
+
{
|
|
529
|
+
role: "system",
|
|
530
|
+
content: system
|
|
531
|
+
? `${instructions}\n\nThe assistant's instructions, for context:\n${system}`
|
|
532
|
+
: instructions
|
|
533
|
+
},
|
|
534
|
+
...messages
|
|
535
|
+
],
|
|
536
|
+
response_format: {
|
|
537
|
+
type: "json_schema",
|
|
538
|
+
json_schema: { name, schema }
|
|
539
|
+
}
|
|
540
|
+
}),
|
|
541
|
+
signal
|
|
542
|
+
});
|
|
543
|
+
if (!response.ok) {
|
|
544
|
+
throw new Error(`apfel returned HTTP ${response.status}: ${await errorBody(response)}`);
|
|
545
|
+
}
|
|
546
|
+
const completion = (await response.json());
|
|
547
|
+
const text = (completion.choices?.[0]?.message?.content ?? "").trim();
|
|
548
|
+
return JSON.parse(text.replace(/^```(?:json)?\s*|\s*```$/g, ""));
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
return await repairToolArguments(tool, complete, draft);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (!signal?.aborted) {
|
|
555
|
+
options.logger.errors("apfel tool-call repair failed", {
|
|
556
|
+
error,
|
|
557
|
+
source: "apfel.repairArguments"
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
|
|
565
|
+
* `response_format: json_schema` constrains Apple's model to the schema
|
|
566
|
+
* (guided generation). `chat()` validates the result.
|
|
567
|
+
*/
|
|
568
|
+
async structuredOutput(options) {
|
|
569
|
+
const { chatOptions, outputSchema } = options;
|
|
570
|
+
const model = this.model;
|
|
571
|
+
const send = chatOptions.wrapFetch ? chatOptions.wrapFetch(fetch) : fetch;
|
|
572
|
+
chatOptions.logger.request(`activity=chat provider=apfel model=${model} messages=${chatOptions.messages.length} tools=0 stream=false`, { provider: "apfel", model });
|
|
573
|
+
let response;
|
|
574
|
+
try {
|
|
575
|
+
response = await send(`${this.baseURL}/chat/completions`, {
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: this.headers,
|
|
578
|
+
body: JSON.stringify({
|
|
579
|
+
...chatOptions.modelOptions,
|
|
580
|
+
model,
|
|
581
|
+
messages: [
|
|
582
|
+
...systemMessages(chatOptions.systemPrompts),
|
|
583
|
+
...toOpenAIMessages(chatOptions.messages)
|
|
584
|
+
],
|
|
585
|
+
response_format: {
|
|
586
|
+
type: "json_schema",
|
|
587
|
+
json_schema: { name: "output", schema: outputSchema }
|
|
588
|
+
}
|
|
589
|
+
}),
|
|
590
|
+
signal: chatOptions.abortController?.signal ??
|
|
591
|
+
chatOptions.request?.signal ??
|
|
592
|
+
undefined
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
chatOptions.logger.errors("apfel.structuredOutput fetch failed", {
|
|
597
|
+
error,
|
|
598
|
+
source: "apfel.structuredOutput"
|
|
599
|
+
});
|
|
600
|
+
if (chatOptions.abortController?.signal.aborted)
|
|
601
|
+
throw error;
|
|
602
|
+
throw new Error(unreachable(this.baseURL));
|
|
603
|
+
}
|
|
604
|
+
if (!response.ok) {
|
|
605
|
+
throw new Error(`apfel returned HTTP ${response.status}: ${await errorBody(response)}`);
|
|
606
|
+
}
|
|
607
|
+
const completion = (await response.json());
|
|
608
|
+
const choice = completion.choices?.[0];
|
|
609
|
+
if (choice?.finish_reason === "length") {
|
|
610
|
+
throw new Error("apfel's answer was cut off at the token limit before its JSON ended; raise modelOptions.max_tokens.");
|
|
611
|
+
}
|
|
612
|
+
const rawText = (choice?.message?.content ??
|
|
613
|
+
choice?.message?.refusal ??
|
|
614
|
+
"").trim();
|
|
615
|
+
const json = rawText.replace(/^```(?:json)?\s*|\s*```$/g, "");
|
|
616
|
+
const usage = usageOf(completion.usage);
|
|
617
|
+
try {
|
|
618
|
+
return {
|
|
619
|
+
data: JSON.parse(json),
|
|
620
|
+
rawText,
|
|
621
|
+
model,
|
|
622
|
+
...(usage && { usage })
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
throw new Error(`The model's answer isn't JSON: ${rawText.slice(0, 200)}${rawText.length > 200 ? "…" : ""}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* A TanStack AI text adapter for Apple's on-device Foundation Model, served
|
|
632
|
+
* by apfel.
|
|
633
|
+
*
|
|
634
|
+
* @example
|
|
635
|
+
* ```ts
|
|
636
|
+
* chat({
|
|
637
|
+
* adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
|
|
638
|
+
* messages,
|
|
639
|
+
* threadId
|
|
640
|
+
* })
|
|
641
|
+
* ```
|
|
642
|
+
*/
|
|
643
|
+
export function apfelText(model, config = {}) {
|
|
644
|
+
return new ApfelTextAdapter(config, model);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Whether apfel is running and serves the model: true if `GET /v1/models`
|
|
648
|
+
* lists it within 10 seconds.
|
|
649
|
+
*/
|
|
650
|
+
export async function isApfelAvailable(model, config = {}) {
|
|
651
|
+
const baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "");
|
|
652
|
+
try {
|
|
653
|
+
const response = await fetch(`${baseURL}/models`, {
|
|
654
|
+
headers: { ...config.defaultHeaders },
|
|
655
|
+
signal: AbortSignal.timeout(10_000)
|
|
656
|
+
});
|
|
657
|
+
if (!response.ok)
|
|
658
|
+
return false;
|
|
659
|
+
const list = (await response.json());
|
|
660
|
+
return list.data?.some((m) => m.id === model) ?? false;
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack AI adapter for Apple's on-device Foundation Model, served by apfel.
|
|
3
|
+
*/
|
|
4
|
+
export { APFEL_BASE_URL, APFEL_TEXT_MODELS, ApfelTextAdapter, apfelText, isApfelAvailable } from "./adapter.js";
|
|
5
|
+
export type { ApfelModelOptions, ApfelProviderUsageDetails, ApfelTextConfig, ApfelTextModel } from "./adapter.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,gBAAgB,EACjB,MAAM,cAAc,CAAA;AACrB,YAAY,EACV,iBAAiB,EACjB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACf,MAAM,cAAc,CAAA"}
|
package/dist/index.js
ADDED