@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/README.md +36 -5
- package/dist/adapter.d.ts +33 -79
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +45 -574
- package/dist/guided-schema.d.ts +20 -0
- package/dist/guided-schema.d.ts.map +1 -0
- package/dist/guided-schema.js +136 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/model-meta.d.ts +23 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +20 -0
- package/dist/tool-repair.d.ts +56 -0
- package/dist/tool-repair.d.ts.map +1 -0
- package/dist/tool-repair.js +252 -0
- package/dist/tools.d.ts +68 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +469 -0
- package/package.json +11 -4
- package/src/adapter.test.ts +81 -0
- package/src/adapter.ts +80 -745
- package/src/availability.test.ts +61 -0
- package/src/index.ts +5 -8
- package/src/model-meta.ts +37 -0
- package/src/tool-calls.ts +0 -134
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
203
|
-
|
|
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
|
-
*
|
|
220
|
-
*
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
.
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
headers
|
|
381
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
}
|