@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/src/adapter.ts CHANGED
@@ -1,16 +1,17 @@
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 type {
11
11
  AdapterYieldChunk,
12
12
  ModelMessage,
13
13
  TextOptions,
14
+ TokenUsage,
14
15
  Tool
15
16
  } from "@tanstack/ai"
16
17
  import { BaseTextAdapter } from "@tanstack/ai/adapters"
@@ -24,46 +25,46 @@ import {
24
25
  createToolCallSplitter,
25
26
  parseJsonToolCalls
26
27
  } from "./tool-calls.js"
28
+ import { matchToolName, repairToolArguments } from "./tool-repair.js"
29
+ import type { GuidedRequest } from "./tool-repair.js"
27
30
 
28
- /** Where `apfel --serve` listens by default. */
31
+ /** Where apfel's server listens by default. */
29
32
  export const APFEL_BASE_URL = "http://127.0.0.1:11434/v1"
30
- /** The model id apfel serves Apple's Foundation Model under. */
31
- export const APFEL_MODEL = "apple-foundationmodel"
33
+
34
+ /** The models apfel serves: Apple's on-device Foundation Model. */
35
+ export const APFEL_TEXT_MODELS = ["apple-foundationmodel"] as const
36
+
37
+ /** A model apfel serves. */
38
+ export type ApfelTextModel = (typeof APFEL_TEXT_MODELS)[number] | (string & {})
39
+
40
+ export type ApfelTextConfig = {
41
+ /** apfel's OpenAI-compatible API, up to `/v1`. `APFEL_BASE_URL` by default. */
42
+ baseURL?: string
43
+ /** Headers sent with every request, such as apfel's bearer token. */
44
+ defaultHeaders?: Record<string, string>
45
+ }
32
46
 
33
47
  /**
34
- * A model call's token counts and timings. apfel reports token counts only
35
- * in the usage at the end of the stream; counts it doesn't report are left
36
- * out.
48
+ * Timings of one model call, in milliseconds, in `RUN_FINISHED`'s
49
+ * `usage.providerUsageDetails`. The adapter measures them as the stream
50
+ * arrives; apfel itself reports token counts only.
37
51
  */
