@tanstack-ai-browser/apfel 0.1.0 → 0.2.1
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 +53 -654
- 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 +11 -4
- package/src/adapter.test.ts +81 -0
- package/src/adapter.ts +89 -837
- 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
|
@@ -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,16 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
apfelText,
|
|
9
|
-
isApfelAvailable
|
|
10
|
-
} from "./adapter.js"
|
|
11
|
-
export type {
|
|
12
|
-
ApfelModelOptions,
|
|
13
|
-
ApfelProviderUsageDetails,
|
|
14
|
-
ApfelTextConfig,
|
|
15
|
-
ApfelTextModel
|
|
16
|
-
} 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
|
-
}
|
package/src/tool-repair.ts
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool-call repair. Apple's model picks the right tool but often gets its
|
|
3
|
-
* arguments wrong: it renames fields (`limit` for `numResults`, `site` for
|
|
4
|
-
* `includeDomains`) and drops optional ones it was asked to set. So after the
|
|
5
|
-
* model calls a tool, its arguments are written again with apfel's guided
|
|
6
|
-
* generation, which holds the model to the tool's JSON Schema:
|
|
7
|
-
*
|
|
8
|
-
* 1. For each optional field, a yes/no answer: does the request give it a
|
|
9
|
-
* value? (Asked for the arguments directly, the model leaves out optional
|
|
10
|
-
* numbers and arrays even when the request states them.)
|
|
11
|
-
* 2. The arguments, with the fields from step 1 required and the rest left
|
|
12
|
-
* out. Fields the model already got right (schema name, right type) are
|
|
13
|
-
* kept as it wrote them.
|
|
14
|
-
*
|
|
15
|
-
* Works from the schema alone, for any tool, MCP tools included. Through
|
|
16
|
-
* `chat()` with search, calendar, file and weather tools, the calls came out
|
|
17
|
-
* right 27 times in 32 with this, against 18 without; the misses left are
|
|
18
|
-
* the model not calling a tool at all, which this can't fix.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
type JsonSchema = Record<string, unknown>
|
|
22
|
-
|
|
23
|
-
/** Keywords guided generation can enforce; apfel rejects others with 400. */
|
|
24
|
-
const KEPT = ["type", "description", "enum", "const"] as const
|
|
25
|
-
|
|
26
|
-
const isObject = (value: unknown): value is JsonSchema =>
|
|
27
|
-
!!value && typeof value === "object" && !Array.isArray(value)
|
|
28
|
-
|
|
29
|
-
/** The schema a local `$ref` (`#/$defs/X`, `#/definitions/X`) points to. */
|
|
30
|
-
function resolveRef(ref: string, root: JsonSchema): JsonSchema | undefined {
|
|
31
|
-
if (!ref.startsWith("#/")) return undefined
|
|
32
|
-
let node: unknown = root
|
|
33
|
-
for (const key of ref.slice(2).split("/")) {
|
|
34
|
-
node = isObject(node)
|
|
35
|
-
? node[key.replace(/~1/g, "/").replace(/~0/g, "~")]
|
|
36
|
-
: undefined
|
|
37
|
-
}
|
|
38
|
-
return isObject(node) ? node : undefined
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The schema in the subset guided generation enforces: types, properties,
|
|
43
|
-
* required, items, enum, const and descriptions. Local `$ref`s are inlined,
|
|
44
|
-
* `anyOf`/`oneOf`/type unions become their first non-null choice, and
|
|
45
|
-
* constraints it can't enforce (lengths, patterns, formats…) are dropped.
|
|
46
|
-
*/
|
|
47
|
-
export function toGuidedSchema(
|
|
48
|
-
schema: unknown,
|
|
49
|
-
root: JsonSchema = isObject(schema) ? schema : {},
|
|
50
|
-
depth = 0
|
|
51
|
-
): JsonSchema {
|
|
52
|
-
if (!isObject(schema) || depth > 12) return { type: "string" }
|
|
53
|
-
if (typeof schema.$ref === "string") {
|
|
54
|
-
const target = resolveRef(schema.$ref, root)
|
|
55
|
-
return target
|
|
56
|
-
? toGuidedSchema(
|
|
57
|
-
{ ...target, ...withoutKey(schema, "$ref") },
|
|
58
|
-
root,
|
|
59
|
-
depth + 1
|
|
60
|
-
)
|
|
61
|
-
: { type: "string" }
|
|
62
|
-
}
|
|
63
|
-
const choices = (schema.anyOf ?? schema.oneOf) as unknown
|
|
64
|
-
if (Array.isArray(choices)) {
|
|
65
|
-
const choice =
|
|
66
|
-
choices.find((c) => isObject(c) && c.type !== "null") ?? choices[0]
|
|
67
|
-
return toGuidedSchema(
|
|
68
|
-
{
|
|
69
|
-
...(isObject(choice) ? choice : {}),
|
|
70
|
-
...(schema.description !== undefined && {
|
|
71
|
-
description: schema.description
|
|
72
|
-
})
|
|
73
|
-
},
|
|
74
|
-
root,
|
|
75
|
-
depth + 1
|
|
76
|
-
)
|
|
77
|
-
}
|
|
78
|
-
if (Array.isArray(schema.allOf)) {
|
|
79
|
-
const merged = schema.allOf.reduce<JsonSchema>(
|
|
80
|
-
(all, part) => (isObject(part) ? mergeSchemas(all, part) : all),
|
|
81
|
-
withoutKey(schema, "allOf")
|
|
82
|
-
)
|
|
83
|
-
return toGuidedSchema(merged, root, depth + 1)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const out: JsonSchema = {}
|
|
87
|
-
for (const key of KEPT) {
|
|
88
|
-
if (schema[key] !== undefined) out[key] = schema[key]
|
|
89
|
-
}
|
|
90
|
-
if (Array.isArray(out.type)) {
|
|
91
|
-
out.type = out.type.find((t) => t !== "null") ?? "string"
|
|
92
|
-
}
|
|
93
|
-
if (out.type === undefined) {
|
|
94
|
-
if (isObject(schema.properties)) out.type = "object"
|
|
95
|
-
else if (schema.items !== undefined) out.type = "array"
|
|
96
|
-
else if (Array.isArray(out.enum)) out.type = typeof out.enum[0]
|
|
97
|
-
else if (out.const === undefined) out.type = "string"
|
|
98
|
-
}
|
|
99
|
-
if (out.type === "object") {
|
|
100
|
-
const properties = isObject(schema.properties) ? schema.properties : {}
|
|
101
|
-
out.properties = Object.fromEntries(
|
|
102
|
-
Object.entries(properties).map(([key, value]) => [
|
|
103
|
-
key,
|
|
104
|
-
toGuidedSchema(value, root, depth + 1)
|
|
105
|
-
])
|
|
106
|
-
)
|
|
107
|
-
out.required = Array.isArray(schema.required)
|
|
108
|
-
? schema.required.filter(
|
|
109
|
-
(key): key is string => typeof key === "string" && key in properties
|
|
110
|
-
)
|
|
111
|
-
: []
|
|
112
|
-
}
|
|
113
|
-
if (out.type === "array") {
|
|
114
|
-
out.items = toGuidedSchema(schema.items ?? {}, root, depth + 1)
|
|
115
|
-
}
|
|
116
|
-
return out
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function withoutKey(schema: JsonSchema, key: string) {
|
|
120
|
-
const rest = { ...schema }
|
|
121
|
-
delete rest[key]
|
|
122
|
-
return rest
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function mergeSchemas(a: JsonSchema, b: JsonSchema): JsonSchema {
|
|
126
|
-
const merged = { ...a, ...b }
|
|
127
|
-
if (isObject(a.properties) || isObject(b.properties)) {
|
|
128
|
-
merged.properties = {
|
|
129
|
-
...(isObject(a.properties) ? a.properties : {}),
|
|
130
|
-
...(isObject(b.properties) ? b.properties : {})
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (Array.isArray(a.required) || Array.isArray(b.required)) {
|
|
134
|
-
merged.required = [
|
|
135
|
-
...new Set([
|
|
136
|
-
...(Array.isArray(a.required) ? a.required : []),
|
|
137
|
-
...(Array.isArray(b.required) ? b.required : [])
|
|
138
|
-
])
|
|
139
|
-
]
|
|
140
|
-
}
|
|
141
|
-
return merged
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Whether `value` fits a guided schema's type and choices. */
|
|
145
|
-
function fits(value: unknown, schema: JsonSchema): boolean {
|
|
146
|
-
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false
|
|
147
|
-
if (schema.const !== undefined) return value === schema.const
|
|
148
|
-
switch (schema.type) {
|
|
149
|
-
case "string":
|
|
150
|
-
return typeof value === "string"
|
|
151
|
-
case "integer":
|
|
152
|
-
return Number.isInteger(value)
|
|
153
|
-
case "number":
|
|
154
|
-
return typeof value === "number" && Number.isFinite(value)
|
|
155
|
-
case "boolean":
|
|
156
|
-
return typeof value === "boolean"
|
|
157
|
-
case "array":
|
|
158
|
-
return (
|
|
159
|
-
Array.isArray(value) &&
|
|
160
|
-
value.every((item) => fits(item, (schema.items ?? {}) as JsonSchema))
|
|
161
|
-
)
|
|
162
|
-
case "object":
|
|
163
|
-
return isObject(value)
|
|
164
|
-
default:
|
|
165
|
-
return true
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* The fields of the model's own arguments that already have a schema's
|
|
171
|
-
* name and a value of the right type. They're kept as they are: with several
|
|
172
|
-
* calls to one tool in a reply, they're what tells the calls apart.
|
|
173
|
-
*/
|
|
174
|
-
function validDraftFields(
|
|
175
|
-
draft: string | undefined,
|
|
176
|
-
properties: Record<string, JsonSchema>
|
|
177
|
-
) {
|
|
178
|
-
let parsed: unknown
|
|
179
|
-
try {
|
|
180
|
-
parsed = draft ? JSON.parse(draft) : undefined
|
|
181
|
-
} catch {
|
|
182
|
-
return {}
|
|
183
|
-
}
|
|
184
|
-
if (!isObject(parsed)) return {}
|
|
185
|
-
return Object.fromEntries(
|
|
186
|
-
Object.entries(parsed).filter(
|
|
187
|
-
([key, value]) =>
|
|
188
|
-
Object.hasOwn(properties, key) &&
|
|
189
|
-
value !== null &&
|
|
190
|
-
fits(value, properties[key] as JsonSchema)
|
|
191
|
-
)
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** One line per field: name, type or choices, and description. */
|
|
196
|
-
function describeFields(properties: Record<string, JsonSchema>) {
|
|
197
|
-
return Object.entries(properties)
|
|
198
|
-
.map(([key, field]) => {
|
|
199
|
-
const kind = Array.isArray(field.enum)
|
|
200
|
-
? field.enum.map((v) => JSON.stringify(v)).join(" | ")
|
|
201
|
-
: String(field.type ?? "any")
|
|
202
|
-
const about =
|
|
203
|
-
typeof field.description === "string" ? `: ${field.description}` : ""
|
|
204
|
-
return `- ${key} (${kind})${about}`
|
|
205
|
-
})
|
|
206
|
-
.join("\n")
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* The name of the tool the model meant, when it got the name slightly wrong
|
|
211
|
-
* (case, `-` for `_`…). Undefined if no tool matches.
|
|
212
|
-
*/
|
|
213
|
-
export function matchToolName(name: string, toolNames: Iterable<string>) {
|
|
214
|
-
const names = [...toolNames]
|
|
215
|
-
if (names.includes(name)) return name
|
|
216
|
-
const plain = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
217
|
-
const matches = names.filter((n) => plain(n) === plain(name))
|
|
218
|
-
return matches.length === 1 ? matches[0] : undefined
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** A chat completion request apfel answers with JSON matching `schema`. */
|
|
222
|
-
export type GuidedRequest = {
|
|
223
|
-
/** Instructions, sent before the conversation. */
|
|
224
|
-
instructions: string
|
|
225
|
-
schema: JsonSchema
|
|
226
|
-
name: string
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Sends a guided request; resolves to the model's JSON, parsed. */
|
|
230
|
-
export type GuidedComplete = (request: GuidedRequest) => Promise<unknown>
|
|
231
|
-
|
|
232
|
-
export type RepairableTool = {
|
|
233
|
-
name: string
|
|
234
|
-
description: string
|
|
235
|
-
inputSchema?: unknown
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* The tool call's arguments, written again under the tool's schema.
|
|
240
|
-
* Undefined if the tool takes no arguments or its schema isn't an object
|
|
241
|
-
* (the model's own arguments are used then).
|
|
242
|
-
*/
|
|
243
|
-
export async function repairToolArguments(
|
|
244
|
-
tool: RepairableTool,
|
|
245
|
-
complete: GuidedComplete,
|
|
246
|
-
/** The model's own arguments, which may use the wrong field names. */
|
|
247
|
-
draft?: string
|
|
248
|
-
): Promise<Record<string, unknown> | undefined> {
|
|
249
|
-
const schema = toGuidedSchema(tool.inputSchema)
|
|
250
|
-
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
|
|
251
|
-
if (schema.type !== "object" || Object.keys(properties).length === 0) {
|
|
252
|
-
return undefined
|
|
253
|
-
}
|
|
254
|
-
const required = schema.required as Array<string>
|
|
255
|
-
const optional = Object.keys(properties).filter(
|
|
256
|
-
(key) => !required.includes(key)
|
|
257
|
-
)
|
|
258
|
-
const about = `the tool ${tool.name}${tool.description ? ` (${tool.description})` : ""}`
|
|
259
|
-
// With several calls in one reply, the draft says which one this is.
|
|
260
|
-
const attempt =
|
|
261
|
-
draft && draft !== "{}"
|
|
262
|
-
? `\nThe assistant's first attempt at this call, which may use wrong field names: ${draft.slice(0, 1000)}`
|
|
263
|
-
: ""
|
|
264
|
-
|
|
265
|
-
let chosen: Array<string> = []
|
|
266
|
-
if (optional.length > 0) {
|
|
267
|
-
const answer = await complete({
|
|
268
|
-
name: "fields",
|
|
269
|
-
instructions: `You are deciding the arguments of a call to ${about}, for the user's latest request. Its fields:\n${describeFields(properties)}\nFor each optional field, answer true only if the conversation states a value for it, otherwise false.`,
|
|
270
|
-
schema: {
|
|
271
|
-
type: "object",
|
|
272
|
-
properties: Object.fromEntries(
|
|
273
|
-
optional.map((key) => [
|
|
274
|
-
key,
|
|
275
|
-
{
|
|
276
|
-
type: "boolean",
|
|
277
|
-
description: `Does the request state a value for ${key}?`
|
|
278
|
-
}
|
|
279
|
-
])
|
|
280
|
-
),
|
|
281
|
-
required: optional
|
|
282
|
-
}
|
|
283
|
-
})
|
|
284
|
-
if (isObject(answer))
|
|
285
|
-
chosen = optional.filter((key) => answer[key] === true)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const kept = validDraftFields(draft, properties)
|
|
289
|
-
const fields = [
|
|
290
|
-
...new Set([
|
|
291
|
-
...required,
|
|
292
|
-
...chosen,
|
|
293
|
-
...optional.filter((key) => key in kept)
|
|
294
|
-
])
|
|
295
|
-
]
|
|
296
|
-
const args = await complete({
|
|
297
|
-
name: tool.name,
|
|
298
|
-
instructions: `Write the arguments for a call to ${about} that does what the user asks in their latest request.${attempt}`,
|
|
299
|
-
schema: {
|
|
300
|
-
type: "object",
|
|
301
|
-
properties: Object.fromEntries(
|
|
302
|
-
fields.map((key) => [key, properties[key]])
|
|
303
|
-
),
|
|
304
|
-
required: fields
|
|
305
|
-
}
|
|
306
|
-
})
|
|
307
|
-
if (!isObject(args)) return undefined
|
|
308
|
-
// Optional fields left empty weren't really set.
|
|
309
|
-
const written = Object.fromEntries(
|
|
310
|
-
Object.entries(args).filter(
|
|
311
|
-
([key, value]) =>
|
|
312
|
-
required.includes(key) ||
|
|
313
|
-
!(
|
|
314
|
-
value === "" ||
|
|
315
|
-
value === null ||
|
|
316
|
-
(Array.isArray(value) && value.length === 0)
|
|
317
|
-
)
|
|
318
|
-
)
|
|
319
|
-
)
|
|
320
|
-
return { ...written, ...kept }
|
|
321
|
-
}
|