@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.
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict"
2
+ import { afterEach, test } from "node:test"
3
+
4
+ import { apfelText } from "./adapter.js"
5
+
6
+ const baseURL = "http://apfel.test/v1"
7
+ const originalFetch = globalThis.fetch
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch
10
+ })
11
+
12
+ test("available when apfel lists the model at the adapter's baseURL; create resolves to the adapter", async () => {
13
+ const asked: Array<{ url: string; headers: unknown }> = []
14
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
15
+ asked.push({ url, headers: init.headers })
16
+ return Response.json({ data: [{ id: "apple-foundationmodel" }] })
17
+ }) as typeof fetch
18
+ const adapter = apfelText("apple-foundationmodel", { baseURL, defaultHeaders: { Authorization: "Bearer t" } })
19
+ assert.equal(await adapter.availability(), "available")
20
+ assert.deepEqual(asked[0], { url: `${baseURL}/models`, headers: { Authorization: "Bearer t" } })
21
+ assert.equal(await adapter.create(), adapter)
22
+ })
23
+
24
+ test("unavailable when apfel doesn't answer, fails, or doesn't serve the model; create rejects", async () => {
25
+ const adapter = apfelText("apple-foundationmodel", { baseURL })
26
+ globalThis.fetch = (async () => {
27
+ throw new TypeError("Failed to fetch")
28
+ }) as typeof fetch
29
+ assert.equal(await adapter.availability(), "unavailable")
30
+ await assert.rejects(adapter.create(), /didn't answer/)
31
+ globalThis.fetch = (async () => new Response("no", { status: 500 })) as typeof fetch
32
+ assert.equal(await adapter.availability(), "unavailable")
33
+ globalThis.fetch = (async () => Response.json({ data: [{ id: "other" }] })) as typeof fetch
34
+ assert.equal(await adapter.availability(), "unavailable")
35
+ })
36
+
37
+ test("create's signal: aborted before, nothing is asked; aborting during the check stops it with an AbortError", async () => {
38
+ let asked = 0
39
+ let requestSignal: AbortSignal | undefined
40
+ globalThis.fetch = ((_url: string, init: RequestInit) => {
41
+ asked++
42
+ requestSignal = init.signal ?? undefined
43
+ return new Promise<Response>((_resolve, reject) =>
44
+ init.signal?.addEventListener("abort", () => reject(init.signal?.reason))
45
+ )
46
+ }) as typeof fetch
47
+ const adapter = apfelText("apple-foundationmodel", { baseURL })
48
+ await assert.rejects(adapter.create({ signal: AbortSignal.abort() }), { name: "AbortError" })
49
+ assert.equal(asked, 0)
50
+ const controller = new AbortController()
51
+ const checking = adapter.create({ signal: controller.signal })
52
+ controller.abort()
53
+ await assert.rejects(checking, { name: "AbortError" })
54
+ assert.equal(requestSignal?.aborted, true, "the request was stopped")
55
+ })
56
+
57
+ test("the model is optional: Apple's by default, with or without a config; any other name apfel serves", () => {
58
+ assert.equal(apfelText().model, "apple-foundationmodel")
59
+ assert.equal(apfelText({ baseURL: "http://localhost:1/v1" }).model, "apple-foundationmodel")
60
+ assert.equal(apfelText("some-other-model").model, "some-other-model")
61
+ })
package/src/index.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * TanStack AI adapter for Apple's on-device Foundation Model, served by apfel.
3
+ * The model catalog (model-meta.ts) is exported here too.
3
4
  */
4
- export {
5
- APFEL_BASE_URL,
6
- APFEL_MODEL,
7
- ApfelTextAdapter,
8
- apfel,
9
- isApfelAvailable
10
- } from "./adapter.js"
11
- export type { ApfelModelOptions, ApfelOptions, ApfelStats } from "./adapter.js"
5
+ export { apfelText } from "./adapter.js"
6
+ export type { ApfelConfig, ApfelModel, ApfelTextAdapter, ApfelTextModelOptions, Availability } from "./adapter.js"
7
+ export { APFEL_CHAT_MODELS, APFEL_MODEL_META } from "./model-meta.js"
8
+ export type { ApfelChatModel, ApfelModelMeta } from "./model-meta.js"
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The models apfel serves: Apple's on-device Foundation Model, with what it
3
+ * supports. It doesn't reason, so `chat()` offers no `reasoning` option for it.
4
+ * It reads images on macOS 27 and later; on older macOS apfel gives it only
5
+ * text read from the image.
6
+ */
7
+
8
+ /** What a model apfel serves supports. */
9
+ export type ApfelModelMeta = {
10
+ /** The model's id, as apfel lists it. */
11
+ name: string
12
+ supports: {
13
+ input: ReadonlyArray<"text" | "image">
14
+ output: ReadonlyArray<"text">
15
+ capabilities: ReadonlyArray<"tools">
16
+ }
17
+ }
18
+
19
+ const APPLE_FOUNDATION_MODEL = {
20
+ name: "apple-foundationmodel",
21
+ supports: {
22
+ input: ["text", "image"],
23
+ output: ["text"],
24
+ capabilities: ["tools"]
25
+ }
26
+ } as const satisfies ApfelModelMeta
27
+
28
+ /** The models apfel serves: Apple's on-device Foundation Model. */
29
+ export const APFEL_CHAT_MODELS = [APPLE_FOUNDATION_MODEL.name] as const
30
+
31
+ /** A model apfel serves. */
32
+ export type ApfelChatModel = (typeof APFEL_CHAT_MODELS)[number]
33
+
34
+ /** Each model's metadata, by name. */
35
+ export const APFEL_MODEL_META: Readonly<Record<ApfelChatModel, ApfelModelMeta>> = {
36
+ [APPLE_FOUNDATION_MODEL.name]: APPLE_FOUNDATION_MODEL
37
+ }
package/src/tool-calls.ts DELETED
@@ -1,134 +0,0 @@
1
- /**
2
- * Tool calls Apple's model writes as text. apfel turns the model's tool
3
- * calls into OpenAI `tool_calls`, but when it doesn't recognize one it
4
- * passes it on as text instead, like
5
- * `{"tool_calls": [{"function": "get_weather", "arguments": {…}}]}`.
6
- */
7
-
8
- export type ParsedToolCall = { name: string; args: Record<string, unknown> }
9
-
10
- /** Where Apple's model starts a tool call it writes as text. */
11
- export const JSON_TOOL_CALLS_MARKER = '{"tool_calls"'
12
-
13
- /** The `{…}` that starts at `text[start]`, braces in strings ignored. */
14
- function balancedObject(text: string, start: number) {
15
- let depth = 0
16
- let inString = false
17
- for (let i = start; i < text.length; i++) {
18
- const c = text[i]
19
- if (inString) {
20
- if (c === "\\") i++
21
- else if (c === '"') inString = false
22
- } else if (c === '"') inString = true
23
- else if (c === "{") depth++
24
- else if (c === "}" && --depth === 0) return text.slice(start, i + 1)
25
- }
26
- return null
27
- }
28
-
29
- /** JSON arguments, read leniently: stray strings without a key are dropped. */
30
- function parseJsonArgs(raw: string): Record<string, unknown> | null {
31
- for (const candidate of [raw, raw.replace(/,\s*"[^"]*"\s*(?=[,}])/g, "")]) {
32
- try {
33
- const value = JSON.parse(candidate) as unknown
34
- const args = typeof value === "string" ? JSON.parse(value) : value
35
- if (args && typeof args === "object") {
36
- return args as Record<string, unknown>
37
- }
38
- } catch {
39
- // Try the next reading.
40
- }
41
- }
42
- return null
43
- }
44
-
45
- /**
46
- * Tool calls Apple's model wrote as JSON text. Its JSON varies and is often
47
- * broken (`"function": "name"`, `"name: "name"`, stray strings in the
48
- * arguments), so instead of parsing the whole thing, this finds each quoted
49
- * name of a tool that exists and reads the `"arguments"` object after it.
50
- */
51
- export function parseJsonToolCalls(
52
- text: string,
53
- toolNames: Iterable<string>
54
- ): Array<ParsedToolCall> {
55
- const mentions: Array<{ name: string; at: number }> = []
56
- for (const name of toolNames) {
57
- const quoted = `"${name}"`
58
- for (
59
- let at = text.indexOf(quoted);
60
- at >= 0;
61
- at = text.indexOf(quoted, at + 1)
62
- ) {
63
- mentions.push({ name, at })
64
- }
65
- }
66
- mentions.sort((a, b) => a.at - b.at)
67
- const calls: Array<ParsedToolCall> = []
68
- mentions.forEach(({ name, at }, i) => {
69
- // The arguments belong to the last mention of a name before them. The
70
- // model sometimes writes a broken "arguments" and then a good one: the
71
- // first that reads is used.
72
- const part = text.slice(at, mentions[i + 1]?.at ?? text.length)
73
- for (const key of part.matchAll(/"arguments"?\s*:\s*/g)) {
74
- const from = key.index + key[0].length
75
- const raw =
76
- part[from] === "{"
77
- ? balancedObject(part, from)
78
- : /^"(?:[^"\\]|\\.)*"/.exec(part.slice(from))?.[0]
79
- const args = raw ? parseJsonArgs(raw) : null
80
- if (args) {
81
- calls.push({ name, args })
82
- return
83
- }
84
- }
85
- })
86
- return calls
87
- }
88
-
89
- /** Length of the longest end of `text` that could start `marker`. */
90
- function partialMarker(text: string, marker: string) {
91
- for (let n = Math.min(text.length, marker.length); n > 0; n--) {
92
- if (marker.startsWith(text.slice(-n))) return n
93
- }
94
- return 0
95
- }
96
-
97
- /**
98
- * Splits a streaming answer into the text before the first tool call written
99
- * as text (passed through as it arrives) and the tool-call JSON after it
100
- * (held back and parsed once the reply ends).
101
- */
102
- export function createToolCallSplitter(marker = JSON_TOOL_CALLS_MARKER) {
103
- let held = ""
104
- let markup: string | null = null
105
-
106
- /** Text that can be shown now. */
107
- function push(text: string) {
108
- if (markup !== null) {
109
- markup += text
110
- return ""
111
- }
112
- held += text
113
- const at = held.indexOf(marker)
114
- if (at >= 0) {
115
- const before = held.slice(0, at)
116
- markup = held.slice(at)
117
- held = ""
118
- return before
119
- }
120
- const keep = partialMarker(held, marker)
121
- const out = held.slice(0, held.length - keep)
122
- held = held.slice(out.length)
123
- return out
124
- }
125
-
126
- /** The rest of the text, and the tool-call JSON if any. */
127
- function flush() {
128
- const rest = held
129
- held = ""
130
- return { text: rest, markup }
131
- }
132
-
133
- return { push, flush }
134
- }