@tanstack-ai-browser/apfel 0.0.1 → 0.2.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,771 +1,106 @@
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()`
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.
3
+ * by apfel. apfel speaks OpenAI's Chat Completions API, so this is TanStack's
4
+ * OpenAI-compatible adapter pointed at it: tool calls and structured output
5
+ * go to apfel as `tools` and `response_format`, image parts as `image_url`
6
+ * parts, and `modelOptions` as given. It adds `availability()` and
7
+ * `create()`, which ask apfel whether it serves the model.
8
8
  */
9
- import { EventType, normalizeSystemPrompts } from "@tanstack/ai"
10
- import type {
11
- AdapterYieldChunk,
12
- ModelMessage,
13
- TextOptions,
14
- Tool
15
- } from "@tanstack/ai"
16
- import { BaseTextAdapter } from "@tanstack/ai/adapters"
17
- import type {
18
- StructuredOutputOptions,
19
- StructuredOutputResult
20
- } from "@tanstack/ai/adapters"
9
+ import { openaiCompatibleText } from "@tanstack/ai-openai/compatible"
10
+ import type { OpenAICompatibleChatAdapter } from "@tanstack/ai-openai/compatible"
21
11
 
22
- import {
23
- JSON_TOOL_CALLS_MARKER,
24
- createToolCallSplitter,
25
- parseJsonToolCalls
26
- } from "./tool-calls.js"
12
+ import { APFEL_MODEL_META } from "./model-meta.js"
13
+ import type { ApfelChatModel } from "./model-meta.js"
27
14
 
28
- /** Where `apfel --serve` listens by default. */
29
- 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"
32
-
33
- /**
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.
37
- */
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. */
46
- 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. */
50
- 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
- /**
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.
62
- */
63
- onStats?: (
64
- stats: ApfelStats,
65
- run: { threadId: string; runId: string; callId: string; done: boolean }
66
- ) => void
15
+ export type ApfelConfig = {
16
+ /** apfel's OpenAI-compatible API, up to `/v1`. `http://127.0.0.1:11434/v1` by default. */
17
+ baseURL?: string
18
+ /** Headers sent with every request, such as apfel's bearer token. */
19
+ defaultHeaders?: Record<string, string>
67
20
  }
68
21
 
