@tanstack-ai-browser/apfel 0.0.1 → 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/README.md +34 -4
- package/dist/adapter.d.ts +49 -42
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +132 -51
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +2 -2
- package/src/adapter.ts +185 -88
- package/src/index.ts +8 -3
- package/src/tool-repair.ts +321 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @tanstack-ai-browser/apfel
|
|
2
2
|
|
|
3
|
-
A [TanStack AI](https://tanstack.com/ai) adapter for Apple's on-device Foundation Model, served on your Mac by [apfel](https://apfel.franzai.com)'s OpenAI-compatible server. The browser streams straight from
|
|
3
|
+
A [TanStack AI](https://tanstack.com/ai) adapter for Apple's on-device Foundation Model, served on your Mac by [apfel](https://apfel.franzai.com)'s OpenAI-compatible server. The browser streams straight from apfel's server, at the `baseURL` you give it.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install @tanstack-ai-browser/apfel @tanstack/ai
|
|
@@ -8,11 +8,41 @@ npm install @tanstack-ai-browser/apfel @tanstack/ai
|
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { chat } from "@tanstack/ai"
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
APFEL_BASE_URL,
|
|
13
|
+
apfelText,
|
|
14
|
+
isApfelAvailable
|
|
15
|
+
} from "@tanstack-ai-browser/apfel"
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
const config = { baseURL: APFEL_BASE_URL }
|
|
18
|
+
|
|
19
|
+
if (await isApfelAvailable("apple-foundationmodel", config)) {
|
|
20
|
+
const stream = chat({
|
|
21
|
+
adapter: apfelText("apple-foundationmodel", config),
|
|
22
|
+
messages,
|
|
23
|
+
threadId
|
|
24
|
+
})
|
|
15
25
|
}
|
|
16
26
|
```
|
|
17
27
|
|
|
28
|
+
`baseURL` is apfel's OpenAI-compatible API, up to `/v1`; `APFEL_BASE_URL` is apfel's default address. `defaultHeaders` adds headers to every request, such as a bearer token. Apple's model doesn't reason, so `chat({ reasoning })` is a type error with this adapter.
|
|
29
|
+
|
|
30
|
+
## Tool calls
|
|
31
|
+
|
|
32
|
+
Tools work as with any adapter: pass them to `chat({ tools })`, MCP tools included, and `chat()` runs them. Apple's model picks the right tool but often gets its arguments wrong, renaming fields (`limit` for `numResults`) or dropping optional ones it was asked to set. So the adapter repairs every tool call before `chat()` runs it, using only the tool's JSON Schema:
|
|
33
|
+
|
|
34
|
+
1. For each optional field, apfel's guided generation answers yes or no: does the request give it a value?
|
|
35
|
+
2. The arguments are written again, constrained to the schema, with those fields required. Fields the model already named and typed right are kept as it wrote them.
|
|
36
|
+
|
|
37
|
+
Schema keywords the on-device model can't enforce (`minLength`, `pattern`, `format`…) are dropped for the repair, `$ref`s are inlined, and `anyOf`/`oneOf` take their first non-null choice. If a repair request fails, the model's own arguments are used. Repair adds two short requests per tool call (one for a tool without optional fields), about a second and a half in all; it's in `usage.providerUsageDetails.toolRepairMs`.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Each call's token counts are in `RUN_FINISHED`'s `usage`, as with any TanStack AI adapter, with timings the adapter measured in `usage.providerUsageDetails` (`ApfelProviderUsageDetails`: `firstTokenMs`, `decodeMs`, `decodeTokens`, `toolRepairMs`).
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// For example, in chat() middleware:
|
|
45
|
+
const stats = { name: "stats", onUsage: (ctx, usage) => console.log(usage) }
|
|
46
|
+
```
|
|
47
|
+
|
|
18
48
|
**Status:** early (0.0.x); the API may change. See the [repository README](https://github.com/daveycodez/tanstack-ai-browser#readme).
|
package/dist/adapter.d.ts
CHANGED
|
@@ -1,45 +1,39 @@
|
|
|
1
1
|
import type { AdapterYieldChunk, TextOptions } from "@tanstack/ai";
|
|
2
2
|
import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
3
3
|
import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters";
|
|
4
|
-
/** Where
|
|
4
|
+
/** Where apfel's server listens by default. */
|
|
5
5
|
export declare const APFEL_BASE_URL = "http://127.0.0.1:11434/v1";
|
|
6
|
-
/** The
|
|
7
|
-
export declare const
|
|
6
|
+
/** The models apfel serves: Apple's on-device Foundation Model. */
|
|
7
|
+
export declare const APFEL_TEXT_MODELS: readonly ["apple-foundationmodel"];
|
|
8
|
+
/** A model apfel serves. */
|
|
9
|
+
export type ApfelTextModel = (typeof APFEL_TEXT_MODELS)[number] | (string & {});
|
|
10
|
+
export type ApfelTextConfig = {
|
|
11
|
+
/** apfel's OpenAI-compatible API, up to `/v1`. `APFEL_BASE_URL` by default. */
|
|
12
|
+
baseURL?: string;
|
|
13
|
+
/** Headers sent with every request, such as apfel's bearer token. */
|
|
14
|
+
defaultHeaders?: Record<string, string>;
|
|
15
|
+
};
|
|
8
16
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
17
|
+
* Timings of one model call, in milliseconds, in `RUN_FINISHED`'s
|
|
18
|
+
* `usage.providerUsageDetails`. The adapter measures them as the stream
|
|
19
|
+
* arrives; apfel itself reports token counts only.
|
|
12
20
|
*/
|
|
13
|
-
export type
|
|
14
|
-
/**
|
|
15
|
-
inputTokens?: number;
|
|
16
|
-
/** Prompt tokens served from a cache, if apfel reports them. */
|
|
17
|
-
cachedTokens?: number;
|
|
18
|
-
/** Tokens the model generated. */
|
|
19
|
-
outputTokens?: number;
|
|
20
|
-
/** From sending the request to the first generated token, in ms. */
|
|
21
|
+
export type ApfelProviderUsageDetails = {
|
|
22
|
+
/** From sending the request to the first generated text. */
|
|
21
23
|
firstTokenMs?: number;
|
|
22
|
-
/**
|
|
23
|
-
decodeTokens?: number;
|
|
24
|
-
/** From the first generated chunk to the last, in ms. */
|
|
24
|
+
/** From the first generated text to the last. */
|
|
25
25
|
decodeMs?: number;
|
|
26
|
-
};
|
|
27
|
-
export type ApfelOptions = {
|
|
28
|
-
/** apfel's OpenAI-compatible API, up to `/v1`. */
|
|
29
|
-
baseUrl?: string;
|
|
30
|
-
/** The model id to ask apfel for. */
|
|
31
|
-
model?: string;
|
|
32
26
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
27
|
+
* Tokens generated after the first chunk, estimated: apfel streams a few
|
|
28
|
+
* tokens per chunk, and the first chunk's share of the characters stands
|
|
29
|
+
* in for its share of the tokens.
|
|
30
|
+
*/
|
|
31
|
+
decodeTokens?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Time spent rewriting the tool calls' arguments under their schemas,
|
|
34
|
+
* after the reply ended (see the README's "Tool calls").
|
|
36
35
|
*/
|
|
37
|
-
|
|
38
|
-
threadId: string;
|
|
39
|
-
runId: string;
|
|
40
|
-
callId: string;
|
|
41
|
-
done: boolean;
|
|
42
|
-
}) => void;
|
|
36
|
+
toolRepairMs?: number;
|
|
43
37
|
};
|
|
44
38
|
/**
|
|
45
39
|
* Request fields apfel takes for Apple's model. apfel rejects parameters
|
|
@@ -58,13 +52,22 @@ type MetadataByModality = {
|
|
|
58
52
|
video: unknown;
|
|
59
53
|
document: unknown;
|
|
60
54
|
};
|
|
61
|
-
export declare class ApfelTextAdapter extends BaseTextAdapter<
|
|
55
|
+
export declare class ApfelTextAdapter<TModel extends string> extends BaseTextAdapter<TModel, ApfelModelOptions, readonly ["text"], MetadataByModality, ReadonlyArray<string>, unknown, never, ApfelReasoning> {
|
|
56
|
+
readonly kind: "text";
|
|
62
57
|
readonly name: "apfel";
|
|
63
|
-
readonly
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
readonly provider: "apfel";
|
|
59
|
+
readonly api: "openai-completions";
|
|
60
|
+
readonly inputModalities: readonly ["text"];
|
|
61
|
+
private readonly baseURL;
|
|
62
|
+
private readonly headers;
|
|
63
|
+
constructor(config: ApfelTextConfig, model: TModel);
|
|
67
64
|
chatStream(options: TextOptions<ApfelModelOptions>): AsyncIterable<AdapterYieldChunk>;
|
|
65
|
+
/**
|
|
66
|
+
* A tool call's arguments, written again under the tool's schema with
|
|
67
|
+
* guided generation. Undefined if the tool takes no arguments or a request
|
|
68
|
+
* failed: the model's own arguments are used then.
|
|
69
|
+
*/
|
|
70
|
+
private repairArguments;
|
|
68
71
|
/**
|
|
69
72
|
* A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
|
|
70
73
|
* `response_format: json_schema` constrains Apple's model to the schema
|
|
@@ -73,19 +76,23 @@ export declare class ApfelTextAdapter extends BaseTextAdapter<string, ApfelModel
|
|
|
73
76
|
structuredOutput(options: StructuredOutputOptions<ApfelModelOptions>): Promise<StructuredOutputResult<unknown>>;
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
76
|
-
* A TanStack AI adapter for Apple's on-device Foundation Model
|
|
77
|
-
*
|
|
79
|
+
* A TanStack AI text adapter for Apple's on-device Foundation Model, served
|
|
80
|
+
* by apfel.
|
|
78
81
|
*
|
|
79
82
|
* @example
|
|
80
83
|
* ```ts
|
|
81
|
-
* chat({
|
|
84
|
+
* chat({
|
|
85
|
+
* adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
|
|
86
|
+
* messages,
|
|
87
|
+
* threadId
|
|
88
|
+
* })
|
|
82
89
|
* ```
|
|
83
90
|
*/
|
|
84
|
-
export declare function
|
|
91
|
+
export declare function apfelText<TModel extends ApfelTextModel>(model: TModel, config?: ApfelTextConfig): ApfelTextAdapter<TModel>;
|
|
85
92
|
/**
|
|
86
93
|
* Whether apfel is running and serves the model: true if `GET /v1/models`
|
|
87
94
|
* lists it within 10 seconds.
|
|
88
95
|
*/
|
|
89
|
-
export declare function isApfelAvailable(
|
|
96
|
+
export declare function isApfelAvailable(model: ApfelTextModel, config?: ApfelTextConfig): Promise<boolean>;
|
|
90
97
|
export {};
|
|
91
98
|
//# sourceMappingURL=adapter.d.ts.map
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,iBAAiB,EAEjB,WAAW,
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,iBAAiB,EAEjB,WAAW,EAGZ,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,KAAK,EACV,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,uBAAuB,CAAA;AAU9B,+CAA+C;AAC/C,eAAO,MAAM,cAAc,8BAA8B,CAAA;AAEzD,mEAAmE;AACnE,eAAO,MAAM,iBAAiB,oCAAqC,CAAA;AAEnE,4BAA4B;AAC5B,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;AAE/E,MAAM,MAAM,eAAe,GAAG;IAC5B,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAED,KAAK,cAAc,GAAG,KAAK,CAAA;AAE3B,KAAK,kBAAkB,GAAG;IACxB,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,OAAO,CAAA;CAClB,CAAA;AA0OD,qBAAa,gBAAgB,CAAC,MAAM,SAAS,MAAM,CAAE,SAAQ,eAAe,CAC1E,MAAM,EACN,iBAAiB,EACjB,SAAS,CAAC,MAAM,CAAC,EACjB,kBAAkB,EAClB,aAAa,CAAC,MAAM,CAAC,EACrB,OAAO,EACP,KAAK,EACL,cAAc,CACf;IACC,SAAkB,IAAI,EAAG,MAAM,CAAS;IACxC,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAS;IAChC,SAAkB,QAAQ,EAAG,OAAO,CAAS;IAC7C,SAAkB,GAAG,EAAG,oBAAoB,CAAS;IACrD,SAAkB,eAAe,oBAAoB;IACrD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;gBAEpC,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM;IAS3C,UAAU,CACf,OAAO,EAAE,WAAW,CAAC,iBAAiB,CAAC,GACtC,aAAa,CAAC,iBAAiB,CAAC;IAyUnC;;;;OAIG;YACW,eAAe;IAgE7B;;;;OAIG;IACG,gBAAgB,CACpB,OAAO,EAAE,uBAAuB,CAAC,iBAAiB,CAAC,GAClD,OAAO,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC;CAsE5C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,MAAM,SAAS,cAAc,EACrD,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,eAAoB,GAC3B,gBAAgB,CAAC,MAAM,CAAC,CAE1B;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,cAAc,EACrB,MAAM,GAAE,eAAoB,oBAc7B"}
|
package/dist/adapter.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A TanStack AI text adapter for Apple's on-device Foundation Model, served
|
|
3
|
-
* by apfel's OpenAI-compatible server
|
|
4
|
-
* involved: the browser streams straight from
|
|
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
5
|
* like any hosted model's adapter: `chat()` runs tools and the agent loop,
|
|
6
6
|
* and each `chatStream` call here is one completion from apfel, streamed
|
|
7
7
|
* back as AG-UI events.
|
|
8
8
|
*/
|
|
9
|
-
import { EventType, normalizeSystemPrompts } from "@tanstack/ai";
|
|
9
|
+
import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai";
|
|
10
10
|
import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
11
11
|
import { JSON_TOOL_CALLS_MARKER, createToolCallSplitter, parseJsonToolCalls } from "./tool-calls.js";
|
|
12
|
-
|
|
12
|
+
import { matchToolName, repairToolArguments } from "./tool-repair.js";
|
|
13
|
+
/** Where apfel's server listens by default. */
|
|
13
14
|
export const APFEL_BASE_URL = "http://127.0.0.1:11434/v1";
|
|
14
|
-
/** The
|
|
15
|
-
export const
|
|
15
|
+
/** The models apfel serves: Apple's on-device Foundation Model. */
|
|
16
|
+
export const APFEL_TEXT_MODELS = ["apple-foundationmodel"];
|
|
16
17
|
/**
|
|
17
18
|
* A tool result for the model. A failed tool gives its error message, so the
|
|
18
19
|
* model can see what went wrong and try again.
|
|
@@ -157,37 +158,48 @@ async function errorBody(response) {
|
|
|
157
158
|
}
|
|
158
159
|
return raw.slice(0, 200);
|
|
159
160
|
}
|
|
160
|
-
|
|
161
|
+
/** apfel's token counts as TanStack AI usage, with the call's timings. */
|
|
162
|
+
function usageOf(usage, details) {
|
|
161
163
|
if (usage?.prompt_tokens === undefined ||
|
|
162
164
|
usage.completion_tokens === undefined) {
|
|
163
165
|
return undefined;
|
|
164
166
|
}
|
|
165
|
-
const
|
|
166
|
-
return {
|
|
167
|
+
const result = buildBaseUsage({
|
|
167
168
|
promptTokens: usage.prompt_tokens,
|
|
168
169
|
completionTokens: usage.completion_tokens,
|
|
169
|
-
totalTokens: usage.prompt_tokens + usage.completion_tokens
|
|
170
|
-
|
|
171
|
-
|
|
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;
|
|
172
179
|
}
|
|
173
180
|
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
174
|
-
const unreachable = (
|
|
181
|
+
const unreachable = (baseURL) => `Couldn't reach apfel at ${baseURL}.`;
|
|
175
182
|
let ids = 0;
|
|
176
183
|
const nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`;
|
|
177
184
|
export class ApfelTextAdapter extends BaseTextAdapter {
|
|
185
|
+
kind = "text";
|
|
178
186
|
name = "apfel";
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
};
|
|
186
199
|
}
|
|
187
200
|
async *chatStream(options) {
|
|
188
201
|
const runId = options.runId ?? nextId("run");
|
|
189
202
|
const threadId = options.threadId;
|
|
190
|
-
const callId = nextId("call");
|
|
191
203
|
const messageId = nextId("msg");
|
|
192
204
|
const reasoningId = nextId("reasoning");
|
|
193
205
|
const model = this.model;
|
|
@@ -214,14 +226,14 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
214
226
|
const tools = toOpenAITools(options.tools);
|
|
215
227
|
const toolNames = new Set(tools.map((tool) => tool.function.name));
|
|
216
228
|
const send = options.wrapFetch ? options.wrapFetch(fetch) : fetch;
|
|
217
|
-
const url = `${this.
|
|
229
|
+
const url = `${this.baseURL}/chat/completions`;
|
|
218
230
|
logger.request(`activity=chat provider=apfel model=${model} messages=${options.messages.length} tools=${tools.length} stream=true`, { provider: "apfel", model });
|
|
219
231
|
const sentAt = performance.now();
|
|
220
232
|
let response;
|
|
221
233
|
try {
|
|
222
234
|
response = await send(url, {
|
|
223
235
|
method: "POST",
|
|
224
|
-
headers:
|
|
236
|
+
headers: this.headers,
|
|
225
237
|
body: JSON.stringify({
|
|
226
238
|
...options.modelOptions,
|
|
227
239
|
model,
|
|
@@ -247,7 +259,7 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
247
259
|
error,
|
|
248
260
|
source: "apfel.chatStream"
|
|
249
261
|
});
|
|
250
|
-
yield runError(unreachable(this.
|
|
262
|
+
yield runError(unreachable(this.baseURL));
|
|
251
263
|
return;
|
|
252
264
|
}
|
|
253
265
|
if (!response.ok || !response.body) {
|
|
@@ -368,8 +380,6 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
368
380
|
if (!timing.first) {
|
|
369
381
|
timing.first = timing.last;
|
|
370
382
|
timing.firstChars = chars;
|
|
371
|
-
// Token counts come only with the usage at the end.
|
|
372
|
-
this.onStats?.({ firstTokenMs: timing.first - sentAt }, { threadId, runId, callId, done: false });
|
|
373
383
|
}
|
|
374
384
|
timing.chars += chars;
|
|
375
385
|
}
|
|
@@ -403,19 +413,16 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
403
413
|
return;
|
|
404
414
|
}
|
|
405
415
|
const outputTokens = usage?.completion_tokens;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
cachedTokens: usage?.prompt_tokens_details?.cached_tokens,
|
|
409
|
-
outputTokens,
|
|
410
|
-
...(timing.first && {
|
|
416
|
+
const details = timing.first
|
|
417
|
+
? {
|
|
411
418
|
firstTokenMs: timing.first - sentAt,
|
|
419
|
+
decodeMs: timing.last - timing.first,
|
|
412
420
|
...(outputTokens !== undefined && {
|
|
413
421
|
decodeTokens: (outputTokens * (timing.chars - timing.firstChars)) /
|
|
414
|
-
timing.chars
|
|
415
|
-
decodeMs: timing.last - timing.first
|
|
422
|
+
timing.chars
|
|
416
423
|
})
|
|
417
|
-
}
|
|
418
|
-
|
|
424
|
+
}
|
|
425
|
+
: {};
|
|
419
426
|
endReasoning();
|
|
420
427
|
const { text: rest, markup } = splitter.flush();
|
|
421
428
|
const fromText = markup && streamed.length === 0
|
|
@@ -433,11 +440,13 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
433
440
|
name: call.name,
|
|
434
441
|
arguments: JSON.stringify(call.args)
|
|
435
442
|
}))
|
|
436
|
-
]
|
|
443
|
+
].map((call) => ({
|
|
444
|
+
...call,
|
|
445
|
+
name: matchToolName(call.name, toolNames) ?? call.name
|
|
446
|
+
}));
|
|
447
|
+
let repairMs = 0;
|
|
437
448
|
for (const [index, call] of calls.entries()) {
|
|
438
449
|
const toolCallId = nextId("tool");
|
|
439
|
-
const args = call.arguments || "{}";
|
|
440
|
-
const input = parseArguments(args);
|
|
441
450
|
yield {
|
|
442
451
|
type: EventType.TOOL_CALL_START,
|
|
443
452
|
toolCallId,
|
|
@@ -447,6 +456,16 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
447
456
|
model,
|
|
448
457
|
timestamp: timestamp()
|
|
449
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);
|
|
450
469
|
yield {
|
|
451
470
|
type: EventType.TOOL_CALL_ARGS,
|
|
452
471
|
toolCallId,
|
|
@@ -468,7 +487,9 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
468
487
|
}
|
|
469
488
|
endText();
|
|
470
489
|
yield* events.splice(0);
|
|
471
|
-
|
|
490
|
+
if (repairMs > 0)
|
|
491
|
+
details.toolRepairMs = repairMs;
|
|
492
|
+
const finishUsage = usageOf(usage, details);
|
|
472
493
|
yield {
|
|
473
494
|
type: EventType.RUN_FINISHED,
|
|
474
495
|
runId,
|
|
@@ -483,6 +504,62 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
483
504
|
...(finishUsage && { usage: finishUsage })
|
|
484
505
|
};
|
|
485
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
|
+
}
|
|
486
563
|
/**
|
|
487
564
|
* A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
|
|
488
565
|
* `response_format: json_schema` constrains Apple's model to the schema
|
|
@@ -495,9 +572,9 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
495
572
|
chatOptions.logger.request(`activity=chat provider=apfel model=${model} messages=${chatOptions.messages.length} tools=0 stream=false`, { provider: "apfel", model });
|
|
496
573
|
let response;
|
|
497
574
|
try {
|
|
498
|
-
response = await send(`${this.
|
|
575
|
+
response = await send(`${this.baseURL}/chat/completions`, {
|
|
499
576
|
method: "POST",
|
|
500
|
-
headers:
|
|
577
|
+
headers: this.headers,
|
|
501
578
|
body: JSON.stringify({
|
|
502
579
|
...chatOptions.modelOptions,
|
|
503
580
|
model,
|
|
@@ -522,7 +599,7 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
522
599
|
});
|
|
523
600
|
if (chatOptions.abortController?.signal.aborted)
|
|
524
601
|
throw error;
|
|
525
|
-
throw new Error(unreachable(this.
|
|
602
|
+
throw new Error(unreachable(this.baseURL));
|
|
526
603
|
}
|
|
527
604
|
if (!response.ok) {
|
|
528
605
|
throw new Error(`apfel returned HTTP ${response.status}: ${await errorBody(response)}`);
|
|
@@ -551,26 +628,30 @@ export class ApfelTextAdapter extends BaseTextAdapter {
|
|
|
551
628
|
}
|
|
552
629
|
}
|
|
553
630
|
/**
|
|
554
|
-
* A TanStack AI adapter for Apple's on-device Foundation Model
|
|
555
|
-
*
|
|
631
|
+
* A TanStack AI text adapter for Apple's on-device Foundation Model, served
|
|
632
|
+
* by apfel.
|
|
556
633
|
*
|
|
557
634
|
* @example
|
|
558
635
|
* ```ts
|
|
559
|
-
* chat({
|
|
636
|
+
* chat({
|
|
637
|
+
* adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
|
|
638
|
+
* messages,
|
|
639
|
+
* threadId
|
|
640
|
+
* })
|
|
560
641
|
* ```
|
|
561
642
|
*/
|
|
562
|
-
export function
|
|
563
|
-
return new ApfelTextAdapter(
|
|
643
|
+
export function apfelText(model, config = {}) {
|
|
644
|
+
return new ApfelTextAdapter(config, model);
|
|
564
645
|
}
|
|
565
646
|
/**
|
|
566
647
|
* Whether apfel is running and serves the model: true if `GET /v1/models`
|
|
567
648
|
* lists it within 10 seconds.
|
|
568
649
|
*/
|
|
569
|
-
export async function isApfelAvailable(
|
|
570
|
-
const
|
|
571
|
-
const model = options.model ?? APFEL_MODEL;
|
|
650
|
+
export async function isApfelAvailable(model, config = {}) {
|
|
651
|
+
const baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "");
|
|
572
652
|
try {
|
|
573
|
-
const response = await fetch(`${
|
|
653
|
+
const response = await fetch(`${baseURL}/models`, {
|
|
654
|
+
headers: { ...config.defaultHeaders },
|
|
574
655
|
signal: AbortSignal.timeout(10_000)
|
|
575
656
|
});
|
|
576
657
|
if (!response.ok)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TanStack AI adapter for Apple's on-device Foundation Model, served by apfel.
|
|
3
3
|
*/
|
|
4
|
-
export { APFEL_BASE_URL,
|
|
5
|
-
export type { ApfelModelOptions,
|
|
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
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,cAAc,EACd,
|
|
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
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TanStack AI adapter for Apple's on-device Foundation Model, served by apfel.
|
|
3
3
|
*/
|
|
4
|
-
export { APFEL_BASE_URL,
|
|
4
|
+
export { APFEL_BASE_URL, APFEL_TEXT_MODELS, ApfelTextAdapter, apfelText, isApfelAvailable } from "./adapter.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call repair. Apple's model picks the right tool but often gets its
|
|
3
|
+
* arguments wrong: it renames fields (`limit` for `numResults`, `site` for
|
|
4
|
+
* `includeDomains`) and drops optional ones it was asked to set. So after the
|
|
5
|
+
* model calls a tool, its arguments are written again with apfel's guided
|
|
6
|
+
* generation, which holds the model to the tool's JSON Schema:
|
|
7
|
+
*
|
|
8
|
+
* 1. For each optional field, a yes/no answer: does the request give it a
|
|
9
|
+
* value? (Asked for the arguments directly, the model leaves out optional
|
|
10
|
+
* numbers and arrays even when the request states them.)
|
|
11
|
+
* 2. The arguments, with the fields from step 1 required and the rest left
|
|
12
|
+
* out. Fields the model already got right (schema name, right type) are
|
|
13
|
+
* kept as it wrote them.
|
|
14
|
+
*
|
|
15
|
+
* Works from the schema alone, for any tool, MCP tools included. Through
|
|
16
|
+
* `chat()` with search, calendar, file and weather tools, the calls came out
|
|
17
|
+
* right 27 times in 32 with this, against 18 without; the misses left are
|
|
18
|
+
* the model not calling a tool at all, which this can't fix.
|
|
19
|
+
*/
|
|
20
|
+
type JsonSchema = Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* The schema in the subset guided generation enforces: types, properties,
|
|
23
|
+
* required, items, enum, const and descriptions. Local `$ref`s are inlined,
|
|
24
|
+
* `anyOf`/`oneOf`/type unions become their first non-null choice, and
|
|
25
|
+
* constraints it can't enforce (lengths, patterns, formats…) are dropped.
|
|
26
|
+
*/
|
|
27
|
+
export declare function toGuidedSchema(schema: unknown, root?: JsonSchema, depth?: number): JsonSchema;
|
|
28
|
+
/**
|
|
29
|
+
* The name of the tool the model meant, when it got the name slightly wrong
|
|
30
|
+
* (case, `-` for `_`…). Undefined if no tool matches.
|
|
31
|
+
*/
|
|
32
|
+
export declare function matchToolName(name: string, toolNames: Iterable<string>): string | undefined;
|
|
33
|
+
/** A chat completion request apfel answers with JSON matching `schema`. */
|
|
34
|
+
export type GuidedRequest = {
|
|
35
|
+
/** Instructions, sent before the conversation. */
|
|
36
|
+
instructions: string;
|
|
37
|
+
schema: JsonSchema;
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
/** Sends a guided request; resolves to the model's JSON, parsed. */
|
|
41
|
+
export type GuidedComplete = (request: GuidedRequest) => Promise<unknown>;
|
|
42
|
+
export type RepairableTool = {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
inputSchema?: unknown;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The tool call's arguments, written again under the tool's schema.
|
|
49
|
+
* Undefined if the tool takes no arguments or its schema isn't an object
|
|
50
|
+
* (the model's own arguments are used then).
|
|
51
|
+
*/
|
|
52
|
+
export declare function repairToolArguments(tool: RepairableTool, complete: GuidedComplete,
|
|
53
|
+
/** The model's own arguments, which may use the wrong field names. */
|
|
54
|
+
draft?: string): Promise<Record<string, unknown> | undefined>;
|
|
55
|
+
export {};
|
|
56
|
+
//# sourceMappingURL=tool-repair.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-repair.d.ts","sourceRoot":"","sources":["../src/tool-repair.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAoBzC;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,EACf,IAAI,GAAE,UAA2C,EACjD,KAAK,SAAI,GACR,UAAU,CAkEZ;AA4FD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,sBAMtE;AAED,2EAA2E;AAC3E,MAAM,MAAM,aAAa,GAAG;IAC1B,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,UAAU,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,oEAAoE;AACpE,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;AAEzE,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB,CAAA;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,cAAc,EACpB,QAAQ,EAAE,cAAc;AACxB,sEAAsE;AACtE,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,CAyE9C"}
|