@tanstack-ai-browser/apfel 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapter.ts ADDED
@@ -0,0 +1,868 @@
1
+ /**
2
+ * A TanStack AI text adapter for Apple's on-device Foundation Model, served
3
+ * by apfel's OpenAI-compatible server on the user's Mac. No backend
4
+ * involved: the browser streams straight from it. Hand it to `chat()`
5
+ * like any hosted model's adapter: `chat()` runs tools and the agent loop,
6
+ * and each `chatStream` call here is one completion from apfel, streamed
7
+ * back as AG-UI events.
8
+ */
9
+ import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai"
10
+ import type {
11
+ AdapterYieldChunk,
12
+ ModelMessage,
13
+ TextOptions,
14
+ TokenUsage,
15
+ Tool
16
+ } from "@tanstack/ai"
17
+ import { BaseTextAdapter } from "@tanstack/ai/adapters"
18
+ import type {
19
+ StructuredOutputOptions,
20
+ StructuredOutputResult
21
+ } from "@tanstack/ai/adapters"
22
+
23
+ import {
24
+ JSON_TOOL_CALLS_MARKER,
25
+ createToolCallSplitter,
26
+ parseJsonToolCalls
27
+ } from "./tool-calls.js"
28
+ import { matchToolName, repairToolArguments } from "./tool-repair.js"
29
+ import type { GuidedRequest } from "./tool-repair.js"
30
+
31
+ /** Where apfel's server listens by default. */
32
+ export const APFEL_BASE_URL = "http://127.0.0.1:11434/v1"
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
+ }
46
+
47
+ /**
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.
51
+ */
52
+ export type ApfelProviderUsageDetails = {
53
+ /** From sending the request to the first generated text. */
54
+ firstTokenMs?: number
55
+ /** From the first generated text to the last. */
56
+ decodeMs?: number
57
+ /**
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").
66
+ */
67
+ toolRepairMs?: number
68
+ }
69
+
70
+ /**
71
+ * Request fields apfel takes for Apple's model. apfel rejects parameters
72
+ * the model doesn't support (stop, penalties, n…) with HTTP 400.
73
+ */
74
+ export type ApfelModelOptions = {
75
+ temperature?: number
76
+ max_tokens?: number
77
+ seed?: number
78
+ }
79
+
80
+ type ApfelReasoning = never
81
+
82
+ type MetadataByModality = {
83
+ text: unknown
84
+ image: unknown
85
+ audio: unknown
86
+ video: unknown
87
+ document: unknown
88
+ }
89
+
90
+ type OpenAIToolCall = {
91
+ id: string
92
+ type: "function"
93
+ function: { name: string; arguments: string }
94
+ }
95
+
96
+ type OpenAIMessage =
97
+ | { role: "system" | "user"; content: string }
98
+ | {
99
+ role: "assistant"
100
+ content: string | null
101
+ tool_calls?: Array<OpenAIToolCall>
102
+ }
103
+ | { role: "tool"; tool_call_id: string; content: string }
104
+
105
+ type OpenAITool = {
106
+ type: "function"
107
+ function: { name: string; description: string; parameters: unknown }
108
+ }
109
+
110
+ type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null
111
+
112
+ type OpenAIUsage = {
113
+ prompt_tokens?: number
114
+ completion_tokens?: number
115
+ prompt_tokens_details?: { cached_tokens?: number }
116
+ }
117
+
118
+ type OpenAIChunk = {
119
+ choices?: Array<{
120
+ delta?: {
121
+ content?: string | null
122
+ reasoning_content?: string | null
123
+ tool_calls?: Array<{
124
+ index: number
125
+ id?: string
126
+ function?: { name?: string; arguments?: string }
127
+ }>
128
+ }
129
+ finish_reason?: FinishReason
130
+ }>
131
+ /** Sent in a last chunk of its own when asked for with include_usage. */
132
+ usage?: OpenAIUsage | null
133
+ error?: { message?: string }
134
+ }
135
+
136
+ type OpenAICompletion = {
137
+ choices?: Array<{
138
+ message?: { content?: string | null; refusal?: string | null }
139
+ finish_reason?: FinishReason
140
+ }>
141
+ usage?: OpenAIUsage | null
142
+ }
143
+
144
+ /**
145
+ * A tool result for the model. A failed tool gives its error message, so the
146
+ * model can see what went wrong and try again.
147
+ */
148
+ function toolResultText(m: ModelMessage) {
149
+ const text = contentText(m.content)
150
+ if (m.error) return `Error: ${m.error}`
151
+ return text && text !== "null" ? text : "The tool returned nothing."
152
+ }
153
+
154
+ function contentText(content: ModelMessage["content"]) {
155
+ if (typeof content === "string") return content
156
+ return (content ?? [])
157
+ .map((part) => (part.type === "text" ? part.content : ""))
158
+ .join("")
159
+ }
160
+
161
+ /**
162
+ * The conversation in OpenAI's chat format. Tool calls and results from
163
+ * earlier turns are left out; their answers stay. With them in the history,
164
+ * Apple's model writes its next tool call as broken JSON text most of the
165
+ * time (4 of 12 calls worked, against 12 of 12 without). The current turn's
166
+ * calls and results are kept: the model answers from them.
167
+ */
168
+ function toOpenAIMessages(messages: Array<ModelMessage>): Array<OpenAIMessage> {
169
+ const out: Array<OpenAIMessage> = []
170
+ const currentTurn = messages.map((m) => m.role).lastIndexOf("user")
171
+ for (const [i, m] of messages.entries()) {
172
+ const earlierToolStep =
173
+ i < currentTurn &&
174
+ (m.role === "tool" || (m.role === "assistant" && m.toolCalls?.length))
175
+ const content = contentText(m.content)
176
+ if (earlierToolStep) {
177
+ if (m.role === "assistant" && content) {
178
+ out.push({ role: "assistant", content })
179
+ }
180
+ } else if (m.role === "tool") {
181
+ out.push({
182
+ role: "tool",
183
+ tool_call_id: m.toolCallId ?? "",
184
+ content: toolResultText(m)
185
+ })
186
+ } else if (m.role === "assistant" && m.toolCalls?.length) {
187
+ out.push({
188
+ role: "assistant",
189
+ content: content || null,
190
+ tool_calls: m.toolCalls.map((call) => ({
191
+ id: call.id,
192
+ type: "function",
193
+ function: call.function
194
+ }))
195
+ })
196
+ } else if (content) {
197
+ out.push({ role: m.role === "user" ? "user" : "assistant", content })
198
+ }
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Tools as OpenAI function tools; `inputSchema` is already JSON Schema. */
204
+ function toOpenAITools(tools: Array<Tool> | undefined): Array<OpenAITool> {
205
+ return (tools ?? []).map((tool) => ({
206
+ type: "function",
207
+ function: {
208
+ name: tool.name,
209
+ description: tool.description,
210
+ parameters: tool.inputSchema ?? {
211
+ type: "object",
212
+ properties: {},
213
+ required: []
214
+ }
215
+ }
216
+ }))
217
+ }
218
+
219
+ /**
220
+ * `tool_choice` for the request. "auto" is apfel's default and is left out,
221
+ * so a plain request is sent exactly as before.
222
+ */
223
+ function toolChoice(choice: TextOptions["toolChoice"]) {
224
+ if (!choice || choice === "auto") return {}
225
+ if (typeof choice === "string") return { tool_choice: choice }
226
+ return {
227
+ tool_choice: { type: "function", function: { name: choice.name } }
228
+ }
229
+ }
230
+
231
+ /** The system prompts as one system message, if there are any. */
232
+ function systemMessages(
233
+ prompts: TextOptions["systemPrompts"]
234
+ ): Array<OpenAIMessage> {
235
+ const system = normalizeSystemPrompts(prompts)
236
+ .map((p) => p.content)
237
+ .join("\n\n")
238
+ return system ? [{ role: "system", content: system }] : []
239
+ }
240
+
241
+ /** Yields the `data:` payloads of an SSE response body. */
242
+ async function* sseData(body: ReadableStream<Uint8Array>) {
243
+ const reader = body.getReader()
244
+ const decoder = new TextDecoder()
245
+ let buffer = ""
246
+ try {
247
+ for (;;) {
248
+ const { value, done } = await reader.read()
249
+ if (done) break
250
+ buffer += decoder.decode(value, { stream: true })
251
+ let newline: number
252
+ while ((newline = buffer.indexOf("\n")) >= 0) {
253
+ const line = buffer.slice(0, newline).trim()
254
+ buffer = buffer.slice(newline + 1)
255
+ if (line.startsWith("data:")) yield line.slice(5).trim()
256
+ }
257
+ }
258
+ } finally {
259
+ reader.releaseLock()
260
+ }
261
+ }
262
+
263
+ /** A tool call's JSON arguments, or undefined if they aren't a JSON object. */
264
+ function parseArguments(json: string): Record<string, unknown> | undefined {
265
+ try {
266
+ const args = JSON.parse(json) as unknown
267
+ return args && typeof args === "object" && !Array.isArray(args)
268
+ ? (args as Record<string, unknown>)
269
+ : undefined
270
+ } catch {
271
+ return undefined
272
+ }
273
+ }
274
+
275
+ /** The message of apfel's `{"error": {"message"}}` body, or the raw body. */
276
+ async function errorBody(response: Response) {
277
+ const raw = await response.text().catch(() => "")
278
+ try {
279
+ const message = (JSON.parse(raw) as OpenAIChunk).error?.message
280
+ if (message) return message
281
+ } catch {
282
+ // Not JSON: the raw body says what it says.
283
+ }
284
+ return raw.slice(0, 200)
285
+ }
286
+
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 {
292
+ if (
293
+ usage?.prompt_tokens === undefined ||
294
+ usage.completion_tokens === undefined
295
+ ) {
296
+ return undefined
297
+ }
298
+ const result = buildBaseUsage<ApfelProviderUsageDetails>({
299
+ promptTokens: usage.prompt_tokens,
300
+ completionTokens: usage.completion_tokens,
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
307
+ }
308
+ return result
309
+ }
310
+
311
+ const errorText = (error: unknown) =>
312
+ error instanceof Error ? error.message : String(error)
313
+
314
+ const unreachable = (baseURL: string) => `Couldn't reach apfel at ${baseURL}.`
315
+
316
+ let ids = 0
317
+ const nextId = (prefix: string) =>
318
+ `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`
319
+
320
+ type StreamedCall = { name: string; arguments: string }
321
+
322
+ export class ApfelTextAdapter<TModel extends string> extends BaseTextAdapter<
323
+ TModel,
324
+ ApfelModelOptions,
325
+ readonly ["text"],
326
+ MetadataByModality,
327
+ ReadonlyArray<string>,
328
+ unknown,
329
+ never,
330
+ ApfelReasoning
331
+ > {
332
+ override readonly kind = "text" as const
333
+ readonly name = "apfel" as const
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
+ }
347
+ }
348
+
349
+ async *chatStream(
350
+ options: TextOptions<ApfelModelOptions>
351
+ ): AsyncIterable<AdapterYieldChunk> {
352
+ const runId = options.runId ?? nextId("run")
353
+ const threadId = options.threadId
354
+ const messageId = nextId("msg")
355
+ const reasoningId = nextId("reasoning")
356
+ const model = this.model
357
+ const { logger } = options
358
+ const signal =
359
+ options.abortController?.signal ??
360
+ (options.request as RequestInit | undefined)?.signal ??
361
+ undefined
362
+ const timestamp = () => Date.now()
363
+ const runError = (message: string): AdapterYieldChunk => ({
364
+ type: EventType.RUN_ERROR,
365
+ model,
366
+ timestamp: timestamp(),
367
+ message,
368
+ error: { message }
369
+ })
370
+
371
+ yield {
372
+ type: EventType.RUN_STARTED,
373
+ runId,
374
+ threadId,
375
+ model,
376
+ timestamp: timestamp(),
377
+ ...(options.parentRunId && { parentRunId: options.parentRunId })
378
+ }
379
+
380
+ const tools = toOpenAITools(options.tools)
381
+ const toolNames = new Set(tools.map((tool) => tool.function.name))
382
+ const send = options.wrapFetch ? options.wrapFetch(fetch) : fetch
383
+ const url = `${this.baseURL}/chat/completions`
384
+ logger.request(
385
+ `activity=chat provider=apfel model=${model} messages=${options.messages.length} tools=${tools.length} stream=true`,
386
+ { provider: "apfel", model }
387
+ )
388
+ const sentAt = performance.now()
389
+ let response: Response
390
+ try {
391
+ response = await send(url, {
392
+ method: "POST",
393
+ headers: this.headers,
394
+ body: JSON.stringify({
395
+ ...options.modelOptions,
396
+ model,
397
+ stream: true,
398
+ // Token counts, for the stats.
399
+ stream_options: { include_usage: true },
400
+ messages: [
401
+ ...systemMessages(options.systemPrompts),
402
+ ...toOpenAIMessages(options.messages)
403
+ ],
404
+ ...(tools.length > 0 && {
405
+ tools,
406
+ ...toolChoice(options.toolChoice)
407
+ })
408
+ }),
409
+ signal
410
+ })
411
+ } catch (error) {
412
+ if (signal?.aborted) return
413
+ logger.errors("apfel.chatStream fetch failed", {
414
+ error,
415
+ source: "apfel.chatStream"
416
+ })
417
+ yield runError(unreachable(this.baseURL))
418
+ return
419
+ }
420
+ if (!response.ok || !response.body) {
421
+ yield runError(
422
+ `apfel returned HTTP ${response.status}: ${await errorBody(response)}`
423
+ )
424
+ return
425
+ }
426
+
427
+ // Reasoning (apfel sends none for Apple's model today) streams as
428
+ // reasoning events, then the answer as text up to any tool call the
429
+ // model wrote as text, which is held back and parsed at the end.
430
+ let reasoning: "none" | "open" | "closed" = "none"
431
+ let textStarted = false
432
+ let text = ""
433
+ const events: Array<AdapterYieldChunk> = []
434
+ const endReasoning = () => {
435
+ if (reasoning !== "open") return
436
+ reasoning = "closed"
437
+ events.push(
438
+ {
439
+ type: EventType.REASONING_MESSAGE_END,
440
+ messageId: reasoningId,
441
+ model,
442
+ timestamp: timestamp()
443
+ },
444
+ {
445
+ type: EventType.REASONING_END,
446
+ messageId: reasoningId,
447
+ model,
448
+ timestamp: timestamp()
449
+ }
450
+ )
451
+ }
452
+ const addReasoning = (delta: string) => {
453
+ if (reasoning === "none") {
454
+ events.push(
455
+ {
456
+ type: EventType.REASONING_START,
457
+ messageId: reasoningId,
458
+ model,
459
+ timestamp: timestamp()
460
+ },
461
+ {
462
+ type: EventType.REASONING_MESSAGE_START,
463
+ messageId: reasoningId,
464
+ role: "reasoning",
465
+ model,
466
+ timestamp: timestamp()
467
+ }
468
+ )
469
+ }
470
+ reasoning = "open"
471
+ events.push({
472
+ type: EventType.REASONING_MESSAGE_CONTENT,
473
+ messageId: reasoningId,
474
+ delta,
475
+ model,
476
+ timestamp: timestamp()
477
+ })
478
+ }
479
+ const addText = (delta: string) => {
480
+ if (!delta) return
481
+ endReasoning()
482
+ if (!textStarted) {
483
+ textStarted = true
484
+ events.push({
485
+ type: EventType.TEXT_MESSAGE_START,
486
+ messageId,
487
+ role: "assistant",
488
+ model,
489
+ timestamp: timestamp()
490
+ })
491
+ }
492
+ text += delta
493
+ events.push({
494
+ type: EventType.TEXT_MESSAGE_CONTENT,
495
+ messageId,
496
+ delta,
497
+ content: text,
498
+ model,
499
+ timestamp: timestamp()
500
+ })
501
+ }
502
+ const endText = () => {
503
+ if (!textStarted) return
504
+ textStarted = false
505
+ events.push({
506
+ type: EventType.TEXT_MESSAGE_END,
507
+ messageId,
508
+ model,
509
+ timestamp: timestamp()
510
+ })
511
+ }
512
+
513
+ // When apfel doesn't recognize a tool call, it passes it on as text;
514
+ // that part is held back and read once the reply ends.
515
+ const splitter = createToolCallSplitter(JSON_TOOL_CALLS_MARKER)
516
+ // Tool calls stream in pieces, by index.
517
+ const streamed: Array<StreamedCall> = []
518
+ // apfel streams a few tokens per chunk: the first chunk's share of the
519
+ // characters stands in for its share of the tokens.
520
+ const timing = { first: 0, last: 0, firstChars: 0, chars: 0 }
521
+ let usage: OpenAIChunk["usage"]
522
+ let finishReason: FinishReason = null
523
+ try {
524
+ for await (const data of sseData(response.body)) {
525
+ if (data === "[DONE]") break
526
+ const chunk = JSON.parse(data) as OpenAIChunk
527
+ logger.provider("provider=apfel", { chunk })
528
+ if (chunk.error) {
529
+ throw new Error(chunk.error.message ?? "apfel sent an error.")
530
+ }
531
+ usage = chunk.usage ?? usage
532
+ const choice = chunk.choices?.[0]
533
+ const delta = choice?.delta
534
+ const chars =
535
+ (delta?.content?.length ?? 0) +
536
+ (delta?.reasoning_content?.length ?? 0) +
537
+ (delta?.tool_calls ?? []).reduce(
538
+ (n, piece) =>
539
+ n +
540
+ (piece.function?.name?.length ?? 0) +
541
+ (piece.function?.arguments?.length ?? 0),
542
+ 0
543
+ )
544
+ if (chars > 0) {
545
+ timing.last = performance.now()
546
+ if (!timing.first) {
547
+ timing.first = timing.last
548
+ timing.firstChars = chars
549
+ }
550
+ timing.chars += chars
551
+ }
552
+ if (delta?.reasoning_content) addReasoning(delta.reasoning_content)
553
+ if (delta?.content) addText(splitter.push(delta.content))
554
+ for (const piece of delta?.tool_calls ?? []) {
555
+ // apfel numbers its calls call_1, call_2… in every response; the
556
+ // adapter gives them ids of its own that are unique in the thread.
557
+ const call = (streamed[piece.index] ??= { name: "", arguments: "" })
558
+ call.name += piece.function?.name ?? ""
559
+ call.arguments += piece.function?.arguments ?? ""
560
+ }
561
+ if (choice?.finish_reason) finishReason = choice.finish_reason
562
+ yield* events.splice(0)
563
+ }
564
+ } catch (error) {
565
+ if (signal?.aborted) return
566
+ logger.errors("apfel.chatStream stream failed", {
567
+ error,
568
+ source: "apfel.chatStream"
569
+ })
570
+ endReasoning()
571
+ endText()
572
+ yield* events.splice(0)
573
+ yield runError(`apfel's stream failed: ${errorText(error)}`)
574
+ return
575
+ }
576
+
577
+ const outputTokens = usage?.completion_tokens
578
+ const details: ApfelProviderUsageDetails = timing.first
579
+ ? {
580
+ firstTokenMs: timing.first - sentAt,
581
+ decodeMs: timing.last - timing.first,
582
+ ...(outputTokens !== undefined && {
583
+ decodeTokens:
584
+ (outputTokens * (timing.chars - timing.firstChars)) /
585
+ timing.chars
586
+ })
587
+ }
588
+ : {}
589
+
590
+ endReasoning()
591
+ const { text: rest, markup } = splitter.flush()
592
+ const fromText =
593
+ markup && streamed.length === 0
594
+ ? parseJsonToolCalls(markup, toolNames)
595
+ : []
596
+ // Text that wasn't a readable tool call is shown after all.
597
+ addText(`${rest}${fromText.length > 0 ? "" : (markup ?? "")}`)
598
+ yield* events.splice(0)
599
+
600
+ // Calls to tools that don't exist are passed on too: `chat()` answers
601
+ // them with an "Unknown tool" error, so the model can answer with what
602
+ // it has instead of stopping there.
603
+ const calls = [
604
+ ...streamed.filter((call) => call?.name),
605
+ ...fromText.map((call) => ({
606
+ name: call.name,
607
+ arguments: JSON.stringify(call.args)
608
+ }))
609
+ ].map((call) => ({
610
+ ...call,
611
+ name: matchToolName(call.name, toolNames) ?? call.name
612
+ }))
613
+ let repairMs = 0
614
+ for (const [index, call] of calls.entries()) {
615
+ const toolCallId = nextId("tool")
616
+ yield {
617
+ type: EventType.TOOL_CALL_START,
618
+ toolCallId,
619
+ toolCallName: call.name,
620
+ parentMessageId: messageId,
621
+ index,
622
+ model,
623
+ timestamp: timestamp()
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)
640
+ yield {
641
+ type: EventType.TOOL_CALL_ARGS,
642
+ toolCallId,
643
+ delta: args,
644
+ args,
645
+ model,
646
+ timestamp: timestamp()
647
+ }
648
+ yield {
649
+ type: EventType.TOOL_CALL_END,
650
+ toolCallId,
651
+ toolCallName: call.name,
652
+ args,
653
+ // Arguments that aren't JSON are left out; `chat()` reports them.
654
+ ...(input && { input }),
655
+ model,
656
+ timestamp: timestamp()
657
+ }
658
+ }
659
+ endText()
660
+ yield* events.splice(0)
661
+
662
+ if (repairMs > 0) details.toolRepairMs = repairMs
663
+ const finishUsage = usageOf(usage, details)
664
+ yield {
665
+ type: EventType.RUN_FINISHED,
666
+ runId,
667
+ threadId,
668
+ model,
669
+ timestamp: timestamp(),
670
+ finishReason:
671
+ calls.length > 0
672
+ ? "tool_calls"
673
+ : finishReason === "tool_calls" || finishReason === null
674
+ ? "stop"
675
+ : finishReason,
676
+ ...(finishUsage && { usage: finishUsage })
677
+ }
678
+ }
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
+
749
+ /**
750
+ * A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
751
+ * `response_format: json_schema` constrains Apple's model to the schema
752
+ * (guided generation). `chat()` validates the result.
753
+ */
754
+ async structuredOutput(
755
+ options: StructuredOutputOptions<ApfelModelOptions>
756
+ ): Promise<StructuredOutputResult<unknown>> {
757
+ const { chatOptions, outputSchema } = options
758
+ const model = this.model
759
+ const send = chatOptions.wrapFetch ? chatOptions.wrapFetch(fetch) : fetch
760
+ chatOptions.logger.request(
761
+ `activity=chat provider=apfel model=${model} messages=${chatOptions.messages.length} tools=0 stream=false`,
762
+ { provider: "apfel", model }
763
+ )
764
+ let response: Response
765
+ try {
766
+ response = await send(`${this.baseURL}/chat/completions`, {
767
+ method: "POST",
768
+ headers: this.headers,
769
+ body: JSON.stringify({
770
+ ...chatOptions.modelOptions,
771
+ model,
772
+ messages: [
773
+ ...systemMessages(chatOptions.systemPrompts),
774
+ ...toOpenAIMessages(chatOptions.messages)
775
+ ],
776
+ response_format: {
777
+ type: "json_schema",
778
+ json_schema: { name: "output", schema: outputSchema }
779
+ }
780
+ }),
781
+ signal:
782
+ chatOptions.abortController?.signal ??
783
+ (chatOptions.request as RequestInit | undefined)?.signal ??
784
+ undefined
785
+ })
786
+ } catch (error) {
787
+ chatOptions.logger.errors("apfel.structuredOutput fetch failed", {
788
+ error,
789
+ source: "apfel.structuredOutput"
790
+ })
791
+ if (chatOptions.abortController?.signal.aborted) throw error
792
+ throw new Error(unreachable(this.baseURL))
793
+ }
794
+ if (!response.ok) {
795
+ throw new Error(
796
+ `apfel returned HTTP ${response.status}: ${await errorBody(response)}`
797
+ )
798
+ }
799
+ const completion = (await response.json()) as OpenAICompletion
800
+ const choice = completion.choices?.[0]
801
+ if (choice?.finish_reason === "length") {
802
+ throw new Error(
803
+ "apfel's answer was cut off at the token limit before its JSON ended; raise modelOptions.max_tokens."
804
+ )
805
+ }
806
+ const rawText = (
807
+ choice?.message?.content ??
808
+ choice?.message?.refusal ??
809
+ ""
810
+ ).trim()
811
+ const json = rawText.replace(/^```(?:json)?\s*|\s*```$/g, "")
812
+ const usage = usageOf(completion.usage)
813
+ try {
814
+ return {
815
+ data: JSON.parse(json) as unknown,
816
+ rawText,
817
+ model,
818
+ ...(usage && { usage })
819
+ }
820
+ } catch {
821
+ throw new Error(
822
+ `The model's answer isn't JSON: ${rawText.slice(0, 200)}${rawText.length > 200 ? "…" : ""}`
823
+ )
824
+ }
825
+ }
826
+ }
827
+
828
+ /**
829
+ * A TanStack AI text adapter for Apple's on-device Foundation Model, served
830
+ * by apfel.
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * chat({
835
+ * adapter: apfelText("apple-foundationmodel", { baseURL: APFEL_BASE_URL }),
836
+ * messages,
837
+ * threadId
838
+ * })
839
+ * ```
840
+ */
841
+ export function apfelText<TModel extends ApfelTextModel>(
842
+ model: TModel,
843
+ config: ApfelTextConfig = {}
844
+ ): ApfelTextAdapter<TModel> {
845
+ return new ApfelTextAdapter(config, model)
846
+ }
847
+
848
+ /**
849
+ * Whether apfel is running and serves the model: true if `GET /v1/models`
850
+ * lists it within 10 seconds.
851
+ */
852
+ export async function isApfelAvailable(
853
+ model: ApfelTextModel,
854
+ config: ApfelTextConfig = {}
855
+ ) {
856
+ const baseURL = (config.baseURL ?? APFEL_BASE_URL).replace(/\/+$/, "")
857
+ try {
858
+ const response = await fetch(`${baseURL}/models`, {
859
+ headers: { ...config.defaultHeaders },
860
+ signal: AbortSignal.timeout(10_000)
861
+ })
862
+ if (!response.ok) return false
863
+ const list = (await response.json()) as { data?: Array<{ id?: string }> }
864
+ return list.data?.some((m) => m.id === model) ?? false
865
+ } catch {
866
+ return false
867
+ }
868
+ }