69
- /**
70
- * Request fields apfel takes for Apple's model. apfel rejects parameters
71
- * the model doesn't support (stop, penalties, n…) with HTTP 400.
72
- */
73
- export type ApfelModelOptions = {
74
- temperature?: number
22
+ /** Per-call `modelOptions`: the request fields apfel takes, with OpenAI's names, sent as given. */
23
+ export type ApfelTextModelOptions = {
24
+ /** Most tokens to generate. */
75
25
  max_tokens?: number
26
+ temperature?: number
27
+ top_p?: number
76
28
  seed?: number
77
29
  }
78
30
 
79
- type ApfelReasoning = never
80
-
81
- type MetadataByModality = {
82
- text: unknown
83
- image: unknown
84
- audio: unknown
85
- video: unknown
86
- document: unknown
87
- }
88
-
89
- type OpenAIToolCall = {
90
- id: string
91
- type: "function"
92
- function: { name: string; arguments: string }
93
- }
94
-
95
- type OpenAIMessage =
96
- | { role: "system" | "user"; content: string }
97
- | {
98
- role: "assistant"
99
- content: string | null
100
- tool_calls?: Array<OpenAIToolCall>
101
- }
102
- | { role: "tool"; tool_call_id: string; content: string }
31
+ /** The Prompt API's availability values; apfel's model is "available" or "unavailable". */
32
+ export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
103
33
 
104
- type OpenAITool = {
105
- type: "function"
106
- function: { name: string; description: string; parameters: unknown }
34
+ /** TanStack's OpenAI-compatible adapter for apfel, with apfel's availability. */
35
+ export type ApfelTextAdapter = OpenAICompatibleChatAdapter<ApfelModel, ApfelTextModelOptions> & {
36
+ /** Text and images for Apple's model; not known for another model. */
37
+ readonly inputModalities: ReadonlyArray<"text" | "image"> | undefined
38
+ /** "available" if apfel answers and serves the model, else "unavailable". */
39
+ availability(signal?: AbortSignal): Promise<Availability>
40
+ /** Resolves to this adapter if apfel serves the model, else rejects. Nothing loads. */
41
+ create(options?: { signal?: AbortSignal }): Promise<ApfelTextAdapter>
107
42
  }
108
43
 
109
- type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null
110
-
111
- type OpenAIUsage = {
112
- prompt_tokens?: number
113
- completion_tokens?: number
114
- prompt_tokens_details?: { cached_tokens?: number }
115
- }
116
-
117
- type OpenAIChunk = {
118
- choices?: Array<{
119
- delta?: {
120
- content?: string | null
121
- reasoning_content?: string | null
122
- tool_calls?: Array<{
123
- index: number
124
- id?: string
125
- function?: { name?: string; arguments?: string }
126
- }>
127
- }
128
- finish_reason?: FinishReason
129
- }>
130
- /** Sent in a last chunk of its own when asked for with include_usage. */
131
- usage?: OpenAIUsage | null
132
- error?: { message?: string }
133
- }
134
-
135
- type OpenAICompletion = {
136
- choices?: Array<{
137
- message?: { content?: string | null; refusal?: string | null }
138
- finish_reason?: FinishReason
139
- }>
140
- usage?: OpenAIUsage | null
141
- }
142
-
143
- /**
144
- * A tool result for the model. A failed tool gives its error message, so the
145
- * model can see what went wrong and try again.
146
- */
147
- function toolResultText(m: ModelMessage) {
148
- const text = contentText(m.content)
149
- if (m.error) return `Error: ${m.error}`
150
- return text && text !== "null" ? text : "The tool returned nothing."
151
- }
152
-
153
- function contentText(content: ModelMessage["content"]) {
154
- if (typeof content === "string") return content
155
- return (content ?? [])
156
- .map((part) => (part.type === "text" ? part.content : ""))
157
- .join("")
158
- }
159
-
160
- /**
161
- * The conversation in OpenAI's chat format. Tool calls and results from
162
- * earlier turns are left out; their answers stay. With them in the history,
163
- * Apple's model writes its next tool call as broken JSON text most of the
164
- * time (4 of 12 calls worked, against 12 of 12 without). The current turn's
165
- * calls and results are kept: the model answers from them.
166
- */
167
- function toOpenAIMessages(messages: Array<ModelMessage>): Array<OpenAIMessage> {
168
- const out: Array<OpenAIMessage> = []
169
- const currentTurn = messages.map((m) => m.role).lastIndexOf("user")
170
- for (const [i, m] of messages.entries()) {
171
- const earlierToolStep =
172
- i < currentTurn &&
173
- (m.role === "tool" || (m.role === "assistant" && m.toolCalls?.length))
174
- const content = contentText(m.content)
175
- if (earlierToolStep) {
176
- if (m.role === "assistant" && content) {
177
- out.push({ role: "assistant", content })
178
- }
179
- } else if (m.role === "tool") {
180
- out.push({
181
- role: "tool",
182
- tool_call_id: m.toolCallId ?? "",
183
- content: toolResultText(m)
184
- })
185
- } else if (m.role === "assistant" && m.toolCalls?.length) {
186
- out.push({
187
- role: "assistant",
188
- content: content || null,
189
- tool_calls: m.toolCalls.map((call) => ({
190
- id: call.id,
191
- type: "function",
192
- function: call.function
193
- }))
194
- })
195
- } else if (content) {
196
- out.push({ role: m.role === "user" ? "user" : "assistant", content })
197
- }
198
- }
199
- return out
200
- }
44
+ const DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"
201
45
 
202
- /** Tools as OpenAI function tools; `inputSchema` is already JSON Schema. */
203
- function toOpenAITools(tools: Array<Tool> | undefined): Array<OpenAITool> {
204
- return (tools ?? []).map((tool) => ({
205
- type: "function",
206
- function: {
207
- name: tool.name,
208
- description: tool.description,
209
- parameters: tool.inputSchema ?? {
210
- type: "object",
211
- properties: {},
212
- required: []
213
- }
214
- }
215
- }))
216
- }
46
+ /** A model apfel serves: Apple's ("apple-foundationmodel", the default), or any other name. */
47
+ export type ApfelModel = ApfelChatModel | (string & {})
217
48
 
218
49
  /**
219
- * `tool_choice` for the request. "auto" is apfel's default and is left out,
220
- * so a plain request is sent exactly as before.
50
+ * An adapter for a model apfel serves: Apple's on-device model by default.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const adapter = apfelText()
55
+ * if ((await adapter.availability()) === "available") {
56
+ * chat({ adapter, messages, tools })
57
+ * }
58
+ * ```
221
59
  */
222
- function toolChoice(choice: TextOptions["toolChoice"]) {
223
- if (!choice || choice === "auto") return {}
224
- if (typeof choice === "string") return { tool_choice: choice }
225
- return {
226
- tool_choice: { type: "function", function: { name: choice.name } }
227
- }
228
- }
229
-
230
- /** The system prompts as one system message, if there are any. */
231
- function systemMessages(
232
- prompts: TextOptions["systemPrompts"]
233
- ): Array<OpenAIMessage> {
234
- const system = normalizeSystemPrompts(prompts)
235
- .map((p) => p.content)
236
- .join("\n\n")
237
- return system ? [{ role: "system", content: system }] : []
238
- }
239
-
240
- /** Yields the `data:` payloads of an SSE response body. */
241
- async function* sseData(body: ReadableStream<Uint8Array>) {
242
- const reader = body.getReader()
243
- const decoder = new TextDecoder()
244
- let buffer = ""
245
- try {
246
- for (;;) {
247
- const { value, done } = await reader.read()
248
- if (done) break
249
- buffer += decoder.decode(value, { stream: true })
250
- let newline: number
251
- while ((newline = buffer.indexOf("\n")) >= 0) {
252
- const line = buffer.slice(0, newline).trim()
253
- buffer = buffer.slice(newline + 1)
254
- if (line.startsWith("data:")) yield line.slice(5).trim()
255
- }
256
- }
257
- } finally {
258
- reader.releaseLock()
259
- }
260
- }
261
-
262
- /** A tool call's JSON arguments, or undefined if they aren't a JSON object. */
263
- function parseArguments(json: string): Record<string, unknown> | undefined {
264
- try {
265
- const args = JSON.parse(json) as unknown
266
- return args && typeof args === "object" && !Array.isArray(args)
267
- ? (args as Record<string, unknown>)
268
- : undefined
269
- } catch {
270
- return undefined
271
- }
272
- }
273
-
274
- /** The message of apfel's `{"error": {"message"}}` body, or the raw body. */
275
- async function errorBody(response: Response) {
276
- const raw = await response.text().catch(() => "")
277
- try {
278
- const message = (JSON.parse(raw) as OpenAIChunk).error?.message
279
- if (message) return message
280
- } catch {
281
- // Not JSON: the raw body says what it says.
282
- }
283
- return raw.slice(0, 200)
284
- }
285
-
286
- function usageOf(usage: OpenAIUsage | null | undefined) {
287
- if (
288
- usage?.prompt_tokens === undefined ||
289
- usage.completion_tokens === undefined
290
- ) {
291
- return undefined
292
- }
293
- const cachedTokens = usage.prompt_tokens_details?.cached_tokens
294
- return {
295
- promptTokens: usage.prompt_tokens,
296
- completionTokens: usage.completion_tokens,
297
- totalTokens: usage.prompt_tokens + usage.completion_tokens,
298
- ...(cachedTokens && { promptTokensDetails: { cachedTokens } })
299
- }
300
- }
301
-
302
- const errorText = (error: unknown) =>
303
- error instanceof Error ? error.message : String(error)
304
-
305
- const unreachable = (baseUrl: string) =>
306
- `Couldn't reach Apple's on-device model at ${baseUrl}. Start it with \`apfel --serve --cors\`.`
307
-
308
- let ids = 0
309
- const nextId = (prefix: string) =>
310
- `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`
311
-
312
- type StreamedCall = { name: string; arguments: string }
313
-
314
- export class ApfelTextAdapter extends BaseTextAdapter<
315
- string,
316
- ApfelModelOptions,
317
- readonly ["text"],
318
- MetadataByModality,
319
- ReadonlyArray<string>,
320
- unknown,
321
- never,
322
- ApfelReasoning
323
- > {
324
- 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
333
- }
334
-
335
- async *chatStream(
336
- options: TextOptions<ApfelModelOptions>
337
- ): AsyncIterable<AdapterYieldChunk> {
338
- const runId = options.runId ?? nextId("run")
339
- const threadId = options.threadId
340
- const callId = nextId("call")
341
- const messageId = nextId("msg")
342
- const reasoningId = nextId("reasoning")
343
- const model = this.model
344
- const { logger } = options
345
- const signal =
346
- options.abortController?.signal ??
347
- (options.request as RequestInit | undefined)?.signal ??
348
- undefined
349
- const timestamp = () => Date.now()
350
- const runError = (message: string): AdapterYieldChunk => ({
351
- type: EventType.RUN_ERROR,
352
- model,
353
- timestamp: timestamp(),
354
- message,
355
- error: { message }
356
- })
357
-
358
- yield {
359
- type: EventType.RUN_STARTED,
360
- runId,
361
- threadId,
362
- model,
363
- timestamp: timestamp(),
364
- ...(options.parentRunId && { parentRunId: options.parentRunId })
365
- }
366
-
367
- const tools = toOpenAITools(options.tools)
368
- const toolNames = new Set(tools.map((tool) => tool.function.name))
369
- const send = options.wrapFetch ? options.wrapFetch(fetch) : fetch
370
- const url = `${this.baseUrl}/chat/completions`
371
- logger.request(
372
- `activity=chat provider=apfel model=${model} messages=${options.messages.length} tools=${tools.length} stream=true`,
373
- { provider: "apfel", model }
374
- )
375
- const sentAt = performance.now()
376
- let response: Response
60
+ export function apfelText(config?: ApfelConfig): ApfelTextAdapter
61
+ export function apfelText(model: ApfelModel, config?: ApfelConfig): ApfelTextAdapter
62
+ export function apfelText(modelOrConfig?: ApfelModel | ApfelConfig, maybeConfig: ApfelConfig = {}): ApfelTextAdapter {
63
+ const model = typeof modelOrConfig === "string" ? modelOrConfig : "apple-foundationmodel"
64
+ const config = typeof modelOrConfig === "string" ? maybeConfig : (modelOrConfig ?? {})
65
+ const baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, "")
66
+ const headers = config.defaultHeaders
67
+ const adapter = openaiCompatibleText(model, {
68
+ name: "apfel",
69
+ baseURL,
70
+ // apfel takes any key unless it was started with a token (see defaultHeaders).
71
+ apiKey: "apfel",
72
+ api: "chat-completions",
73
+ ...(headers && { defaultHeaders: headers }),
74
+ // apfel runs on the user's own machine: there's no secret key to expose.
75
+ dangerouslyAllowBrowser: true
76
+ }) as OpenAICompatibleChatAdapter<ApfelModel, ApfelTextModelOptions>
77
+
78
+ const availability = async (signal?: AbortSignal): Promise<Availability> => {
377
79
  try {
378
- response = await send(url, {
379
- method: "POST",
380
- headers: { "Content-Type": "application/json" },
381
- body: JSON.stringify({
382
- ...options.modelOptions,
383
- model,
384
- stream: true,
385
- // Token counts, for the stats.
386
- stream_options: { include_usage: true },
387
- messages: [
388
- ...systemMessages(options.systemPrompts),
389
- ...toOpenAIMessages(options.messages)
390
- ],
391
- ...(tools.length > 0 && {
392
- tools,
393
- ...toolChoice(options.toolChoice)
394
- })
395
- }),
396
- signal
80
+ const timeout = AbortSignal.timeout(10_000)
81
+ const response = await fetch(`${baseURL}/models`, {
82
+ ...(headers && { headers }),
83
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout
397
84
  })
398
- } catch (error) {
399
- if (signal?.aborted) return
400
- logger.errors("apfel.chatStream fetch failed", {
401
- error,
402
- source: "apfel.chatStream"
403
- })
404
- yield runError(unreachable(this.baseUrl))
405
- return
406
- }
407
- if (!response.ok || !response.body) {
408
- yield runError(
409
- `apfel returned HTTP ${response.status}: ${await errorBody(response)}`
410
- )
411
- return
412
- }
413
-
414
- // Reasoning (apfel sends none for Apple's model today) streams as
415
- // reasoning events, then the answer as text up to any tool call the
416
- // model wrote as text, which is held back and parsed at the end.
417
- let reasoning: "none" | "open" | "closed" = "none"
418
- let textStarted = false
419
- let text = ""
420
- const events: Array<AdapterYieldChunk> = []
421
- const endReasoning = () => {
422
- if (reasoning !== "open") return
423
- reasoning = "closed"
424
- events.push(
425
- {
426
- type: EventType.REASONING_MESSAGE_END,
427
- messageId: reasoningId,
428
- model,
429
- timestamp: timestamp()
430
- },
431
- {
432
- type: EventType.REASONING_END,
433
- messageId: reasoningId,
434
- model,
435
- timestamp: timestamp()
436
- }
437
- )
438
- }
439
- const addReasoning = (delta: string) => {
440
- if (reasoning === "none") {
441
- events.push(
442
- {
443
- type: EventType.REASONING_START,
444
- messageId: reasoningId,
445
- model,
446
- timestamp: timestamp()
447
- },
448
- {
449
- type: EventType.REASONING_MESSAGE_START,
450
- messageId: reasoningId,
451
- role: "reasoning",
452
- model,
453
- timestamp: timestamp()
454
- }
455
- )
456
- }
457
- reasoning = "open"
458
- events.push({
459
- type: EventType.REASONING_MESSAGE_CONTENT,
460
- messageId: reasoningId,
461
- delta,
462
- model,
463
- timestamp: timestamp()
464
- })
465
- }
466
- const addText = (delta: string) => {
467
- if (!delta) return
468
- endReasoning()
469
- if (!textStarted) {
470
- textStarted = true
471
- events.push({
472
- type: EventType.TEXT_MESSAGE_START,
473
- messageId,
474
- role: "assistant",
475
- model,
476
- timestamp: timestamp()
477
- })
478
- }
479
- text += delta
480
- events.push({
481
- type: EventType.TEXT_MESSAGE_CONTENT,
482
- messageId,
483
- delta,
484
- content: text,
485
- model,
486
- timestamp: timestamp()
487
- })
488
- }
489
- const endText = () => {
490
- if (!textStarted) return
491
- textStarted = false
492
- events.push({
493
- type: EventType.TEXT_MESSAGE_END,
494
- messageId,
495
- model,
496
- timestamp: timestamp()
497
- })
498
- }
499
-
500
- // When apfel doesn't recognize a tool call, it passes it on as text;
501
- // that part is held back and read once the reply ends.
502
- const splitter = createToolCallSplitter(JSON_TOOL_CALLS_MARKER)
503
- // Tool calls stream in pieces, by index.
504
- const streamed: Array<StreamedCall> = []
505
- // apfel streams a few tokens per chunk: the first chunk's share of the
506
- // characters stands in for its share of the tokens.
507
- const timing = { first: 0, last: 0, firstChars: 0, chars: 0 }
508
- let usage: OpenAIChunk["usage"]
509
- let finishReason: FinishReason = null
510
- try {
511
- for await (const data of sseData(response.body)) {
512
- if (data === "[DONE]") break
513
- const chunk = JSON.parse(data) as OpenAIChunk
514
- logger.provider("provider=apfel", { chunk })
515
- if (chunk.error) {
516
- throw new Error(chunk.error.message ?? "apfel sent an error.")
517
- }
518
- usage = chunk.usage ?? usage
519
- const choice = chunk.choices?.[0]
520
- const delta = choice?.delta
521
- const chars =
522
- (delta?.content?.length ?? 0) +
523
- (delta?.reasoning_content?.length ?? 0) +
524
- (delta?.tool_calls ?? []).reduce(
525
- (n, piece) =>
526
- n +
527
- (piece.function?.name?.length ?? 0) +
528
- (piece.function?.arguments?.length ?? 0),
529
- 0
530
- )
531
- if (chars > 0) {
532
- timing.last = performance.now()
533
- if (!timing.first) {
534
- timing.first = timing.last
535
- 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
- }
542
- timing.chars += chars
543
- }
544
- if (delta?.reasoning_content) addReasoning(delta.reasoning_content)
545
- if (delta?.content) addText(splitter.push(delta.content))
546
- for (const piece of delta?.tool_calls ?? []) {
547
- // apfel numbers its calls call_1, call_2… in every response; the
548
- // adapter gives them ids of its own that are unique in the thread.
549
- const call = (streamed[piece.index] ??= { name: "", arguments: "" })
550
- call.name += piece.function?.name ?? ""
551
- call.arguments += piece.function?.arguments ?? ""
552
- }
553
- if (choice?.finish_reason) finishReason = choice.finish_reason
554
- yield* events.splice(0)
555
- }
556
- } catch (error) {
557
- if (signal?.aborted) return
558
- logger.errors("apfel.chatStream stream failed", {
559
- error,
560
- source: "apfel.chatStream"
561
- })
562
- endReasoning()
563
- endText()
564
- yield* events.splice(0)
565
- yield runError(`apfel's stream failed: ${errorText(error)}`)
566
- return
567
- }
568
-
569
- 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 && {
576
- firstTokenMs: timing.first - sentAt,
577
- ...(outputTokens !== undefined && {
578
- decodeTokens:
579
- (outputTokens * (timing.chars - timing.firstChars)) /
580
- timing.chars,
581
- decodeMs: timing.last - timing.first
582
- })
583
- })
584
- },
585
- { threadId, runId, callId, done: true }
586
- )
587
-
588
- endReasoning()
589
- const { text: rest, markup } = splitter.flush()
590
- const fromText =
591
- markup && streamed.length === 0
592
- ? parseJsonToolCalls(markup, toolNames)
593
- : []
594
- // Text that wasn't a readable tool call is shown after all.
595
- addText(`${rest}${fromText.length > 0 ? "" : (markup ?? "")}`)
596
- yield* events.splice(0)
597
-
598
- // Calls to tools that don't exist are passed on too: `chat()` answers
599
- // them with an "Unknown tool" error, so the model can answer with what
600
- // it has instead of stopping there.
601
- const calls = [
602
- ...streamed.filter((call) => call?.name),
603
- ...fromText.map((call) => ({
604
- name: call.name,
605
- arguments: JSON.stringify(call.args)
606
- }))
607
- ]
608
- for (const [index, call] of calls.entries()) {
609
- const toolCallId = nextId("tool")
610
- const args = call.arguments || "{}"
611
- const input = parseArguments(args)
612
- yield {
613
- type: EventType.TOOL_CALL_START,
614
- toolCallId,
615
- toolCallName: call.name,
616
- parentMessageId: messageId,
617
- index,
618
- model,
619
- timestamp: timestamp()
620
- }
621
- yield {
622
- type: EventType.TOOL_CALL_ARGS,
623
- toolCallId,
624
- delta: args,
625
- args,
626
- model,
627
- timestamp: timestamp()
628
- }
629
- yield {
630
- type: EventType.TOOL_CALL_END,
631
- toolCallId,
632
- toolCallName: call.name,
633
- args,
634
- // Arguments that aren't JSON are left out; `chat()` reports them.
635
- ...(input && { input }),
636
- model,
637
- timestamp: timestamp()
638
- }
639
- }
640
- endText()
641
- yield* events.splice(0)
642
-
643
- const finishUsage = usageOf(usage)
644
- yield {
645
- type: EventType.RUN_FINISHED,
646
- runId,
647
- threadId,
648
- model,
649
- timestamp: timestamp(),
650
- finishReason:
651
- calls.length > 0
652
- ? "tool_calls"
653
- : finishReason === "tool_calls" || finishReason === null
654
- ? "stop"
655
- : finishReason,
656
- ...(finishUsage && { usage: finishUsage })
85
+ const list = (await response.json()) as { data?: Array<{ id?: string }> }
86
+ return response.ok && list.data?.some((m) => m.id === model) ? "available" : "unavailable"
87
+ } catch {
88
+ return "unavailable"
657
89
  }
658
90
  }
659
91
 
660
- /**
661
- * A JSON answer for `chat({ outputSchema })`, from apfel's JSON mode:
662
- * `response_format: json_schema` constrains Apple's model to the schema
663
- * (guided generation). `chat()` validates the result.
664
- */
665
- async structuredOutput(
666
- options: StructuredOutputOptions<ApfelModelOptions>
667
- ): Promise<StructuredOutputResult<unknown>> {
668
- const { chatOptions, outputSchema } = options
669
- const model = this.model
670
- const send = chatOptions.wrapFetch ? chatOptions.wrapFetch(fetch) : fetch
671
- chatOptions.logger.request(
672
- `activity=chat provider=apfel model=${model} messages=${chatOptions.messages.length} tools=0 stream=false`,
673
- { provider: "apfel", model }
674
- )
675
- let response: Response
676
- try {
677
- response = await send(`${this.baseUrl}/chat/completions`, {
678
- method: "POST",
679
- headers: { "Content-Type": "application/json" },
680
- body: JSON.stringify({
681
- ...chatOptions.modelOptions,
682
- model,
683
- messages: [
684
- ...systemMessages(chatOptions.systemPrompts),
685
- ...toOpenAIMessages(chatOptions.messages)
686
- ],
687
- response_format: {
688
- type: "json_schema",
689
- json_schema: { name: "output", schema: outputSchema }
690
- }
691
- }),
692
- signal:
693
- chatOptions.abortController?.signal ??
694
- (chatOptions.request as RequestInit | undefined)?.signal ??
695
- undefined
696
- })
697
- } catch (error) {
698
- chatOptions.logger.errors("apfel.structuredOutput fetch failed", {
699
- error,
700
- source: "apfel.structuredOutput"
701
- })
702
- if (chatOptions.abortController?.signal.aborted) throw error
703
- throw new Error(unreachable(this.baseUrl))
704
- }
705
- if (!response.ok) {
706
- throw new Error(
707
- `apfel returned HTTP ${response.status}: ${await errorBody(response)}`
708
- )
709
- }
710
- const completion = (await response.json()) as OpenAICompletion
711
- const choice = completion.choices?.[0]
712
- if (choice?.finish_reason === "length") {
713
- throw new Error(
714
- "apfel's answer was cut off at the token limit before its JSON ended; raise modelOptions.max_tokens."
715
- )
716
- }
717
- const rawText = (
718
- choice?.message?.content ??
719
- choice?.message?.refusal ??
720
- ""
721
- ).trim()
722
- const json = rawText.replace(/^```(?:json)?\s*|\s*```$/g, "")
723
- const usage = usageOf(completion.usage)
724
- try {
725
- return {
726
- data: JSON.parse(json) as unknown,
727
- rawText,
728
- model,
729
- ...(usage && { usage })
92
+ const apfel: ApfelTextAdapter = Object.assign(adapter, {
93
+ inputModalities: Object.hasOwn(APFEL_MODEL_META, model) ? APFEL_MODEL_META[model as ApfelChatModel].supports.input : undefined,
94
+ availability,
95
+ async create({ signal }: { signal?: AbortSignal } = {}) {
96
+ signal?.throwIfAborted()
97
+ const answer = await availability(signal)
98
+ signal?.throwIfAborted()
99
+ if (answer !== "available") {
100
+ throw new Error(`apfel at ${baseURL} didn't answer, or doesn't serve ${model}. Start it with \`apfel --serve --cors\`.`)
730
101
  }
731
- } catch {
732
- throw new Error(
733
- `The model's answer isn't JSON: ${rawText.slice(0, 200)}${rawText.length > 200 ? "…" : ""}`
734
- )
102
+ return apfel
735
103
  }
736
- }
737
- }
738
-
739
- /**
740
- * A TanStack AI adapter for Apple's on-device Foundation Model through
741
- * apfel (`apfel --serve --cors`).
742
- *
743
- * @example
744
- * ```ts
745
- * chat({ adapter: apfel(), messages, threadId })
746
- * ```
747
- */
748
- export function apfel(options?: ApfelOptions) {
749
- return new ApfelTextAdapter(options)
750
- }
751
-
752
- /**
753
- * Whether apfel is running and serves the model: true if `GET /v1/models`
754
- * lists it within 10 seconds.
755
- */
756
- export async function isApfelAvailable(
757
- options: Pick<ApfelOptions, "baseUrl" | "model"> = {}
758
- ) {
759
- const baseUrl = (options.baseUrl ?? APFEL_BASE_URL).replace(/\/+$/, "")
760
- const model = options.model ?? APFEL_MODEL
761
- try {
762
- const response = await fetch(`${baseUrl}/models`, {
763
- signal: AbortSignal.timeout(10_000)
764
- })
765
- if (!response.ok) return false
766
- const list = (await response.json()) as { data?: Array<{ id?: string }> }
767
- return list.data?.some((m) => m.id === model) ?? false
768
- } catch {
769
- return false
770
- }
104
+ })
105
+ return apfel
771
106
  }