@tanstack-ai-browser/prompt-api 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +96 -2
- package/dist/adapter.d.ts +160 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +413 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/limit.d.ts +119 -0
- package/dist/limit.d.ts.map +1 -0
- package/dist/limit.js +203 -0
- package/dist/messages.d.ts +20 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +95 -0
- package/dist/model-meta.d.ts +21 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +18 -0
- package/dist/model.d.ts +28 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +53 -0
- package/dist/reasoning.d.ts +42 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +113 -0
- package/dist/sessions.d.ts +48 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +275 -0
- package/dist/shared-load.d.ts +48 -0
- package/dist/shared-load.d.ts.map +1 -0
- package/dist/shared-load.js +103 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +17 -0
- package/dist/tools.d.ts +50 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +314 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/package.json +59 -3
- package/src/adapter.test.ts +571 -0
- package/src/adapter.ts +521 -0
- package/src/index.ts +13 -0
- package/src/messages.ts +102 -0
- package/src/model-meta.ts +35 -0
- package/src/model.ts +75 -0
- package/src/tools.test.ts +129 -0
- package/src/tools.ts +311 -0
- package/src/types.ts +110 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { beforeEach, test } from "node:test"
|
|
3
|
+
|
|
4
|
+
import { chat } from "@tanstack/ai"
|
|
5
|
+
import type { TextOptions } from "@tanstack/ai"
|
|
6
|
+
|
|
7
|
+
import { promptApiText } from "./adapter.js"
|
|
8
|
+
import type { PromptApiConfig } from "./adapter.js"
|
|
9
|
+
|
|
10
|
+
type Chunk = { type: string; [key: string]: unknown }
|
|
11
|
+
type Prompt = { kind: "prompt" | "stream"; input: unknown; options: Record<string, unknown>; session: EventTarget }
|
|
12
|
+
|
|
13
|
+
const noop = () => undefined
|
|
14
|
+
const logger = { request: noop, provider: noop, errors: noop, warn: noop } as unknown as TextOptions["logger"]
|
|
15
|
+
|
|
16
|
+
/** What the fake browser does, per test. */
|
|
17
|
+
const control = {
|
|
18
|
+
creates: [] as Array<Record<string, unknown>>,
|
|
19
|
+
prompts: [] as Array<Prompt>,
|
|
20
|
+
/** The streamed reply, in these pieces. */
|
|
21
|
+
pieces: ["Hello ", "there"] as Array<string>,
|
|
22
|
+
/** prompt()'s reply. */
|
|
23
|
+
answer: '{"city":"Tokyo"}',
|
|
24
|
+
/** Thrown by create(), or by the prompt. */
|
|
25
|
+
createFailure: undefined as unknown,
|
|
26
|
+
promptFailure: undefined as unknown,
|
|
27
|
+
/** Called before each streamed piece. */
|
|
28
|
+
beforePiece: noop as (index: number) => void,
|
|
29
|
+
/** Pieces written before the stream stopped. */
|
|
30
|
+
written: 0,
|
|
31
|
+
destroyed: 0,
|
|
32
|
+
/** Download progress the browser reports on create. */
|
|
33
|
+
progress: [0, 1] as Array<number>,
|
|
34
|
+
/** Sessions made, in order. */
|
|
35
|
+
sessions: [] as Array<EventTarget>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class FakeSession extends EventTarget {
|
|
39
|
+
constructor() {
|
|
40
|
+
super()
|
|
41
|
+
control.sessions.push(this)
|
|
42
|
+
}
|
|
43
|
+
contextUsage = 120
|
|
44
|
+
readonly contextWindow = 9216
|
|
45
|
+
destroy() {
|
|
46
|
+
control.destroyed++
|
|
47
|
+
}
|
|
48
|
+
async measureContextUsage(input: unknown) {
|
|
49
|
+
return JSON.stringify(input).length
|
|
50
|
+
}
|
|
51
|
+
async prompt(input: unknown, options: Record<string, unknown> = {}) {
|
|
52
|
+
control.prompts.push({ kind: "prompt", input, options, session: this })
|
|
53
|
+
;(options.signal as AbortSignal | undefined)?.throwIfAborted()
|
|
54
|
+
if (control.promptFailure) throw control.promptFailure
|
|
55
|
+
return control.answer
|
|
56
|
+
}
|
|
57
|
+
promptStreaming(input: unknown, options: Record<string, unknown> = {}) {
|
|
58
|
+
control.prompts.push({ kind: "stream", input, options, session: this })
|
|
59
|
+
const signal = options.signal as AbortSignal | undefined
|
|
60
|
+
let index = 0
|
|
61
|
+
return new ReadableStream<string>({
|
|
62
|
+
pull(controller) {
|
|
63
|
+
if (control.promptFailure) return controller.error(control.promptFailure)
|
|
64
|
+
control.beforePiece(index)
|
|
65
|
+
if (signal?.aborted) return controller.error(signal.reason)
|
|
66
|
+
if (index >= control.pieces.length) return controller.close()
|
|
67
|
+
control.written++
|
|
68
|
+
controller.enqueue(control.pieces[index++])
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
;(globalThis as { LanguageModel?: unknown }).LanguageModel = {
|
|
75
|
+
availability: async () => "available",
|
|
76
|
+
async create(options: Record<string, unknown>) {
|
|
77
|
+
control.creates.push(options)
|
|
78
|
+
if (control.createFailure) throw control.createFailure
|
|
79
|
+
const monitor = new EventTarget()
|
|
80
|
+
;(options.monitor as ((m: EventTarget) => void) | undefined)?.(monitor)
|
|
81
|
+
for (const loaded of control.progress) monitor.dispatchEvent(Object.assign(new Event("downloadprogress"), { loaded }))
|
|
82
|
+
return new FakeSession()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
Object.assign(control, {
|
|
88
|
+
creates: [],
|
|
89
|
+
prompts: [],
|
|
90
|
+
pieces: ["Hello ", "there"],
|
|
91
|
+
answer: '{"city":"Tokyo"}',
|
|
92
|
+
createFailure: undefined,
|
|
93
|
+
promptFailure: undefined,
|
|
94
|
+
beforePiece: noop,
|
|
95
|
+
written: 0,
|
|
96
|
+
destroyed: 0,
|
|
97
|
+
progress: [0, 1],
|
|
98
|
+
sessions: []
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const chatOptions = (extra: Record<string, unknown> = {}) =>
|
|
103
|
+
({
|
|
104
|
+
model: "text",
|
|
105
|
+
logger,
|
|
106
|
+
threadId: "thread",
|
|
107
|
+
messages: [{ role: "user", content: "Weather in Paris?" }],
|
|
108
|
+
...extra
|
|
109
|
+
}) as unknown as TextOptions
|
|
110
|
+
|
|
111
|
+
async function run(extra: Record<string, unknown> = {}, config: PromptApiConfig = {}) {
|
|
112
|
+
const chunks: Array<Chunk> = []
|
|
113
|
+
for await (const chunk of promptApiText("text", config).chatStream(chatOptions(extra))) chunks.push(chunk as Chunk)
|
|
114
|
+
return chunks
|
|
115
|
+
}
|
|
116
|
+
const typesOf = (chunks: Array<Chunk>) => chunks.map((c) => c.type)
|
|
117
|
+
const textIn = (chunks: Array<Chunk>) =>
|
|
118
|
+
chunks.filter((c) => c.type === "TEXT_MESSAGE_CONTENT").map((c) => c.delta).join("")
|
|
119
|
+
const weather = { name: "get_weather", description: "Weather for a city", inputSchema: { type: "object", properties: { city: { type: "string" } } } }
|
|
120
|
+
|
|
121
|
+
test("a reply streams as text, on a session the thread keeps", async () => {
|
|
122
|
+
const chunks = await run({ systemPrompts: ["Be brief."], modelOptions: { topK: 3 } })
|
|
123
|
+
assert.deepEqual(typesOf(chunks), [
|
|
124
|
+
"RUN_STARTED",
|
|
125
|
+
"TEXT_MESSAGE_START",
|
|
126
|
+
"TEXT_MESSAGE_CONTENT",
|
|
127
|
+
"TEXT_MESSAGE_CONTENT",
|
|
128
|
+
"TEXT_MESSAGE_END",
|
|
129
|
+
"RUN_FINISHED"
|
|
130
|
+
])
|
|
131
|
+
assert.equal(textIn(chunks), "Hello there")
|
|
132
|
+
const finished = chunks.at(-1)
|
|
133
|
+
assert.equal(finished?.finishReason, "stop")
|
|
134
|
+
const [create] = control.creates
|
|
135
|
+
assert.deepEqual(create.initialPrompts, [{ role: "system", content: "Be brief." }])
|
|
136
|
+
assert.equal(create.topK, 3)
|
|
137
|
+
assert.equal("tools" in create, false)
|
|
138
|
+
assert.deepEqual(control.prompts[0].input, [{ role: "user", content: "Weather in Paris?" }])
|
|
139
|
+
assert.equal(control.destroyed, 0, "kept for the thread's next step")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("tools go in the system prompt; a fenced call ends the step with TOOL_CALL events and tool_calls", async () => {
|
|
143
|
+
control.pieces = ["Checking. ", "```tool_call\n", '{"name": "get_weather", ', '"arguments": {"city": "Paris"}}\n', "```", "never read", "never read"]
|
|
144
|
+
const chunks = await run({ tools: [weather], systemPrompts: ["Be brief."] })
|
|
145
|
+
assert.match(String((control.creates[0].initialPrompts as Array<{ content: string }>)[0].content), /^Be brief\.\n\nYou are a helpful AI assistant with access to tools\.\n\n# Available Tools\n\[/)
|
|
146
|
+
assert.deepEqual(typesOf(chunks), [
|
|
147
|
+
"RUN_STARTED",
|
|
148
|
+
"TEXT_MESSAGE_START",
|
|
149
|
+
"TEXT_MESSAGE_CONTENT",
|
|
150
|
+
"TEXT_MESSAGE_END",
|
|
151
|
+
"TOOL_CALL_START",
|
|
152
|
+
"TOOL_CALL_ARGS",
|
|
153
|
+
"TOOL_CALL_END",
|
|
154
|
+
"RUN_FINISHED"
|
|
155
|
+
])
|
|
156
|
+
assert.equal(textIn(chunks), "Checking. ")
|
|
157
|
+
const end = chunks.find((c) => c.type === "TOOL_CALL_END")
|
|
158
|
+
assert.equal(end?.toolCallName, "get_weather")
|
|
159
|
+
assert.deepEqual(end?.input, { city: "Paris" })
|
|
160
|
+
assert.equal(chunks.at(-1)?.finishReason, "tool_calls")
|
|
161
|
+
// Stopped at the closing fence (the stream reads at most one piece ahead).
|
|
162
|
+
assert.ok(control.written <= 6, `wrote ${control.written} of 7 pieces`)
|
|
163
|
+
assert.equal((control.prompts[0].options.signal as AbortSignal).aborted, true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("a call with no text before it sends no text message", async () => {
|
|
167
|
+
control.pieces = ['```tool_call\n{"name":"get_weather","arguments":{"city":"Paris"}}\n```']
|
|
168
|
+
const chunks = await run({ tools: [weather] })
|
|
169
|
+
assert.deepEqual(typesOf(chunks), ["RUN_STARTED", "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "RUN_FINISHED"])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("toolChoice none leaves the tools out of the prompt", async () => {
|
|
173
|
+
await run({ tools: [weather], toolChoice: "none", systemPrompts: ["Be brief."] })
|
|
174
|
+
assert.deepEqual(control.creates[0].initialPrompts, [{ role: "system", content: "Be brief." }])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("earlier calls are tool_call fences and their results one tool_result message", async () => {
|
|
178
|
+
await run({
|
|
179
|
+
tools: [weather],
|
|
180
|
+
messages: [
|
|
181
|
+
{ role: "user", content: "Weather in Paris and Rome?" },
|
|
182
|
+
{
|
|
183
|
+
role: "assistant",
|
|
184
|
+
content: "Checking.",
|
|
185
|
+
toolCalls: [
|
|
186
|
+
{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
|
|
187
|
+
{ id: "c2", type: "function", function: { name: "get_weather", arguments: '{"city":"Rome"}' } }
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
{ role: "tool", toolCallId: "c1", content: '{"temp":12}' },
|
|
191
|
+
{ role: "tool", toolCallId: "c2", content: "", error: "offline" }
|
|
192
|
+
]
|
|
193
|
+
})
|
|
194
|
+
assert.deepEqual(control.prompts[0].input, [
|
|
195
|
+
{ role: "user", content: "Weather in Paris and Rome?" },
|
|
196
|
+
{
|
|
197
|
+
role: "assistant",
|
|
198
|
+
content:
|
|
199
|
+
'Checking.\n```tool_call\n{"name":"get_weather","arguments":{"city":"Paris"},"id":"c1"}\n{"name":"get_weather","arguments":{"city":"Rome"},"id":"c2"}\n```'
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
role: "user",
|
|
203
|
+
content:
|
|
204
|
+
'```tool_result\n{"name":"get_weather","result":{"temp":12},"error":false,"id":"c1"}\n{"name":"get_weather","result":"offline","error":true,"id":"c2"}\n```'
|
|
205
|
+
}
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("images are loaded as Blobs, and the session expects them", async () => {
|
|
210
|
+
await run({
|
|
211
|
+
messages: [
|
|
212
|
+
{
|
|
213
|
+
role: "user",
|
|
214
|
+
content: [
|
|
215
|
+
{ type: "text", content: "What's this?" },
|
|
216
|
+
{ type: "image", source: { type: "data", value: btoa("png"), mimeType: "image/png" } }
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
})
|
|
221
|
+
assert.deepEqual(control.creates[0].expectedInputs, [{ type: "text", languages: ["en"] }, { type: "image" }])
|
|
222
|
+
const [message] = control.prompts[0].input as Array<{ content: Array<{ type: string; value: unknown }> }>
|
|
223
|
+
assert.equal(message.content[1].type, "image")
|
|
224
|
+
assert.ok(message.content[1].value instanceof Blob)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test("a failure ends with RUN_ERROR, with the error's own message, and frees the session", async () => {
|
|
228
|
+
control.promptFailure = new DOMException("too long", "QuotaExceededError")
|
|
229
|
+
const chunks = await run()
|
|
230
|
+
assert.deepEqual(typesOf(chunks), ["RUN_STARTED", "RUN_ERROR"])
|
|
231
|
+
assert.equal(chunks[1].message, "too long")
|
|
232
|
+
assert.equal(control.destroyed, 1)
|
|
233
|
+
|
|
234
|
+
control.promptFailure = undefined
|
|
235
|
+
control.createFailure = new DOMException("click first", "NotAllowedError")
|
|
236
|
+
assert.equal((await run()).at(-1)?.message, "click first")
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("an abort mid-reply ends with RUN_ERROR (code aborted) and keeps the thread's session", async () => {
|
|
240
|
+
for (const key of ["abortController", "request"] as const) {
|
|
241
|
+
control.written = 0
|
|
242
|
+
control.destroyed = 0
|
|
243
|
+
const controller = new AbortController()
|
|
244
|
+
control.pieces = ["a", "b", "c"]
|
|
245
|
+
control.beforePiece = (index) => {
|
|
246
|
+
if (index === 1) controller.abort(new Error("stop"))
|
|
247
|
+
}
|
|
248
|
+
const chunks = await run(key === "abortController" ? { abortController: controller } : { request: { signal: controller.signal } })
|
|
249
|
+
assert.equal(textIn(chunks), "a")
|
|
250
|
+
assert.ok(!chunks.some((c) => c.type === "RUN_FINISHED"))
|
|
251
|
+
assert.equal((chunks.at(-1)?.error as { code?: string } | undefined)?.code, "aborted")
|
|
252
|
+
// Chrome drops a stopped turn whole, so the session is still good.
|
|
253
|
+
assert.equal(control.destroyed, 0)
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"], additionalProperties: false }
|
|
258
|
+
const structured = (extra: Record<string, unknown> = {}) => ({ chatOptions: chatOptions(extra), outputSchema: schema })
|
|
259
|
+
|
|
260
|
+
test("structured output: one prompt on the thread's session under responseConstraint, with the schema as given", async () => {
|
|
261
|
+
const result = await promptApiText("text").structuredOutput(structured())
|
|
262
|
+
assert.deepEqual(result.data, { city: "Tokyo" })
|
|
263
|
+
assert.equal(result.rawText, '{"city":"Tokyo"}')
|
|
264
|
+
assert.equal(control.prompts.length, 1)
|
|
265
|
+
assert.equal(control.prompts[0].options.responseConstraint, schema)
|
|
266
|
+
assert.equal(control.destroyed, 0, "the thread keeps its session")
|
|
267
|
+
|
|
268
|
+
const value = await chat({
|
|
269
|
+
adapter: promptApiText("text"),
|
|
270
|
+
threadId: "structured",
|
|
271
|
+
messages: [{ role: "user", content: "Largest city?" }],
|
|
272
|
+
outputSchema: schema
|
|
273
|
+
})
|
|
274
|
+
assert.deepEqual(value, { city: "Tokyo" })
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test("structured output fails on an answer that isn't JSON, the browser's error, and an abort", async () => {
|
|
278
|
+
control.answer = "Tokyo"
|
|
279
|
+
await assert.rejects(promptApiText("text").structuredOutput(structured()), SyntaxError)
|
|
280
|
+
control.promptFailure = new DOMException("unsupported schema", "NotSupportedError")
|
|
281
|
+
await assert.rejects(promptApiText("text").structuredOutput(structured()), /unsupported schema/)
|
|
282
|
+
const controller = new AbortController()
|
|
283
|
+
controller.abort(new Error("stopped"))
|
|
284
|
+
await assert.rejects(promptApiText("text").structuredOutput(structured({ abortController: controller })), /stopped/)
|
|
285
|
+
assert.equal(control.destroyed, 3, "each failed session is freed")
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test("availability is the browser's answer for the adapter's languages and inputs", async () => {
|
|
289
|
+
let asked: unknown
|
|
290
|
+
const api = (globalThis as unknown as { LanguageModel: { availability: (o: unknown) => Promise<string> } }).LanguageModel
|
|
291
|
+
const original = api.availability
|
|
292
|
+
api.availability = async (options) => {
|
|
293
|
+
asked = options
|
|
294
|
+
return "downloadable"
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
assert.equal(await promptApiText("text", { languages: ["ja"], inputs: ["audio"] }).availability(), "downloadable")
|
|
298
|
+
assert.deepEqual(asked, { expectedInputs: [{ type: "text", languages: ["ja"] }, { type: "audio" }], expectedOutputs: [{ type: "text", languages: ["ja"] }] })
|
|
299
|
+
} finally {
|
|
300
|
+
api.availability = original
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test("availableInputModalities asks the browser about each kind of input: what it doesn't make unavailable", async () => {
|
|
305
|
+
const asked: Array<unknown> = []
|
|
306
|
+
const api = (globalThis as unknown as { LanguageModel: { availability: (o: { expectedInputs: Array<{ type: string }> }) => Promise<string> } }).LanguageModel
|
|
307
|
+
const original = api.availability
|
|
308
|
+
// Images on the device, audio not on this one (it needs a GPU).
|
|
309
|
+
api.availability = async (options) => {
|
|
310
|
+
asked.push(options.expectedInputs)
|
|
311
|
+
return options.expectedInputs.some((input) => input.type === "audio") ? "unavailable" : "available"
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
// As an app makes it: no inputs given.
|
|
315
|
+
const adapter = promptApiText({ languages: ["ja"] })
|
|
316
|
+
assert.deepEqual(await adapter.availableInputModalities(), ["text", "image"])
|
|
317
|
+
assert.deepEqual(asked, [
|
|
318
|
+
[{ type: "text", languages: ["ja"] }],
|
|
319
|
+
[{ type: "text", languages: ["ja"] }, { type: "image" }],
|
|
320
|
+
[{ type: "text", languages: ["ja"] }, { type: "audio" }]
|
|
321
|
+
])
|
|
322
|
+
// No model at all: nothing.
|
|
323
|
+
api.availability = async () => "unavailable"
|
|
324
|
+
assert.deepEqual(await adapter.availableInputModalities(), [])
|
|
325
|
+
} finally {
|
|
326
|
+
api.availability = original
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test("create reports the download between 0 and 1, then 1, to both listeners, and frees its session", async () => {
|
|
331
|
+
control.progress = [0, 0.5, 1]
|
|
332
|
+
const own: Array<number> = []
|
|
333
|
+
const call: Array<number> = []
|
|
334
|
+
const adapter = promptApiText("text", { onDownloadProgress: (p) => own.push(p) })
|
|
335
|
+
assert.equal(await adapter.create({ onDownloadProgress: (p) => call.push(p) }), adapter)
|
|
336
|
+
assert.deepEqual(own, [0.5, 1])
|
|
337
|
+
assert.deepEqual(call, [0.5, 1])
|
|
338
|
+
assert.equal(control.destroyed, 1)
|
|
339
|
+
|
|
340
|
+
control.createFailure = new DOMException("no", "NotSupportedError")
|
|
341
|
+
await assert.rejects(adapter.create(), /no/)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
/** A step on `adapter`, with these messages. */
|
|
345
|
+
async function step(adapter: ReturnType<typeof promptApiText>, messages: Array<unknown>, extra: Record<string, unknown> = {}) {
|
|
346
|
+
const chunks: Array<Chunk> = []
|
|
347
|
+
for await (const chunk of adapter.chatStream(chatOptions({ messages, ...extra }))) chunks.push(chunk as Chunk)
|
|
348
|
+
return chunks
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
test("a thread's next step prompts its kept session with only the new messages", async () => {
|
|
352
|
+
const adapter = promptApiText("text")
|
|
353
|
+
const first = [{ role: "user", content: "Weather in Paris?" }]
|
|
354
|
+
await step(adapter, first)
|
|
355
|
+
await step(adapter, [...first, { role: "assistant", content: "Hello there" }, { role: "user", content: "And tomorrow?" }])
|
|
356
|
+
assert.equal(control.creates.length, 1, "one session for the thread")
|
|
357
|
+
assert.deepEqual(control.prompts[1].input, [{ role: "user", content: "And tomorrow?" }])
|
|
358
|
+
assert.equal(control.destroyed, 0)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test("an edited conversation or new settings start the thread's session over", async () => {
|
|
362
|
+
const adapter = promptApiText("text")
|
|
363
|
+
await step(adapter, [{ role: "user", content: "Weather in Paris?" }])
|
|
364
|
+
await step(adapter, [{ role: "user", content: "Weather in Rome?" }])
|
|
365
|
+
assert.equal(control.creates.length, 2)
|
|
366
|
+
assert.equal(control.destroyed, 1)
|
|
367
|
+
assert.deepEqual(control.prompts[1].input, [{ role: "user", content: "Weather in Rome?" }])
|
|
368
|
+
|
|
369
|
+
const rome = [{ role: "user", content: "Weather in Rome?" }, { role: "assistant", content: "Hello there" }, { role: "user", content: "And tomorrow?" }]
|
|
370
|
+
await step(adapter, rome, { systemPrompts: ["Be brief."] })
|
|
371
|
+
assert.equal(control.creates.length, 3, "a new system prompt is a new session")
|
|
372
|
+
assert.equal((control.prompts[2].input as Array<unknown>).length, 3, "the whole conversation")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("a step stopped at a tool call isn't in the session, so the next step sends it", async () => {
|
|
376
|
+
const adapter = promptApiText("text")
|
|
377
|
+
control.pieces = ['```tool_call\n{"name": "get_weather", "arguments": {"city": "Paris"}}\n```', " and more"]
|
|
378
|
+
const asked = [{ role: "user", content: "Weather in Paris?" }]
|
|
379
|
+
const chunks = await step(adapter, asked, { tools: [weather] })
|
|
380
|
+
assert.equal(chunks.at(-1)?.finishReason, "tool_calls")
|
|
381
|
+
control.pieces = ["Sunny."]
|
|
382
|
+
await step(
|
|
383
|
+
adapter,
|
|
384
|
+
[
|
|
385
|
+
...asked,
|
|
386
|
+
{ role: "assistant", content: "", toolCalls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }] },
|
|
387
|
+
{ role: "tool", toolCallId: "c1", content: '{"temperature":20}' }
|
|
388
|
+
],
|
|
389
|
+
{ tools: [weather] }
|
|
390
|
+
)
|
|
391
|
+
assert.equal(control.creates.length, 1, "the same session")
|
|
392
|
+
const input = control.prompts[1].input as Array<{ role: string; content: string }>
|
|
393
|
+
assert.deepEqual(input.map((m) => m.role), ["user", "assistant", "user"], "the question, the call and its result")
|
|
394
|
+
assert.match(input[2].content, /```tool_result/)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("a thread's session reports an overflow to onContextOverflow", async () => {
|
|
398
|
+
const overflows: Array<string> = []
|
|
399
|
+
await run({}, { onContextOverflow: (event) => overflows.push(event.type) })
|
|
400
|
+
control.sessions.at(-1)?.dispatchEvent(new Event("contextoverflow"))
|
|
401
|
+
assert.deepEqual(overflows, ["contextoverflow"])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test("contextUsage and contextWindow are the thread's session's own; undefined without a session", async () => {
|
|
405
|
+
const adapter = promptApiText("text")
|
|
406
|
+
assert.equal(await adapter.contextUsage(), undefined)
|
|
407
|
+
assert.equal(await adapter.contextWindow("a"), undefined)
|
|
408
|
+
await step(adapter, [{ role: "user", content: "Hi" }], { threadId: "a" })
|
|
409
|
+
await step(adapter, [{ role: "user", content: "Hi" }], { threadId: "b" })
|
|
410
|
+
Object.assign(control.sessions[0], { contextUsage: 300 })
|
|
411
|
+
assert.equal(await adapter.contextUsage("a"), 300)
|
|
412
|
+
assert.equal(await adapter.contextUsage(), 120, "the thread used last")
|
|
413
|
+
assert.equal(await adapter.contextWindow("a"), 9216)
|
|
414
|
+
assert.equal(await adapter.contextUsage("c"), undefined)
|
|
415
|
+
await adapter.destroy()
|
|
416
|
+
assert.equal(await adapter.contextUsage("a"), undefined)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test("past four threads the least recently used session closes, and destroy() closes the rest", async () => {
|
|
420
|
+
const adapter = promptApiText("text")
|
|
421
|
+
for (const thread of ["a", "b", "c", "d", "e"]) await step(adapter, [{ role: "user", content: "Hi" }], { threadId: thread })
|
|
422
|
+
assert.equal(control.destroyed, 1, "thread a's")
|
|
423
|
+
await adapter.destroy()
|
|
424
|
+
assert.equal(control.destroyed, 5)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test("a call is emitted as it parses: chat() checks its arguments", async () => {
|
|
428
|
+
control.pieces = ['```tool_call\n{"name": "get_weather", "arguments": {"town": "Paris"}}\n```']
|
|
429
|
+
const chunks = await run({ tools: [weather] })
|
|
430
|
+
const end = chunks.find((c) => c.type === "TOOL_CALL_END")
|
|
431
|
+
assert.equal(end?.toolCallName, "get_weather")
|
|
432
|
+
assert.deepEqual(end?.input, { town: "Paris" })
|
|
433
|
+
assert.equal(chunks.at(-1)?.finishReason, "tool_calls")
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test("toolChoice none runs no tool, even when the model writes a call", async () => {
|
|
437
|
+
let runs = 0
|
|
438
|
+
const tool = { ...weather, execute: async () => ++runs }
|
|
439
|
+
control.pieces = ['```tool_call\n{"name": "get_weather", "arguments": {"city": "Paris"}}\n```']
|
|
440
|
+
const result = await chat({
|
|
441
|
+
adapter: promptApiText("text"),
|
|
442
|
+
threadId: "none",
|
|
443
|
+
messages: [{ role: "user", content: "Weather in Paris?" }],
|
|
444
|
+
tools: [tool],
|
|
445
|
+
toolChoice: "none",
|
|
446
|
+
stream: false
|
|
447
|
+
})
|
|
448
|
+
assert.equal(runs, 0)
|
|
449
|
+
assert.match(result.text, /tool_call/, "the call is text")
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test("a call to a tool the step doesn't offer is text, not a call", async () => {
|
|
453
|
+
control.pieces = ['```tool_call\n{"name": "delete_files", "arguments": {}}\n```']
|
|
454
|
+
const chunks = await run({ tools: [weather] })
|
|
455
|
+
assert.ok(!chunks.some((c) => c.type.startsWith("TOOL_CALL")))
|
|
456
|
+
assert.equal(chunks.at(-1)?.finishReason, "stop")
|
|
457
|
+
|
|
458
|
+
control.pieces = ['```tool_call\n{"name": "get_weather", "arguments": {}}\n```']
|
|
459
|
+
const forced = await run({ tools: [weather, { ...weather, name: "clock" }], toolChoice: { type: "tool", name: "clock" } })
|
|
460
|
+
assert.ok(!forced.some((c) => c.type.startsWith("TOOL_CALL")), "only the chosen tool")
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
/** Every chunk of these steps, run at the same time. */
|
|
464
|
+
const together = (...steps: Array<AsyncIterable<unknown>>) =>
|
|
465
|
+
Promise.all(
|
|
466
|
+
steps.map(async (s) => {
|
|
467
|
+
const chunks: Array<Chunk> = []
|
|
468
|
+
for await (const chunk of s) chunks.push(chunk as Chunk)
|
|
469
|
+
return chunks
|
|
470
|
+
})
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
test("overlapping steps on a warm thread get sessions of their own", async () => {
|
|
474
|
+
const adapter = promptApiText("text")
|
|
475
|
+
const first = [{ role: "user", content: "Weather in Paris?" }]
|
|
476
|
+
await step(adapter, first)
|
|
477
|
+
const next = [...first, { role: "assistant", content: "Hello there" }, { role: "user", content: "And tomorrow?" }]
|
|
478
|
+
await together(adapter.chatStream(chatOptions({ messages: next })), adapter.chatStream(chatOptions({ messages: next })))
|
|
479
|
+
assert.notEqual(control.prompts[1].session, control.prompts[2].session, "never the same session at once")
|
|
480
|
+
await adapter.destroy()
|
|
481
|
+
assert.equal(control.destroyed, control.creates.length, "every session closed")
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test("overlapping steps on a cold thread: destroy() closes every session", async () => {
|
|
485
|
+
const adapter = promptApiText("text")
|
|
486
|
+
await together(adapter.chatStream(chatOptions()), adapter.chatStream(chatOptions()))
|
|
487
|
+
await adapter.destroy()
|
|
488
|
+
assert.equal(control.creates.length, 2)
|
|
489
|
+
assert.equal(control.destroyed, 2)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test("promptCache retention none closes the session after the step; a key names the session to keep", async () => {
|
|
493
|
+
await run({ promptCache: { retention: "none" } })
|
|
494
|
+
assert.equal(control.destroyed, 1)
|
|
495
|
+
|
|
496
|
+
const adapter = promptApiText("text")
|
|
497
|
+
const first = [{ role: "user", content: "Weather in Paris?" }]
|
|
498
|
+
const promptCache = { retention: "short", key: "shared" }
|
|
499
|
+
await step(adapter, first, { threadId: "a", promptCache })
|
|
500
|
+
await step(adapter, [...first, { role: "assistant", content: "Hello there" }, { role: "user", content: "And tomorrow?" }], { threadId: "b", promptCache })
|
|
501
|
+
assert.equal(control.creates.length, 2, "thread b goes on from the keyed session")
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test("a fence without a call is text, and the reply goes on", async () => {
|
|
505
|
+
control.pieces = ["Hm. ", "```tool_call\nnope\n```", " Sunny."]
|
|
506
|
+
const chunks = await run({ tools: [weather] })
|
|
507
|
+
assert.equal(textIn(chunks), "Hm. ```tool_call\nnope\n``` Sunny.")
|
|
508
|
+
assert.equal(chunks.at(-1)?.finishReason, "stop")
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test("the model is optional: promptApiText() and promptApiText(config) are the browser's built-in model", () => {
|
|
512
|
+
assert.equal(promptApiText().model, "text")
|
|
513
|
+
assert.equal(promptApiText({ languages: ["en"] }).model, "text")
|
|
514
|
+
assert.equal(promptApiText("text", {}).model, "text")
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
/** The fake session's count of an input: `measureContextUsage`. */
|
|
518
|
+
const measured = (input: unknown) => JSON.stringify(input).length
|
|
519
|
+
const usageOf = (chunk: Chunk | undefined) =>
|
|
520
|
+
(chunk as { usage?: { promptTokens: number; completionTokens: number; totalTokens: number; promptTokensDetails?: { cachedTokens?: number } } } | undefined)?.usage
|
|
521
|
+
|
|
522
|
+
test("a reply reports its tokens: what the session held, and the prompt and the reply as the session measures them", async () => {
|
|
523
|
+
const adapter = promptApiText("text")
|
|
524
|
+
const step = async (messages: Array<Record<string, unknown>>) => {
|
|
525
|
+
const chunks: Array<Chunk> = []
|
|
526
|
+
for await (const chunk of adapter.chatStream(chatOptions({ messages }))) chunks.push(chunk as Chunk)
|
|
527
|
+
return usageOf(chunks.at(-1))
|
|
528
|
+
}
|
|
529
|
+
const first = [{ role: "user", content: "Weather in Paris?" }]
|
|
530
|
+
const prompt = 120 + measured(first)
|
|
531
|
+
// Less an empty message's count: the turn markers a session counts around any message.
|
|
532
|
+
const reply = measured("Hello there") - measured("")
|
|
533
|
+
// A new session read what it holds in this step: nothing cached.
|
|
534
|
+
assert.deepEqual(await step(first), { promptTokens: prompt, completionTokens: reply, totalTokens: prompt + reply })
|
|
535
|
+
|
|
536
|
+
// The thread's kept session doesn't read what it holds again: only the new message.
|
|
537
|
+
Object.assign(control.sessions[0], { contextUsage: 300 })
|
|
538
|
+
const next = [...first, { role: "assistant", content: "Hello there" }, { role: "user", content: "And tomorrow?" }]
|
|
539
|
+
const usage = await step(next)
|
|
540
|
+
assert.equal(control.creates.length, 1, "the same session")
|
|
541
|
+
assert.equal(usage?.promptTokens, 300 + measured([{ role: "user", content: "And tomorrow?" }]))
|
|
542
|
+
assert.deepEqual(usage?.promptTokensDetails, { cachedTokens: 300 })
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test("a step stopped at a tool call counts everything the model wrote, the call included", async () => {
|
|
546
|
+
control.pieces = ["Checking. ", '```tool_call\n{"name":"get_weather","arguments":{"city":"Paris"}}\n```']
|
|
547
|
+
const chunks = await run({ tools: [weather] })
|
|
548
|
+
assert.equal(chunks.at(-1)?.finishReason, "tool_calls")
|
|
549
|
+
assert.equal(usageOf(chunks.at(-1))?.completionTokens, measured(control.pieces.join("")) - measured(""))
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
test("structured output reports its tokens too", async () => {
|
|
553
|
+
const result = await promptApiText("text").structuredOutput(structured())
|
|
554
|
+
assert.equal(result.usage?.completionTokens, measured('{"city":"Tokyo"}') - measured(""))
|
|
555
|
+
assert.equal(result.usage?.promptTokens, 120 + measured([{ role: "user", content: "Weather in Paris?" }]))
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
test("a browser that can't measure reports no usage, and the reply still ends", async () => {
|
|
559
|
+
const measure = FakeSession.prototype.measureContextUsage
|
|
560
|
+
FakeSession.prototype.measureContextUsage = async () => {
|
|
561
|
+
throw new Error("measureContextUsage isn't supported")
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const chunks = await run()
|
|
565
|
+
assert.equal(chunks.at(-1)?.type, "RUN_FINISHED")
|
|
566
|
+
assert.equal(usageOf(chunks.at(-1)), undefined)
|
|
567
|
+
assert.equal(textIn(chunks), "Hello there")
|
|
568
|
+
} finally {
|
|
569
|
+
FakeSession.prototype.measureContextUsage = measure
|
|
570
|
+
}
|
|
571
|
+
})
|