@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 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 localhost: start it with `apfel --serve --cors`.
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 { apfel, isApfelAvailable } from "@tanstack-ai-browser/apfel"
11
+ import {
12
+ APFEL_BASE_URL,
13
+ apfelText,
14
+ isApfelAvailable
15
+ } from "@tanstack-ai-browser/apfel"
12
16
 
13
- if (await isApfelAvailable()) {
14
- const stream = chat({ adapter: apfel(), messages, threadId })
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 `apfel --serve` listens by default. */
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 model id apfel serves Apple's Foundation Model under. */
7
- export declare const APFEL_MODEL = "apple-foundationmodel";
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
- * A model call's token counts and timings. apfel reports token counts only
10
- * in the usage at the end of the stream; counts it doesn't report are left
11
- * out.
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 ApfelStats = {
14
- /** Tokens of the prompt. */
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
- /** Tokens generated after the first chunk (estimated, see below). */
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
- * The call's stats at its first token (`done: false`, time to first token
34
- * only) and when it ends (`done: true`). `callId` is unique per model
35
- * call; `chat()` makes one per step of its agent loop.
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
- onStats?: (stats: ApfelStats, run: {
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<string, ApfelModelOptions, readonly ["text"], MetadataByModality, ReadonlyArray<string>, unknown, never, ApfelReasoning> {
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 api: "apfel";
64
- private readonly baseUrl;
65
- private readonly onStats;
66
- constructor(options?: ApfelOptions);
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 through
77
- * apfel (`apfel --serve --cors`).
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({ adapter: apfel(), messages, threadId })
84
+ * chat({
85
+ * adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
86
+ * messages,
87
+ * threadId
88
+ * })
82
89
  * ```
83
90
  */
84
- export declare function apfel(options?: ApfelOptions): ApfelTextAdapter;
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(options?: Pick<ApfelOptions, "baseUrl" | "model">): Promise<boolean>;
96
+ export declare function isApfelAvailable(model: ApfelTextModel, config?: ApfelTextConfig): Promise<boolean>;
90
97
  export {};
91
98
  //# sourceMappingURL=adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,iBAAiB,EAEjB,WAAW,EAEZ,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,KAAK,EACV,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,uBAAuB,CAAA;AAQ9B,gDAAgD;AAChD,eAAO,MAAM,cAAc,8BAA8B,CAAA;AACzD,gEAAgE;AAChE,eAAO,MAAM,WAAW,0BAA0B,CAAA;AAElD;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,kCAAkC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,OAAO,CAAC,EAAE,CACR,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,KACpE,IAAI,CAAA;CACV,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;AAmOD,qBAAa,gBAAiB,SAAQ,eAAe,CACnD,MAAM,EACN,iBAAiB,EACjB,SAAS,CAAC,MAAM,CAAC,EACjB,kBAAkB,EAClB,aAAa,CAAC,MAAM,CAAC,EACrB,OAAO,EACP,KAAK,EACL,cAAc,CACf;IACC,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAS;IAChC,SAAkB,GAAG,EAAG,OAAO,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyB;gBAErC,OAAO,GAAE,YAAiB;IAM/B,UAAU,CACf,OAAO,EAAE,WAAW,CAAC,iBAAiB,CAAC,GACtC,aAAa,CAAC,iBAAiB,CAAC;IAmUnC;;;;OAIG;IACG,gBAAgB,CACpB,OAAO,EAAE,uBAAuB,CAAC,iBAAiB,CAAC,GAClD,OAAO,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC;CAsE5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,KAAK,CAAC,OAAO,CAAC,EAAE,YAAY,oBAE3C;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,GAAE,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,OAAO,CAAM,oBActD"}
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 (`apfel --serve --cors`). No backend
4
- * involved: the browser streams straight from localhost. Hand it to `chat()`
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
- /** Where `apfel --serve` listens by default. */
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 model id apfel serves Apple's Foundation Model under. */
15
- export const APFEL_MODEL = "apple-foundationmodel";
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
- function usageOf(usage) {
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 cachedTokens = usage.prompt_tokens_details?.cached_tokens;
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
- ...(cachedTokens && { promptTokensDetails: { cachedTokens } })
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 = (baseUrl) => `Couldn't reach Apple's on-device model at ${baseUrl}. Start it with \`apfel --serve --cors\`.`;
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
- api = "apfel";
180
- baseUrl;
181
- onStats;
182
- constructor(options = {}) {
183
- super({}, options.model ?? APFEL_MODEL);
184
- this.baseUrl = (options.baseUrl ?? APFEL_BASE_URL).replace(/\/+$/, "");
185
- this.onStats = options.onStats;
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.baseUrl}/chat/completions`;
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: { "Content-Type": "application/json" },
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.baseUrl));
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
- this.onStats?.({
407
- inputTokens: usage?.prompt_tokens,
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
- }, { threadId, runId, callId, done: true });
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
- const finishUsage = usageOf(usage);
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.baseUrl}/chat/completions`, {
575
+ response = await send(`${this.baseURL}/chat/completions`, {
499
576
  method: "POST",
500
- headers: { "Content-Type": "application/json" },
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.baseUrl));
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 through
555
- * apfel (`apfel --serve --cors`).
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({ adapter: apfel(), messages, threadId })
636
+ * chat({
637
+ * adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
638
+ * messages,
639
+ * threadId
640
+ * })
560
641
  * ```
561
642
  */
562
- export function apfel(options) {
563
- return new ApfelTextAdapter(options);
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(options = {}) {
570
- const baseUrl = (options.baseUrl ?? APFEL_BASE_URL).replace(/\/+$/, "");
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(`${baseUrl}/models`, {
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, APFEL_MODEL, ApfelTextAdapter, apfel, isApfelAvailable } from "./adapter.js";
5
- export type { ApfelModelOptions, ApfelOptions, ApfelStats } from "./adapter.js";
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,KAAK,EACL,gBAAgB,EACjB,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA"}
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, APFEL_MODEL, ApfelTextAdapter, apfel, isApfelAvailable } from "./adapter.js";
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"}