@tanstack-ai-browser/apfel 0.1.0 → 0.2.1
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/README.md +29 -28
- package/dist/adapter.d.ts +30 -83
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +53 -654
- package/dist/guided-schema.d.ts +20 -0
- package/dist/guided-schema.d.ts.map +1 -0
- package/dist/guided-schema.js +136 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/model-meta.d.ts +23 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +20 -0
- package/dist/tools.d.ts +68 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +469 -0
- package/package.json +11 -4
- package/src/adapter.test.ts +81 -0
- package/src/adapter.ts +89 -837
- package/src/availability.test.ts +61 -0
- package/src/index.ts +5 -13
- package/src/model-meta.ts +37 -0
- package/src/tool-calls.ts +0 -134
- package/src/tool-repair.ts +0 -321
package/dist/tools.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
const DELIMITERS = [
|
|
2
|
+
{ start: "```tool_call", end: "```" },
|
|
3
|
+
{ start: "```tool-call", end: "```" },
|
|
4
|
+
{ start: "<tool_call>", end: "</tool_call>" },
|
|
5
|
+
{ start: "<|tool_call>", end: "<tool_call|>" },
|
|
6
|
+
{ start: "<|tool_call_start|>", end: "<|tool_call_end|>" }
|
|
7
|
+
];
|
|
8
|
+
/** A Python-style call, `[name(...)]`. */
|
|
9
|
+
const PYTHON_CALL = /\[(\w+)\(/;
|
|
10
|
+
const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
/**
|
|
12
|
+
* The system prompt with the tools and how to call them; unchanged without
|
|
13
|
+
* tools.
|
|
14
|
+
*/
|
|
15
|
+
export function toolSystemPrompt(system, tools) {
|
|
16
|
+
if (tools.length === 0)
|
|
17
|
+
return system;
|
|
18
|
+
const definitions = tools.map((tool) => ({
|
|
19
|
+
name: tool.name,
|
|
20
|
+
description: tool.description ?? "No description provided.",
|
|
21
|
+
parameters: tool.inputSchema ?? { type: "object", properties: {} }
|
|
22
|
+
}));
|
|
23
|
+
const instructions = `You are a helpful AI assistant with access to tools.
|
|
24
|
+
|
|
25
|
+
# Available Tools
|
|
26
|
+
${JSON.stringify(definitions, null, 2)}
|
|
27
|
+
|
|
28
|
+
# Tool Calling Instructions
|
|
29
|
+
Only request one tool call at a time. Wait for tool results before asking for another tool.
|
|
30
|
+
|
|
31
|
+
To call a tool, output JSON in this exact format inside a \`\`\`tool_call code fence:
|
|
32
|
+
|
|
33
|
+
\`\`\`tool_call
|
|
34
|
+
{"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
Tool responses will be provided in \`\`\`tool_result fences. Each line contains JSON like:
|
|
38
|
+
\`\`\`tool_result
|
|
39
|
+
{"id": "call_123", "name": "tool_name", "result": {...}, "error": false}
|
|
40
|
+
\`\`\`
|
|
41
|
+
Use the \`result\` payload (and treat \`error\` as a boolean flag) when continuing the conversation.
|
|
42
|
+
|
|
43
|
+
Important:
|
|
44
|
+
- Use exact tool and parameter names from the schema above
|
|
45
|
+
- Arguments must be a valid JSON object matching the tool's parameters
|
|
46
|
+
- You can include brief reasoning before or after the tool call
|
|
47
|
+
- If no tool is needed, respond directly without tool_call fences`;
|
|
48
|
+
return system.trim() ? `${system.trim()}\n\n${instructions}` : instructions;
|
|
49
|
+
}
|
|
50
|
+
/** Earlier tool calls in the conversation, as the model would have written them. */
|
|
51
|
+
export function toolCallFence(calls) {
|
|
52
|
+
const lines = calls.map((call) => {
|
|
53
|
+
let args;
|
|
54
|
+
try {
|
|
55
|
+
args = JSON.parse(call.function.arguments || "{}");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
args = call.function.arguments;
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify({ name: call.function.name, arguments: args, ...(call.id && { id: call.id }) });
|
|
61
|
+
});
|
|
62
|
+
return `\`\`\`tool_call\n${lines.join("\n")}\n\`\`\``;
|
|
63
|
+
}
|
|
64
|
+
/** A tool's result line for a ```tool_result fence. */
|
|
65
|
+
export function toolResultLine(id, name, content, error) {
|
|
66
|
+
let result = error ?? content;
|
|
67
|
+
if (error === undefined) {
|
|
68
|
+
try {
|
|
69
|
+
result = JSON.parse(content);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Plain text.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify({ name, result: result ?? null, error: error !== undefined, ...(id && { id }) });
|
|
76
|
+
}
|
|
77
|
+
/** Tool results as the model reads them. */
|
|
78
|
+
export const toolResultFence = (lines) => `\`\`\`tool_result\n${lines.join("\n")}\n\`\`\``;
|
|
79
|
+
/**
|
|
80
|
+
* The first tool call in a fence's body: a JSON object (or an array of them,
|
|
81
|
+
* or one per line) with `name` (or `tool`, `tool_name`) and `arguments` (or
|
|
82
|
+
* `parameters`), the arguments possibly a JSON string, maybe wrapped in a
|
|
83
|
+
* `tool_call` or `function` key; or a Python-style call, `[name(key="v")]`;
|
|
84
|
+
* or `call:name{key:value}`.
|
|
85
|
+
*/
|
|
86
|
+
export function parseToolCall(body) {
|
|
87
|
+
const text = body.trim();
|
|
88
|
+
// `call:name{key:value,…}`
|
|
89
|
+
const colon = /call:(\w+)\{([^}]*)\}/.exec(text);
|
|
90
|
+
if (colon)
|
|
91
|
+
return { name: colon[1], arguments: parseColonArguments(colon[2]) };
|
|
92
|
+
// Python style, `[name(key="value")]` or `name(key="value")`
|
|
93
|
+
if (/^\[?\s*\w+\(/.test(text)) {
|
|
94
|
+
const call = parsePythonCall(text);
|
|
95
|
+
if (call)
|
|
96
|
+
return call;
|
|
97
|
+
}
|
|
98
|
+
const candidates = [];
|
|
99
|
+
try {
|
|
100
|
+
const value = JSON.parse(text);
|
|
101
|
+
candidates.push(...(Array.isArray(value) ? value : [value]));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
for (const line of text.split("\n")) {
|
|
105
|
+
try {
|
|
106
|
+
candidates.push(JSON.parse(line));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not JSON.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
// A call wrapped in a "tool_call" or "function" key is read too.
|
|
115
|
+
const call = isObject(candidate) && isObject(candidate.tool_call ?? candidate.function)
|
|
116
|
+
? (candidate.tool_call ?? candidate.function)
|
|
117
|
+
: candidate;
|
|
118
|
+
if (!isObject(call))
|
|
119
|
+
continue;
|
|
120
|
+
const name = call.name ?? call.tool ?? call.tool_name;
|
|
121
|
+
if (typeof name !== "string" || !name)
|
|
122
|
+
continue;
|
|
123
|
+
let args = call.arguments ?? call.parameters ?? {};
|
|
124
|
+
if (typeof args === "string") {
|
|
125
|
+
try {
|
|
126
|
+
args = JSON.parse(args);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Left as is, and rejected below.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (isObject(args))
|
|
133
|
+
return { name, arguments: args };
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/** `key:value,…` pairs, values read as numbers, booleans or null where they are. */
|
|
138
|
+
function parseColonArguments(text) {
|
|
139
|
+
const args = {};
|
|
140
|
+
for (const pair of text.split(",")) {
|
|
141
|
+
const at = pair.indexOf(":");
|
|
142
|
+
if (at <= 0)
|
|
143
|
+
continue;
|
|
144
|
+
const key = pair.slice(0, at).trim();
|
|
145
|
+
const raw = pair.slice(at + 1).trim();
|
|
146
|
+
const number = Number(raw);
|
|
147
|
+
args[key] =
|
|
148
|
+
raw === "true" ? true : raw === "false" ? false : raw === "null" ? null : raw !== "" && !Number.isNaN(number) ? number : raw;
|
|
149
|
+
}
|
|
150
|
+
return args;
|
|
151
|
+
}
|
|
152
|
+
/** The index of the bracket that closes the one at `open`, past quoted text, or -1. */
|
|
153
|
+
function closing(text, open) {
|
|
154
|
+
const pairs = { "(": ")", "[": "]", "{": "}" };
|
|
155
|
+
const stack = [];
|
|
156
|
+
let quote = "";
|
|
157
|
+
for (let i = open; i < text.length; i++) {
|
|
158
|
+
const c = text[i];
|
|
159
|
+
if (quote) {
|
|
160
|
+
if (c === "\\")
|
|
161
|
+
i++;
|
|
162
|
+
else if (c === quote)
|
|
163
|
+
quote = "";
|
|
164
|
+
}
|
|
165
|
+
else if (c === '"' || c === "'")
|
|
166
|
+
quote = c;
|
|
167
|
+
else if (pairs[c])
|
|
168
|
+
stack.push(pairs[c]);
|
|
169
|
+
else if (c === stack.at(-1)) {
|
|
170
|
+
stack.pop();
|
|
171
|
+
if (stack.length === 0)
|
|
172
|
+
return i;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return -1;
|
|
176
|
+
}
|
|
177
|
+
/** `text` split at its top-level commas (not inside quotes or brackets). */
|
|
178
|
+
function splitTopLevel(text) {
|
|
179
|
+
const parts = [];
|
|
180
|
+
let depth = 0;
|
|
181
|
+
let quote = "";
|
|
182
|
+
let from = 0;
|
|
183
|
+
for (let i = 0; i < text.length; i++) {
|
|
184
|
+
const c = text[i];
|
|
185
|
+
if (quote) {
|
|
186
|
+
if (c === "\\")
|
|
187
|
+
i++;
|
|
188
|
+
else if (c === quote)
|
|
189
|
+
quote = "";
|
|
190
|
+
}
|
|
191
|
+
else if (c === '"' || c === "'")
|
|
192
|
+
quote = c;
|
|
193
|
+
else if ("([{".includes(c))
|
|
194
|
+
depth++;
|
|
195
|
+
else if (")]}".includes(c))
|
|
196
|
+
depth--;
|
|
197
|
+
else if (c === "," && depth === 0) {
|
|
198
|
+
parts.push(text.slice(from, i));
|
|
199
|
+
from = i + 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
parts.push(text.slice(from));
|
|
203
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
204
|
+
}
|
|
205
|
+
/** A Python literal (string, number, True/False/None, list, dict) as a value. */
|
|
206
|
+
function pythonValue(raw) {
|
|
207
|
+
const text = raw.trim();
|
|
208
|
+
if (text === "True")
|
|
209
|
+
return true;
|
|
210
|
+
if (text === "False")
|
|
211
|
+
return false;
|
|
212
|
+
if (text === "None")
|
|
213
|
+
return null;
|
|
214
|
+
if (/^(["']).*\1$/s.test(text))
|
|
215
|
+
return text.slice(1, -1).replace(/\\(["'\\])/g, "$1");
|
|
216
|
+
const json = text
|
|
217
|
+
.replace(/'((?:[^'\\]|\\.)*)'/g, (_, inner) => JSON.stringify(inner.replace(/\\'/g, "'")))
|
|
218
|
+
.replace(/\bTrue\b/g, "true")
|
|
219
|
+
.replace(/\bFalse\b/g, "false")
|
|
220
|
+
.replace(/\bNone\b/g, "null");
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(json);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return text;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** The first Python-style call in `text`: `name(key=value, …)`, maybe in brackets. */
|
|
229
|
+
function parsePythonCall(text) {
|
|
230
|
+
const match = /(\w+)\(/.exec(text);
|
|
231
|
+
if (!match)
|
|
232
|
+
return undefined;
|
|
233
|
+
const open = match.index + match[1].length;
|
|
234
|
+
const close = closing(text, open);
|
|
235
|
+
if (close === -1)
|
|
236
|
+
return undefined;
|
|
237
|
+
const args = {};
|
|
238
|
+
for (const part of splitTopLevel(text.slice(open + 1, close))) {
|
|
239
|
+
const at = part.indexOf("=");
|
|
240
|
+
if (at <= 0)
|
|
241
|
+
continue;
|
|
242
|
+
args[part.slice(0, at).trim()] = pythonValue(part.slice(at + 1));
|
|
243
|
+
}
|
|
244
|
+
return { name: match[1], arguments: args };
|
|
245
|
+
}
|
|
246
|
+
/** The JSON object `text` starts with, up to its closing brace (or all of `text`). */
|
|
247
|
+
function leadingObject(text) {
|
|
248
|
+
const start = text.indexOf("{");
|
|
249
|
+
let depth = 0;
|
|
250
|
+
let quoted = false;
|
|
251
|
+
for (let i = start; i >= 0 && i < text.length; i++) {
|
|
252
|
+
const c = text[i];
|
|
253
|
+
if (quoted) {
|
|
254
|
+
if (c === "\\")
|
|
255
|
+
i++;
|
|
256
|
+
else if (c === '"')
|
|
257
|
+
quoted = false;
|
|
258
|
+
}
|
|
259
|
+
else if (c === '"')
|
|
260
|
+
quoted = true;
|
|
261
|
+
else if (c === "{")
|
|
262
|
+
depth++;
|
|
263
|
+
else if (c === "}" && --depth === 0)
|
|
264
|
+
return text.slice(start, i + 1);
|
|
265
|
+
}
|
|
266
|
+
return text;
|
|
267
|
+
}
|
|
268
|
+
/** Characters at the end of `text` that could start a fence. */
|
|
269
|
+
function partialFenceStart(text) {
|
|
270
|
+
for (let size = Math.min(text.length, Math.max(...DELIMITERS.map((d) => d.start.length)) - 1); size > 0; size--) {
|
|
271
|
+
const end = text.slice(-size);
|
|
272
|
+
if (DELIMITERS.some((d) => d.start.startsWith(end)))
|
|
273
|
+
return size;
|
|
274
|
+
}
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Reads a streamed reply: `push` each piece and get back the text that can
|
|
279
|
+
* be shown now; text inside a ```tool_call fence (or another call's
|
|
280
|
+
* delimiters: `<tool_call>`, `<|tool_call>`, `<|tool_call_start|>`, or a
|
|
281
|
+
* Python-style `[name(…)]`) is held back. The first
|
|
282
|
+
* fence that closes ends the reply (stop generating then): `fence` is its
|
|
283
|
+
* text, `call` the call in it, if it holds one (else the fence is a failed
|
|
284
|
+
* call, for `repairToolCall`), and `trailing` the text after it. Other code
|
|
285
|
+
* fences are text. `end` gives the rest, reading an unclosed fence as the
|
|
286
|
+
* reply's last.
|
|
287
|
+
*/
|
|
288
|
+
export function toolCallReader() {
|
|
289
|
+
let buffer = "";
|
|
290
|
+
// The delimiter of the call being read; a Python-style call is read whole.
|
|
291
|
+
let start;
|
|
292
|
+
let fence;
|
|
293
|
+
let call;
|
|
294
|
+
// Text after the fence, in the piece that closed it.
|
|
295
|
+
let trailing = "";
|
|
296
|
+
// A reply that starts with "{" may be a call without its fence (Apple's
|
|
297
|
+
// model writes one sometimes): held back until the reply ends.
|
|
298
|
+
let bare;
|
|
299
|
+
const read = () => {
|
|
300
|
+
let shown = "";
|
|
301
|
+
if (fence !== undefined)
|
|
302
|
+
return shown;
|
|
303
|
+
if (bare === undefined && buffer.trim())
|
|
304
|
+
bare = buffer.trimStart().startsWith("{");
|
|
305
|
+
if (bare)
|
|
306
|
+
return shown;
|
|
307
|
+
if (!start) {
|
|
308
|
+
const python = PYTHON_CALL.exec(buffer);
|
|
309
|
+
const found = [
|
|
310
|
+
...DELIMITERS.map((d) => ({ d, at: buffer.indexOf(d.start) })),
|
|
311
|
+
...(python ? [{ d: { start: python[0], end: ")]", python: true }, at: python.index }] : [])
|
|
312
|
+
]
|
|
313
|
+
.filter((f) => f.at !== -1)
|
|
314
|
+
.sort((a, b) => a.at - b.at)[0];
|
|
315
|
+
if (!found) {
|
|
316
|
+
const keep = partialFenceStart(buffer);
|
|
317
|
+
shown += buffer.slice(0, buffer.length - keep);
|
|
318
|
+
buffer = buffer.slice(buffer.length - keep);
|
|
319
|
+
return shown;
|
|
320
|
+
}
|
|
321
|
+
shown += buffer.slice(0, found.at);
|
|
322
|
+
buffer = buffer.slice(found.at);
|
|
323
|
+
start = found.d;
|
|
324
|
+
}
|
|
325
|
+
const close = buffer.indexOf(start.end, start.start.length);
|
|
326
|
+
if (close === -1)
|
|
327
|
+
return shown;
|
|
328
|
+
fence = buffer.slice(0, close + start.end.length);
|
|
329
|
+
trailing = buffer.slice(fence.length);
|
|
330
|
+
call = parseToolCall(start.python ? fence : fence.slice(start.start.length, close));
|
|
331
|
+
return shown;
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
get call() {
|
|
335
|
+
return call;
|
|
336
|
+
},
|
|
337
|
+
get fence() {
|
|
338
|
+
return fence;
|
|
339
|
+
},
|
|
340
|
+
/** Text that came after the fence: shown after the call. */
|
|
341
|
+
get trailing() {
|
|
342
|
+
return trailing;
|
|
343
|
+
},
|
|
344
|
+
push(piece) {
|
|
345
|
+
buffer += piece;
|
|
346
|
+
return read();
|
|
347
|
+
},
|
|
348
|
+
end() {
|
|
349
|
+
if (fence !== undefined)
|
|
350
|
+
return "";
|
|
351
|
+
if (start) {
|
|
352
|
+
fence = buffer;
|
|
353
|
+
call = parseToolCall(start.python ? buffer : buffer.slice(start.start.length));
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
const rest = buffer;
|
|
357
|
+
buffer = "";
|
|
358
|
+
const bareCall = bare ? parseToolCall(leadingObject(rest)) : undefined;
|
|
359
|
+
if (bareCall) {
|
|
360
|
+
fence = rest;
|
|
361
|
+
call = bareCall;
|
|
362
|
+
return "";
|
|
363
|
+
}
|
|
364
|
+
return rest;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const matches = (type, value) => type === "string"
|
|
369
|
+
? typeof value === "string"
|
|
370
|
+
: type === "number"
|
|
371
|
+
? typeof value === "number"
|
|
372
|
+
: type === "integer"
|
|
373
|
+
? Number.isInteger(value)
|
|
374
|
+
: type === "boolean"
|
|
375
|
+
? typeof value === "boolean"
|
|
376
|
+
: type === "array"
|
|
377
|
+
? Array.isArray(value)
|
|
378
|
+
: type === "object"
|
|
379
|
+
? isObject(value)
|
|
380
|
+
: type === "null"
|
|
381
|
+
? value === null
|
|
382
|
+
: true;
|
|
383
|
+
/** Why `value` doesn't fit `schema` (types, enums, required and unknown fields), if it doesn't. */
|
|
384
|
+
function schemaError(schema, value, path) {
|
|
385
|
+
if (!isObject(schema))
|
|
386
|
+
return undefined;
|
|
387
|
+
if (Array.isArray(schema.enum) &&
|
|
388
|
+
!schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {
|
|
389
|
+
return `${path} must be one of ${JSON.stringify(schema.enum)}`;
|
|
390
|
+
}
|
|
391
|
+
const types = [schema.type].flat().filter((t) => t !== undefined);
|
|
392
|
+
if (types.length > 0 && !types.some((t) => matches(t, value)))
|
|
393
|
+
return `${path} must be ${types.join(" or ")}`;
|
|
394
|
+
if (isObject(value)) {
|
|
395
|
+
const properties = isObject(schema.properties) ? schema.properties : {};
|
|
396
|
+
for (const key of Array.isArray(schema.required) ? schema.required : []) {
|
|
397
|
+
if (typeof key === "string" && !(key in value))
|
|
398
|
+
return `${path}.${key} is missing`;
|
|
399
|
+
}
|
|
400
|
+
for (const [key, field] of Object.entries(value)) {
|
|
401
|
+
if (key in properties) {
|
|
402
|
+
const error = schemaError(properties[key], field, `${path}.${key}`);
|
|
403
|
+
if (error)
|
|
404
|
+
return error;
|
|
405
|
+
}
|
|
406
|
+
else if (schema.additionalProperties === false) {
|
|
407
|
+
return `${path}.${key} isn't a parameter`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (Array.isArray(value)) {
|
|
412
|
+
for (const [i, item] of value.entries()) {
|
|
413
|
+
const error = schemaError(schema.items, item, `${path}[${i}]`);
|
|
414
|
+
if (error)
|
|
415
|
+
return error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
/** Why a call doesn't fit the tools (an unknown tool, or arguments that fail its schema), if it doesn't. */
|
|
421
|
+
export function invalidToolCall(call, tools) {
|
|
422
|
+
const tool = tools.find((t) => t.name === call.name);
|
|
423
|
+
if (!tool)
|
|
424
|
+
return `there's no tool named "${call.name}" (the tools are ${tools.map((t) => t.name).join(", ")})`;
|
|
425
|
+
return schemaError(tool.inputSchema, call.arguments, "arguments");
|
|
426
|
+
}
|
|
427
|
+
/** Why a reply's ```tool_call fence isn't a valid call, if it isn't. */
|
|
428
|
+
export const toolCallError = (call, tools) => call
|
|
429
|
+
? invalidToolCall(call, tools)
|
|
430
|
+
: 'it isn\'t JSON with "name" and "arguments"';
|
|
431
|
+
/** The message after a failed call, asking for it again. */
|
|
432
|
+
export const repairPrompt = (reason) => `That tool call wasn't valid: ${reason}. Write it again.`;
|
|
433
|
+
/**
|
|
434
|
+
* One repair of a failed tool call, with output held to a schema (as webgllm
|
|
435
|
+
* repairs): the tool's arguments if `name` is a tool, else first the tool's
|
|
436
|
+
* name, then its arguments. `generate(schema)` asks the model, with the
|
|
437
|
+
* conversation, its failed attempt and `repairPrompt`, for JSON that fits
|
|
438
|
+
* `schema`, and returns it parsed. Undefined if the repair fails too.
|
|
439
|
+
*/
|
|
440
|
+
export async function repairToolCall(tools, name, generate) {
|
|
441
|
+
if (tools.length === 0)
|
|
442
|
+
return undefined;
|
|
443
|
+
try {
|
|
444
|
+
let tool = tools.find((t) => t.name === name);
|
|
445
|
+
if (!tool) {
|
|
446
|
+
const picked = await generate({
|
|
447
|
+
type: "object",
|
|
448
|
+
properties: {
|
|
449
|
+
name: { type: "string", enum: tools.map((t) => t.name) }
|
|
450
|
+
},
|
|
451
|
+
required: ["name"]
|
|
452
|
+
});
|
|
453
|
+
tool = tools.find((t) => isObject(picked) && t.name === picked.name);
|
|
454
|
+
if (!tool)
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
const args = await generate(tool.inputSchema ?? {
|
|
458
|
+
type: "object",
|
|
459
|
+
properties: {}
|
|
460
|
+
});
|
|
461
|
+
if (!isObject(args))
|
|
462
|
+
return undefined;
|
|
463
|
+
const call = { name: tool.name, arguments: args };
|
|
464
|
+
return invalidToolCall(call, tools) ? undefined : call;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack-ai-browser/apfel",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "TanStack AI adapter for Apple's on-device Foundation Model, served by apfel",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "daveycodez",
|
|
@@ -47,10 +47,17 @@
|
|
|
47
47
|
"nx": {
|
|
48
48
|
"name": "apfel"
|
|
49
49
|
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@tanstack/ai-openai": "^1.1.1"
|
|
55
|
+
},
|
|
50
56
|
"peerDependencies": {
|
|
51
|
-
"@tanstack/ai": "^1.
|
|
57
|
+
"@tanstack/ai": "^1.2.0"
|
|
52
58
|
},
|
|
53
59
|
"devDependencies": {
|
|
54
|
-
"@tanstack/ai": "1.
|
|
60
|
+
"@tanstack/ai": "1.4.0",
|
|
61
|
+
"esbuild": "0.28.2"
|
|
55
62
|
}
|
|
56
|
-
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { afterEach, test } from "node:test"
|
|
3
|
+
|
|
4
|
+
import { chat } from "@tanstack/ai"
|
|
5
|
+
|
|
6
|
+
import { apfelText } from "./adapter.js"
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
globalThis.fetch = originalFetch
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/** apfel's streamed reply: the chunks, then [DONE]. */
|
|
14
|
+
function sse(chunks: Array<object>) {
|
|
15
|
+
const body = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n"
|
|
16
|
+
return new Response(body, { headers: { "content-type": "text/event-stream" } })
|
|
17
|
+
}
|
|
18
|
+
const chunk = (delta: object, finish: string | null = null) => ({
|
|
19
|
+
id: "c",
|
|
20
|
+
object: "chat.completion.chunk",
|
|
21
|
+
created: 0,
|
|
22
|
+
model: "apple-foundationmodel",
|
|
23
|
+
choices: [{ index: 0, delta, finish_reason: finish }]
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("a chat goes to apfel's Chat Completions with tools, and OpenRouter-style options with OpenAI's names", async () => {
|
|
27
|
+
const bodies: Array<Record<string, unknown>> = []
|
|
28
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
29
|
+
assert.equal(String(input instanceof Request ? input.url : input), "http://apfel.test/v1/chat/completions")
|
|
30
|
+
bodies.push(JSON.parse(String(init?.body ?? (input as Request).body)))
|
|
31
|
+
return sse([chunk({ role: "assistant", content: "Hi" }), chunk({}, "stop")])
|
|
32
|
+
}) as typeof fetch
|
|
33
|
+
const weather = { name: "get_weather", description: "Weather for a city", inputSchema: { type: "object", properties: { city: { type: "string" } } } }
|
|
34
|
+
const text = await chat({
|
|
35
|
+
adapter: apfelText("apple-foundationmodel", { baseURL: "http://apfel.test/v1" }),
|
|
36
|
+
threadId: "apfel-test",
|
|
37
|
+
messages: [{ role: "user", content: "hey" }],
|
|
38
|
+
tools: [weather],
|
|
39
|
+
modelOptions: { max_tokens: 64, temperature: 0.2, top_p: 0.9, seed: 1 },
|
|
40
|
+
stream: false
|
|
41
|
+
})
|
|
42
|
+
assert.equal(typeof text === "string" ? text : text.text, "Hi")
|
|
43
|
+
const body = bodies[0]
|
|
44
|
+
assert.equal(body.model, "apple-foundationmodel")
|
|
45
|
+
assert.equal(body.max_tokens, 64)
|
|
46
|
+
assert.equal(body.temperature, 0.2)
|
|
47
|
+
assert.equal(body.top_p, 0.9)
|
|
48
|
+
assert.equal(body.seed, 1)
|
|
49
|
+
assert.equal((body.tools as Array<{ function: { name: string } }>)[0].function.name, "get_weather")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("Apple's model takes images, sent to apfel as image_url parts with a data URL", async () => {
|
|
53
|
+
const bodies: Array<{ messages: Array<{ content: unknown }> }> = []
|
|
54
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
55
|
+
bodies.push(JSON.parse(String(init?.body ?? (input as Request).body)))
|
|
56
|
+
return sse([chunk({ role: "assistant", content: "A blue circle." }), chunk({}, "stop")])
|
|
57
|
+
}) as typeof fetch
|
|
58
|
+
const adapter = apfelText({ baseURL: "http://apfel.test/v1" })
|
|
59
|
+
assert.deepEqual(adapter.inputModalities, ["text", "image"])
|
|
60
|
+
assert.equal(apfelText("some-other-model").inputModalities, undefined)
|
|
61
|
+
await chat({
|
|
62
|
+
adapter,
|
|
63
|
+
threadId: "apfel-image-test",
|
|
64
|
+
messages: [
|
|
65
|
+
{
|
|
66
|
+
role: "user",
|
|
67
|
+
content: [
|
|
68
|
+
{ type: "text", content: "What is this?" },
|
|
69
|
+
{ type: "image", source: { type: "data", value: "iVBORw0KGgo=", mimeType: "image/png" } }
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
stream: false
|
|
74
|
+
})
|
|
75
|
+
const content = bodies[0].messages.at(-1)?.content as Array<{ type: string; image_url?: { url: string } }>
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
content.map((p) => p.type),
|
|
78
|
+
["text", "image_url"]
|
|
79
|
+
)
|
|
80
|
+
assert.equal(content[1].image_url?.url, "data:image/png;base64,iVBORw0KGgo=")
|
|
81
|
+
})
|