38
- export type ApfelStats = {
39
- /** Tokens of the prompt. */
40
- inputTokens?: number
41
- /** Prompt tokens served from a cache, if apfel reports them. */
42
- cachedTokens?: number
43
- /** Tokens the model generated. */
44
- outputTokens?: number
45
- /** From sending the request to the first generated token, in ms. */
52
+ export type ApfelProviderUsageDetails = {
53
+ /** From sending the request to the first generated text. */
46
54
  firstTokenMs?: number
47
- /** Tokens generated after the first chunk (estimated, see below). */
48
- decodeTokens?: number
49
- /** From the first generated chunk to the last, in ms. */
55
+ /** From the first generated text to the last. */
50
56
  decodeMs?: number
51
- }
52
-
53
- export type ApfelOptions = {
54
- /** apfel's OpenAI-compatible API, up to `/v1`. */
55
- baseUrl?: string
56
- /** The model id to ask apfel for. */
57
- model?: string
58
57
  /**
59
- * The call's stats at its first token (`done: false`, time to first token
60
- * only) and when it ends (`done: true`). `callId` is unique per model
61
- * call; `chat()` makes one per step of its agent loop.
58
+ * Tokens generated after the first chunk, estimated: apfel streams a few
59
+ * tokens per chunk, and the first chunk's share of the characters stands
60
+ * in for its share of the tokens.
61
+ */
62
+ decodeTokens?: number
63
+ /**
64
+ * Time spent rewriting the tool calls' arguments under their schemas,
65
+ * after the reply ended (see the README's "Tool calls").
62
66
  */
63
- onStats?: (
64
- stats: ApfelStats,
65
- run: { threadId: string; runId: string; callId: string; done: boolean }
66
- ) => void
67
+ toolRepairMs?: number
67
68
  }
68
69
 
69
70
  /**
@@ -283,27 +284,34 @@ async function errorBody(response: Response) {
283
284
  return raw.slice(0, 200)
284
285
  }
285
286
 
286
- function usageOf(usage: OpenAIUsage | null | undefined) {
287
+ /** apfel's token counts as TanStack AI usage, with the call's timings. */
288
+ function usageOf(
289
+ usage: OpenAIUsage | null | undefined,
290
+ details?: ApfelProviderUsageDetails
291
+ ): TokenUsage | undefined {
287
292
  if (
288
293
  usage?.prompt_tokens === undefined ||
289
294
  usage.completion_tokens === undefined
290
295
  ) {
291
296
  return undefined
292
297
  }
293
- const cachedTokens = usage.prompt_tokens_details?.cached_tokens
294
- return {
298
+ const result = buildBaseUsage<ApfelProviderUsageDetails>({
295
299
  promptTokens: usage.prompt_tokens,
296
300
  completionTokens: usage.completion_tokens,
297
- totalTokens: usage.prompt_tokens + usage.completion_tokens,
298
- ...(cachedTokens && { promptTokensDetails: { cachedTokens } })
301
+ totalTokens: usage.prompt_tokens + usage.completion_tokens
302
+ })
303
+ const cachedTokens = usage.prompt_tokens_details?.cached_tokens
304
+ if (cachedTokens) result.promptTokensDetails = { cachedTokens }
305
+ if (details && Object.keys(details).length > 0) {
306
+ result.providerUsageDetails = details
299
307
  }
308
+ return result
300
309
  }
301
310
 
302
311
  const errorText = (error: unknown) =>
303
312
  error instanceof Error ? error.message : String(error)
304
313
 
305
- const unreachable = (baseUrl: string) =>
306
- `Couldn't reach Apple's on-device model at ${baseUrl}. Start it with \`apfel --serve --cors\`.`
314
+ const unreachable = (baseURL: string) => `Couldn't reach apfel at ${baseURL}.`
307
315
 
308
316
  let ids = 0
309
317
  const nextId = (prefix: string) =>
@@ -311,8 +319,8 @@ const nextId = (prefix: string) =>
311
319
 
312
320
  type StreamedCall = { name: string; arguments: string }
313
321
 
314
- export class ApfelTextAdapter extends BaseTextAdapter<
315
- string,
322
+ export class ApfelTextAdapter<TModel extends string> extends BaseTextAdapter<
323
+ TModel,
316
324
  ApfelModelOptions,
317
325
  readonly ["text"],
318
326
  MetadataByModality,
@@ -321,15 +329,21 @@ export class ApfelTextAdapter extends BaseTextAdapter<
321
329
  never,
322
330
  ApfelReasoning
323
331
  > {
332
+ override readonly kind = "text" as const
324
333
  readonly name = "apfel" as const
325
- override readonly api = "apfel" as const
326
- private readonly baseUrl: string
327
- private readonly onStats: ApfelOptions["onStats"]
328
-
329
- constructor(options: ApfelOptions = {}) {
330
- super({}, options.model ?? APFEL_MODEL)
331
- this.baseUrl = (options.baseUrl ?? APFEL_BASE_URL).replace(/\/+$/, "")
332
- this.onStats = options.onStats
334
+ override readonly provider = "apfel" as const
335
+ override readonly api = "openai-completions" as const
336
+ override readonly inputModalities = ["text"] as const
337
+ private readonly baseURL: string
338
+ private readonly headers: Record<string, string>
339
+
340
+ constructor(config: ApfelTextConfig, model: TModel) {
341
+ super({}, model)
342
+ this.baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "")
343
+ this.headers = {
344
+ ...config.defaultHeaders,
345
+ "Content-Type": "application/json"
346
+ }
333
347
  }
334
348
 
335
349
  async *chatStream(
@@ -337,7 +351,6 @@ export class ApfelTextAdapter extends BaseTextAdapter<
337
351
  ): AsyncIterable<AdapterYieldChunk> {
338
352
  const runId = options.runId ?? nextId("run")
339
353
  const threadId = options.threadId
340
- const callId = nextId("call")
341
354
  const messageId = nextId("msg")
342
355
  const reasoningId = nextId("reasoning")
343
356
  const model = this.model
@@ -367,7 +380,7 @@ export class ApfelTextAdapter extends BaseTextAdapter<
367
380
  const tools = toOpenAITools(options.tools)
368
381
  const toolNames = new Set(tools.map((tool) => tool.function.name))
369
382
  const send = options.wrapFetch ? options.wrapFetch(fetch) : fetch
370
- const url = `${this.baseUrl}/chat/completions`
383
+ const url = `${this.baseURL}/chat/completions`
371
384
  logger.request(
372
385
  `activity=chat provider=apfel model=${model} messages=${options.messages.length} tools=${tools.length} stream=true`,
373
386
  { provider: "apfel", model }
@@ -377,7 +390,7 @@ export class ApfelTextAdapter extends BaseTextAdapter<
377
390
  try {
378
391
  response = await send(url, {
379
392
  method: "POST",
380
- headers: { "Content-Type": "application/json" },
393
+ headers: this.headers,
381
394
  body: JSON.stringify({
382
395
  ...options.modelOptions,
383
396
  model,
@@ -401,7 +414,7 @@ export class ApfelTextAdapter extends BaseTextAdapter<
401
414
  error,
402
415
  source: "apfel.chatStream"
403
416
  })
404
- yield runError(unreachable(this.baseUrl))
417
+ yield runError(unreachable(this.baseURL))
405
418
  return
406
419
  }
407
420
  if (!response.ok || !response.body) {
@@ -533,11 +546,6 @@ export class ApfelTextAdapter extends BaseTextAdapter<
533
546
  if (!timing.first) {
534
547
  timing.first = timing.last
535
548
  timing.firstChars = chars
536
- // Token counts come only with the usage at the end.
537
- this.onStats?.(
538
- { firstTokenMs: timing.first - sentAt },
539
- { threadId, runId, callId, done: false }
540
- )
541
549
  }
542
550
  timing.chars += chars
543
551
  }
@@ -567,23 +575,17 @@ export class ApfelTextAdapter extends BaseTextAdapter<
567
575
  }
568
576
 
569
577
  const outputTokens = usage?.completion_tokens
570
- this.onStats?.(
571
- {
572
- inputTokens: usage?.prompt_tokens,
573
- cachedTokens: usage?.prompt_tokens_details?.cached_tokens,
574
- outputTokens,
575
- ...(timing.first && {
578
+ const details: ApfelProviderUsageDetails = timing.first
579
+ ? {
576
580
  firstTokenMs: timing.first - sentAt,
581
+ decodeMs: timing.last - timing.first,
577
582
  ...(outputTokens !== undefined && {
578
583
  decodeTokens:
579
584
  (outputTokens * (timing.chars - timing.firstChars)) /
580
- timing.chars,
581
- decodeMs: timing.last - timing.first
585
+ timing.chars
582
586
  })
583
- })
584
- },
585
- { threadId, runId, callId, done: true }
586
- )
587
+ }
588
+ : {}
587
589
 
588
590
  endReasoning()
589
591
  const { text: rest, markup } = splitter.flush()
@@ -604,11 +606,13 @@ export class ApfelTextAdapter extends BaseTextAdapter<
604
606
  name: call.name,
605
607
  arguments: JSON.stringify(call.args)
606
608
  }))
607
- ]
609
+ ].map((call) => ({
610
+ ...call,
611
+ name: matchToolName(call.name, toolNames) ?? call.name
612
+ }))
613
+ let repairMs = 0
608
614
  for (const [index, call] of calls.entries()) {
609
615
  const toolCallId = nextId("tool")
610
- const args = call.arguments || "{}"
611
- const input = parseArguments(args)
612
616
  yield {
613
617
  type: EventType.TOOL_CALL_START,
614
618
  toolCallId,
@@ -618,6 +622,21 @@ export class ApfelTextAdapter extends BaseTextAdapter<
618
622
  model,
619
623
  timestamp: timestamp()
620
624
  }
625
+ const tool = options.tools?.find((t) => t.name === call.name)
626
+ const repairStart = performance.now()
627
+ const repaired = tool
628
+ ? await this.repairArguments(
629
+ tool,
630
+ call.arguments,
631
+ options,
632
+ send,
633
+ signal
634
+ )
635
+ : undefined
636
+ repairMs += performance.now() - repairStart
637
+ if (signal?.aborted) return
638
+ const args = repaired ? JSON.stringify(repaired) : call.arguments || "{}"
639
+ const input = repaired ?? parseArguments(args)
621
640
  yield {
622
641
  type: EventType.TOOL_CALL_ARGS,
623
642
  toolCallId,
@@ -640,7 +659,8 @@ export class ApfelTextAdapter extends BaseTextAdapter<
640
659
  endText()
641
660
  yield* events.splice(0)
642
661
 
643
- const finishUsage = usageOf(usage)
662
+ if (repairMs > 0) details.toolRepairMs = repairMs
663
+ const finishUsage = usageOf(usage, details)
644
664
  yield {
645
665
  type: EventType.RUN_FINISHED,
646
666
  runId,
@@ -657,6 +677,75 @@ export class ApfelTextAdapter extends BaseTextAdapter<
657
677
  }
658
678
  }
659
679
 
680
+ /**
681
+ * A tool call's arguments, written again under the tool's schema with
682
+ * guided generation. Undefined if the tool takes no arguments or a request
683
+ * failed: the model's own arguments are used then.
684
+ */
685
+ private async repairArguments(
686
+ tool: Tool,
687
+ draft: string,
688
+ options: TextOptions<ApfelModelOptions>,
689
+ send: typeof fetch,
690
+ signal: AbortSignal | undefined
691
+ ) {
692
+ const system = normalizeSystemPrompts(options.systemPrompts)
693
+ .map((p) => p.content)
694
+ .join("\n\n")
695
+ const messages = toOpenAIMessages(options.messages)
696
+ const complete = async ({ instructions, schema, name }: GuidedRequest) => {
697
+ options.logger.request(
698
+ `activity=tool-repair provider=apfel model=${this.model} tool=${tool.name} step=${name}`,
699
+ { provider: "apfel", model: this.model }
700
+ )
701
+ const response = await send(`${this.baseURL}/chat/completions`, {
702
+ method: "POST",
703
+ headers: this.headers,
704
+ body: JSON.stringify({
705
+ ...options.modelOptions,
706
+ model: this.model,
707
+ // apfel takes one system message, and the conversation must end
708
+ // with the user or a tool result: the instructions go first.
709
+ messages: [
710
+ {
711
+ role: "system",
712
+ content: system
713
+ ? `${instructions}\n\nThe assistant's instructions, for context:\n${system}`
714
+ : instructions
715
+ },
716
+ ...messages
717
+ ],
718
+ response_format: {
719
+ type: "json_schema",
720
+ json_schema: { name, schema }
721
+ }
722
+ }),
723
+ signal
724
+ })
725
+ if (!response.ok) {
726
+ throw new Error(
727
+ `apfel returned HTTP ${response.status}: ${await errorBody(response)}`
728
+ )
729
+ }
730
+ const completion = (await response.json()) as OpenAICompletion
731
+ const text = (completion.choices?.[0]?.message?.content ?? "").trim()
732
+ return JSON.parse(
733
+ text.replace(/^```(?:json)?\s*|\s*```$/g, "")
734
+ ) as unknown
735
+ }
736
+ try {
737
+ return await repairToolArguments(tool, complete, draft)
738
+ } catch (error) {
739
+ if (!signal?.aborted) {
740
+ options.logger.errors("apfel tool-call repair failed", {
741
+ error,
742
+ source: "apfel.repairArguments"
743
+ })
744
+ }
745
+ return undefined
746
+ }
747
+ }
748
+
660
749
  /**
661
750
  * A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
662
751
  * `response_format: json_schema` constrains Apple's model to the schema
@@ -674,9 +763,9 @@ export class ApfelTextAdapter extends BaseTextAdapter<
674
763
  )
675
764
  let response: Response
676
765
  try {
677
- response = await send(`${this.baseUrl}/chat/completions`, {
766
+ response = await send(`${this.baseURL}/chat/completions`, {
678
767
  method: "POST",
679
- headers: { "Content-Type": "application/json" },
768
+ headers: this.headers,
680
769
  body: JSON.stringify({
681
770
  ...chatOptions.modelOptions,
682
771
  model,
@@ -700,7 +789,7 @@ export class ApfelTextAdapter extends BaseTextAdapter<
700
789
  source: "apfel.structuredOutput"
701
790
  })
702
791
  if (chatOptions.abortController?.signal.aborted) throw error
703
- throw new Error(unreachable(this.baseUrl))
792
+ throw new Error(unreachable(this.baseURL))
704
793
  }
705
794
  if (!response.ok) {
706
795
  throw new Error(
@@ -737,16 +826,23 @@ export class ApfelTextAdapter extends BaseTextAdapter<
737
826
  }
738
827
 
739
828
  /**
740
- * A TanStack AI adapter for Apple's on-device Foundation Model through
741
- * apfel (`apfel --serve --cors`).
829
+ * A TanStack AI text adapter for Apple's on-device Foundation Model, served
830
+ * by apfel.
742
831
  *
743
832
  * @example
744
833
  * ```ts
745
- * chat({ adapter: apfel(), messages, threadId })
834
+ * chat({
835
+ * adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
836
+ * messages,
837
+ * threadId
838
+ * })
746
839
  * ```
747
840
  */
748
- export function apfel(options?: ApfelOptions) {
749
- return new ApfelTextAdapter(options)
841
+ export function apfelText<TModel extends ApfelTextModel>(
842
+ model: TModel,
843
+ config: ApfelTextConfig = {}
844
+ ): ApfelTextAdapter<TModel> {
845
+ return new ApfelTextAdapter(config, model)
750
846
  }
751
847
 
752
848
  /**
@@ -754,12 +850,13 @@ export function apfel(options?: ApfelOptions) {
754
850
  * lists it within 10 seconds.
755
851
  */
756
852
  export async function isApfelAvailable(
757
- options: Pick<ApfelOptions, "baseUrl" | "model"> = {}
853
+ model: ApfelTextModel,
854
+ config: ApfelTextConfig = {}
758
855
  ) {
759
- const baseUrl = (options.baseUrl ?? APFEL_BASE_URL).replace(/\/+$/, "")
760
- const model = options.model ?? APFEL_MODEL
856
+ const baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "")
761
857
  try {
762
- const response = await fetch(`${baseUrl}/models`, {
858
+ const response = await fetch(`${baseURL}/models`, {
859
+ headers: { ...config.defaultHeaders },
763
860
  signal: AbortSignal.timeout(10_000)
764
861
  })
765
862
  if (!response.ok) return false
package/src/index.ts CHANGED
@@ -3,9 +3,14 @@
3
3
  */
4
4
  export {
5
5
  APFEL_BASE_URL,
6
- APFEL_MODEL,
6
+ APFEL_TEXT_MODELS,
7
7
  ApfelTextAdapter,
8
- apfel,
8
+ apfelText,
9
9
  isApfelAvailable
10
10
  } from "./adapter.js"
11
- export type { ApfelModelOptions, ApfelOptions, ApfelStats } from "./adapter.js"
11
+ export type {
12
+ ApfelModelOptions,
13
+ ApfelProviderUsageDetails,
14
+ ApfelTextConfig,
15
+ ApfelTextModel
16
+ } from "./adapter.js"