@tanstack-ai-browser/apfel 0.1.0 → 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 +29 -28
- package/dist/adapter.d.ts +30 -83
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +45 -655
- 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/tools.d.ts +68 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +469 -0
- package/package.json +10 -3
- package/src/adapter.test.ts +81 -0
- package/src/adapter.ts +77 -839
- package/src/availability.test.ts +61 -0
- package/src/index.ts +5 -13
- package/src/model-meta.ts +37 -0
- package/src/tool-calls.ts +0 -134
- package/src/tool-repair.ts +0 -321
package/src/adapter.ts
CHANGED
|
@@ -1,868 +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
|
-
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"
|
|
9
|
+
import { openaiCompatibleText } from "@tanstack/ai-openai/compatible"
|
|
10
|
+
import type { OpenAICompatibleChatAdapter } from "@tanstack/ai-openai/compatible"
|
|
22
11
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
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"
|
|
12
|
+
import { APFEL_MODEL_META } from "./model-meta.js"
|
|
13
|
+
import type { ApfelChatModel } from "./model-meta.js"
|
|
30
14
|
|
|
31
|
-
|
|
32
|
-
|
|
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. */
|
|
15
|
+
export type ApfelConfig = {
|
|
16
|
+
/** apfel's OpenAI-compatible API, up to `/v1`. `http://127.0.0.1:11434/v1` by default. */
|
|
42
17
|
baseURL?: string
|
|
43
18
|
/** Headers sent with every request, such as apfel's bearer token. */
|
|
44
19
|
defaultHeaders?: Record<string, string>
|
|
45
20
|
}
|
|
46
21
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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. */
|
|
76
25
|
max_tokens?: number
|
|
26
|
+
temperature?: number
|
|
27
|
+
top_p?: number
|
|
77
28
|
seed?: number
|
|
78
29
|
}
|
|
79
30
|
|
|
80
|
-
|
|
31
|
+
/** The Prompt API's availability values; apfel's model is "available" or "unavailable". */
|
|
32
|
+
export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
|
|
81
33
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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>
|
|
88
42
|
}
|
|
89
43
|
|
|
90
|
-
|
|
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 }
|
|
44
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"
|
|
104
45
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
}
|
|
46
|
+
/** A model apfel serves: Apple's ("apple-foundationmodel", the default), or any other name. */
|
|
47
|
+
export type ApfelModel = ApfelChatModel | (string & {})
|
|
143
48
|
|
|
144
49
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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
|
+
* ```
|
|
222
59
|
*/
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
.
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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> => {
|
|
523
79
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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
|
|
724
84
|
})
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
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"
|
|
746
89
|
}
|
|
747
90
|
}
|
|
748
91
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 })
|
|
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\`.`)
|
|
819
101
|
}
|
|
820
|
-
|
|
821
|
-
throw new Error(
|
|
822
|
-
`The model's answer isn't JSON: ${rawText.slice(0, 200)}${rawText.length > 200 ? "…" : ""}`
|
|
823
|
-
)
|
|
102
|
+
return apfel
|
|
824
103
|
}
|
|
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
|
-
}
|
|
104
|
+
})
|
|
105
|
+
return apfel
|
|
868
106
|
}
|