@tanstack-ai-browser/prompt-api 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/dist/adapter.d.ts +160 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +414 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +7 -0
  9. package/dist/limit.d.ts +119 -0
  10. package/dist/limit.d.ts.map +1 -0
  11. package/dist/limit.js +203 -0
  12. package/dist/messages.d.ts +20 -0
  13. package/dist/messages.d.ts.map +1 -0
  14. package/dist/messages.js +95 -0
  15. package/dist/model-meta.d.ts +21 -0
  16. package/dist/model-meta.d.ts.map +1 -0
  17. package/dist/model-meta.js +18 -0
  18. package/dist/model.d.ts +28 -0
  19. package/dist/model.d.ts.map +1 -0
  20. package/dist/model.js +53 -0
  21. package/dist/reasoning.d.ts +42 -0
  22. package/dist/reasoning.d.ts.map +1 -0
  23. package/dist/reasoning.js +113 -0
  24. package/dist/sessions.d.ts +48 -0
  25. package/dist/sessions.d.ts.map +1 -0
  26. package/dist/sessions.js +275 -0
  27. package/dist/shared-load.d.ts +48 -0
  28. package/dist/shared-load.d.ts.map +1 -0
  29. package/dist/shared-load.js +103 -0
  30. package/dist/store.d.ts +8 -0
  31. package/dist/store.d.ts.map +1 -0
  32. package/dist/store.js +17 -0
  33. package/dist/tools.d.ts +50 -0
  34. package/dist/tools.d.ts.map +1 -0
  35. package/dist/tools.js +314 -0
  36. package/dist/types.d.ts +81 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +13 -0
  39. package/package.json +60 -4
  40. package/src/adapter.test.ts +571 -0
  41. package/src/adapter.ts +522 -0
  42. package/src/index.ts +13 -0
  43. package/src/messages.ts +102 -0
  44. package/src/model-meta.ts +35 -0
  45. package/src/model.ts +75 -0
  46. package/src/tools.test.ts +129 -0
  47. package/src/tools.ts +311 -0
  48. package/src/types.ts +110 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * A TanStack AI text adapter for the browser's built-in language model,
3
+ * through the Prompt API (`LanguageModel`): Gemini Nano in Chrome,
4
+ * Phi-4-mini in Edge. The browser downloads and runs the model itself;
5
+ * nothing leaves the device. Hand it to `chat()` like any hosted model's
6
+ * adapter: `chat()` runs tools and the agent loop, and each `chatStream`
7
+ * call here is one step of the model, streamed back as AG-UI events.
8
+ *
9
+ * Each thread (`threadId`) keeps a session: a step prompts it with only the
10
+ * messages it hasn't seen, and a conversation that changed (an edit, a new
11
+ * system prompt) starts a new one. Tools go by prompt (`tools.ts`);
12
+ * structured output prompts the thread's session under the Prompt API's own
13
+ * `responseConstraint`.
14
+ */
15
+ import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai"
16
+ import type { AdapterYieldChunk, ModelMessage, TextOptions, TokenUsage, Tool } from "@tanstack/ai"
17
+ import { BaseTextAdapter } from "@tanstack/ai/adapters"
18
+ import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters"
19
+
20
+ import { contentText, mediaIn, toLanguageModelMessages } from "./messages.js"
21
+ import { baseOptions, createSession, DEFAULT_LANGUAGES, MEDIA_INPUTS, promptApiAvailability } from "./model.js"
22
+ import type { Availability, MediaInput } from "./model.js"
23
+ import type { PromptApiChatModel } from "./model-meta.js"
24
+ import { toolCallReader, toolSystemPrompt } from "./tools.js"
25
+ import type { DownloadProgressCallback, LanguageModelInput, LanguageModelSession, SamplingMode } from "./types.js"
26
+
27
+ export type PromptApiConfig = {
28
+ /**
29
+ * Languages the conversation is in, input and output: ["en"] by default.
30
+ * Chrome's model takes "en", "es", "ja", "de" and "fr", and checks its
31
+ * safety filters for the output language.
32
+ */
33
+ languages?: ReadonlyArray<string>
34
+ /**
35
+ * Kinds of input besides text the conversation will have ("image",
36
+ * "audio"), for `availability()`: images need more of the device, audio a
37
+ * GPU. Text only by default. Sessions expect what each conversation has.
38
+ */
39
+ inputs?: ReadonlyArray<MediaInput>
40
+ /**
41
+ * The browser's download of its model, 0 to 1, when a call starts it; then
42
+ * 1 once a session has started. To download ahead of time, see `create`.
43
+ */
44
+ onDownloadProgress?: DownloadProgressCallback
45
+ /**
46
+ * Called when a thread's session overflows its context window (the
47
+ * session's `contextoverflow` event): Chrome then drops the oldest
48
+ * messages after the system prompt to make room.
49
+ */
50
+ onContextOverflow?: (event: Event) => void
51
+ }
52
+
53
+ /** Per-call `modelOptions`: the session's sampling. */
54
+ export type PromptApiTextModelOptions = {
55
+ /**
56
+ * Sampling preset, "most-predictable" to "most-creative" ("balanced" by
57
+ * default). Chrome takes it on web pages only in its Sampling Parameters
58
+ * origin trial.
59
+ */
60
+ samplingMode?: SamplingMode
61
+ /** Raw sampling, for extensions (web pages ignore it). Chrome refuses it together with `samplingMode`. */
62
+ temperature?: number
63
+ topK?: number
64
+ }
65
+
66
+ type MetadataByModality = {
67
+ text: unknown
68
+ image: unknown
69
+ audio: unknown
70
+ video: unknown
71
+ document: unknown
72
+ }
73
+
74
+ /**
75
+ * Threads that keep a session open; the least recently used closes first. A
76
+ * session holds its conversation in the model's memory (about 100 MB for
77
+ * 6,000 tokens in Chrome 156), freed only by `destroy()`.
78
+ */
79
+ const KEPT_SESSIONS = 4
80
+
81
+ /**
82
+ * A thread's session (kept from the moment a step reserves it, while the
83
+ * browser makes it), the settings it was made with, and the messages it holds.
84
+ */
85
+ type Kept = { session: Promise<LanguageModelSession>; settings: string; seen: Array<string>; busy: boolean }
86
+
87
+ /** Closes a session once it's made; one that failed to start has nothing to close. */
88
+ const close = (session: Promise<LanguageModelSession>) => void session.then((s) => s.destroy(), () => undefined)
89
+
90
+ /**
91
+ * A message as compared with what a session holds. The model's own replies
92
+ * go by their text alone: the session has them as the model wrote them.
93
+ */
94
+ const messageKey = (m: ModelMessage) =>
95
+ m.role === "assistant"
96
+ ? JSON.stringify(["assistant", contentText(m.content).trim()])
97
+ : JSON.stringify([m.role, m.content, m.toolCallId ?? null, m.error ?? null])
98
+
99
+ /** The tools a step offers: none for `toolChoice: "none"`, only the chosen one for a named choice. */
100
+ function offeredTools(options: TextOptions<PromptApiTextModelOptions>): ReadonlyArray<Tool> {
101
+ const { toolChoice, tools = [] } = options
102
+ if (toolChoice === "none") return []
103
+ if (typeof toolChoice === "object") return tools.filter((tool) => tool.name === toolChoice.name)
104
+ return tools
105
+ }
106
+
107
+ /** A step's system prompt (the caller's, then the tools), and the settings its session is made with. */
108
+ function sessionSettings(options: TextOptions<PromptApiTextModelOptions>, tools: ReadonlyArray<Tool>) {
109
+ const system = toolSystemPrompt(
110
+ normalizeSystemPrompts(options.systemPrompts)
111
+ .map((p) => p.content)
112
+ .join("\n\n"),
113
+ tools
114
+ )
115
+ const { samplingMode, temperature, topK } = options.modelOptions ?? {}
116
+ return { system, settings: JSON.stringify([system, samplingMode, temperature, topK, mediaIn(options.messages)]) }
117
+ }
118
+
119
+ /** Whether a conversation (its messages' keys) goes on from what a kept session holds, with its settings. */
120
+ const goesOnFrom = (held: Kept, settings: string, keys: Array<string>) =>
121
+ !held.busy && held.settings === settings && held.seen.length < keys.length && held.seen.every((key, i) => key === keys[i])
122
+
123
+ let ids = 0
124
+ const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`
125
+
126
+ /** The caller's signal: the legacy `abortController` first, then `request`. */
127
+ const signalOf = (options: TextOptions<PromptApiTextModelOptions>) =>
128
+ options.abortController?.signal ?? (options.request as RequestInit | undefined)?.signal ?? undefined
129
+
130
+
131
+ export class PromptApiTextAdapter extends BaseTextAdapter<
132
+ PromptApiChatModel,
133
+ PromptApiTextModelOptions,
134
+ readonly ["text", "image", "audio"],
135
+ MetadataByModality
136
+ > {
137
+ override readonly kind = "text" as const
138
+ readonly name = "prompt-api" as const
139
+ override readonly provider = "prompt-api" as const
140
+ override readonly api = "prompt-api" as const
141
+ override readonly inputModalities = ["text", "image", "audio"] as const
142
+ private readonly languages: ReadonlyArray<string>
143
+ private readonly inputs: ReadonlyArray<MediaInput>
144
+ private readonly onDownloadProgress: DownloadProgressCallback | undefined
145
+ private readonly onContextOverflow: ((event: Event) => void) | undefined
146
+ /** Each thread's session, least recently used first. */
147
+ private readonly kept = new Map<string, Kept>()
148
+
149
+ constructor(config: PromptApiConfig, model: PromptApiChatModel) {
150
+ super({}, model)
151
+ this.languages = config.languages ?? DEFAULT_LANGUAGES
152
+ this.inputs = config.inputs ?? []
153
+ this.onDownloadProgress = config.onDownloadProgress
154
+ this.onContextOverflow = config.onContextOverflow
155
+ }
156
+
157
+ /**
158
+ * The browser's own `LanguageModel.availability()` for this adapter's
159
+ * `languages` and `inputs`: "available" (on the device), "downloadable"
160
+ * or "downloading" (it downloads when a session starts, or at `create`),
161
+ * or "unavailable" (no Prompt API, or the device can't run it).
162
+ */
163
+ availability(): Promise<Availability> {
164
+ return promptApiAvailability(baseOptions(this.languages, this.inputs))
165
+ }
166
+
167
+ /**
168
+ * The kinds of input the browser's model takes here, of the
169
+ * `inputModalities` the Prompt API has: each kind the browser's
170
+ * `LanguageModel.availability()` doesn't call "unavailable" for this
171
+ * adapter's `languages` (images need more of the device, audio a GPU).
172
+ * Empty where there's no model. Sessions expect what each conversation has.
173
+ */
174
+ async availableInputModalities(): Promise<Array<"text" | MediaInput>> {
175
+ if ((await promptApiAvailability(baseOptions(this.languages))) === "unavailable") return []
176
+ const media = await Promise.all(
177
+ MEDIA_INPUTS.map(async (kind) =>
178
+ (await promptApiAvailability(baseOptions(this.languages, [kind]))) === "unavailable" ? [] : [kind]
179
+ )
180
+ )
181
+ return ["text", ...media.flat()]
182
+ }
183
+
184
+ /**
185
+ * Downloads the browser's model if it isn't on the device and starts it,
186
+ * with the download's progress, 0 to 1, then 1 once it's ready. Resolves
187
+ * to this adapter; rejects if it can't start (fine to leave unawaited).
188
+ * `chat()` doesn't need it, but the browser asks for a click on the page
189
+ * before a download: call it from the click that turns the model on.
190
+ * Aborting `signal` aborts the browser's `create()`.
191
+ */
192
+ create(options: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}): Promise<this> {
193
+ const listeners = [this.onDownloadProgress, options.onDownloadProgress].filter((l) => !!l)
194
+ const ready = createSession(baseOptions(this.languages), options.signal, (progress) => {
195
+ for (const listener of listeners) listener(progress)
196
+ }).then((session) => {
197
+ session.destroy()
198
+ return this
199
+ })
200
+ // Callers may leave it: an unawaited failure isn't an unhandled rejection.
201
+ ready.catch(() => undefined)
202
+ return ready
203
+ }
204
+
205
+ /**
206
+ * Tokens a thread's session holds (the session's `contextUsage`): the
207
+ * system prompt, the tools and the conversation so far. The thread is
208
+ * `chat()`'s `threadId` (or its `promptCache.key`); left out, the thread
209
+ * used last. Undefined where the thread keeps no session.
210
+ */
211
+ async contextUsage(threadId?: string): Promise<number | undefined> {
212
+ return (await this.keptSession(threadId))?.contextUsage
213
+ }
214
+
215
+ /**
216
+ * Tokens a thread's session can hold (the session's `contextWindow`), for
217
+ * the thread as `contextUsage` takes it. Undefined where the thread keeps
218
+ * no session.
219
+ */
220
+ async contextWindow(threadId?: string): Promise<number | undefined> {
221
+ return (await this.keptSession(threadId))?.contextWindow
222
+ }
223
+
224
+ /** A thread's kept session, or the last used thread's; undefined if there's none, or it failed to start. */
225
+ private keptSession(threadId?: string): Promise<LanguageModelSession | undefined> {
226
+ const kept = threadId === undefined ? [...this.kept.values()].at(-1) : this.kept.get(threadId)
227
+ return kept ? kept.session.catch(() => undefined) : Promise.resolve(undefined)
228
+ }
229
+
230
+ /** Closes every thread's session. The browser keeps the model. */
231
+ async destroy(): Promise<void> {
232
+ for (const kept of this.kept.values()) close(kept.session)
233
+ this.kept.clear()
234
+ }
235
+
236
+ /** Keeps a thread's session, closing the least recently used past the limit. */
237
+ private keep(threadId: string, kept: Kept) {
238
+ this.kept.delete(threadId)
239
+ this.kept.set(threadId, kept)
240
+ for (const [id, old] of this.kept) {
241
+ if (this.kept.size <= KEPT_SESSIONS) break
242
+ if (old.busy) continue
243
+ close(old.session)
244
+ this.kept.delete(id)
245
+ }
246
+ }
247
+
248
+ /** Closes a thread's session, unless a step is using it. */
249
+ private drop(threadId: string) {
250
+ const kept = this.kept.get(threadId)
251
+ if (!kept || kept.busy) return
252
+ close(kept.session)
253
+ this.kept.delete(threadId)
254
+ }
255
+
256
+ /** A session for this call, holding the system prompt, that reports an overflow. */
257
+ private async session(
258
+ options: TextOptions<PromptApiTextModelOptions>,
259
+ system: string,
260
+ signal: AbortSignal | undefined
261
+ ): Promise<LanguageModelSession> {
262
+ const { samplingMode, temperature, topK } = options.modelOptions ?? {}
263
+ const session = await createSession(
264
+ {
265
+ ...baseOptions(this.languages, mediaIn(options.messages)),
266
+ ...(system && { initialPrompts: [{ role: "system", content: system }] }),
267
+ ...(samplingMode !== undefined && { samplingMode }),
268
+ ...(temperature !== undefined && { temperature }),
269
+ ...(topK !== undefined && { topK })
270
+ },
271
+ signal,
272
+ this.onDownloadProgress
273
+ )
274
+ if (this.onContextOverflow) session.addEventListener("contextoverflow", this.onContextOverflow)
275
+ return session
276
+ }
277
+
278
+ /**
279
+ * The step's session and the messages to prompt it with: the thread's
280
+ * kept session if the conversation goes on from what it holds (only the
281
+ * new messages), else a new one with the whole conversation. The session
282
+ * is reserved before anything is awaited, so a second step on a busy
283
+ * thread gets a session of its own (`kept` undefined), closed after it.
284
+ * Threads go by `promptCache.key`; retention "none" keeps no session.
285
+ */
286
+ private async begin(options: TextOptions<PromptApiTextModelOptions>, signal: AbortSignal | undefined) {
287
+ const key = options.promptCache?.key ?? options.threadId
288
+ const retain = options.promptCache?.retention !== "none"
289
+ const tools = offeredTools(options)
290
+ const { system, settings } = sessionSettings(options, tools)
291
+ const keys = options.messages.map(messageKey)
292
+ const held = retain ? this.kept.get(key) : undefined
293
+ const goesOn = !!held && goesOnFrom(held, settings, keys)
294
+ let kept: Kept | undefined
295
+ if (goesOn) kept = held
296
+ else if (retain && !held?.busy) {
297
+ this.drop(key)
298
+ kept = { session: this.session(options, system, signal), settings, seen: [], busy: false }
299
+ }
300
+ if (kept) {
301
+ kept.busy = true
302
+ this.keep(key, kept)
303
+ }
304
+ const pending = kept?.session ?? this.session(options, system, signal)
305
+ try {
306
+ const session = await pending
307
+ const input = await toLanguageModelMessages(options.messages, goesOn ? held.seen.length : 0)
308
+ return { key, session, kept, keys, input, tools, goesOn }
309
+ } catch (error) {
310
+ // A session that goes on still holds the thread; a new one is dropped.
311
+ this.end(key, pending, kept, !goesOn)
312
+ throw error
313
+ }
314
+ }
315
+
316
+ /** Frees a step's session: a kept one stays for the thread unless the step failed. */
317
+ private end(key: string, session: Promise<LanguageModelSession>, kept: Kept | undefined, failed: boolean) {
318
+ if (!kept) return close(session)
319
+ kept.busy = false
320
+ if (failed && this.kept.get(key) === kept) this.drop(key)
321
+ }
322
+
323
+ /**
324
+ * A step's token counts, which the Prompt API doesn't report, from the
325
+ * session's own: the tokens it held before the prompt, the prompt as the
326
+ * session measures it, and the reply measured the same way, less an empty
327
+ * message's tokens (the turn markers the session counts around any
328
+ * message). What a kept session held is cached: it isn't read again. A
329
+ * new session read what it holds (the system prompt) in this step.
330
+ * Nothing where the browser can't measure.
331
+ */
332
+ private async usage(
333
+ session: LanguageModelSession,
334
+ held: number,
335
+ goesOn: boolean,
336
+ input: LanguageModelInput,
337
+ reply: string
338
+ ): Promise<TokenUsage | undefined> {
339
+ try {
340
+ const [read, written, markers] = await Promise.all([
341
+ session.measureContextUsage(input),
342
+ session.measureContextUsage(reply),
343
+ session.measureContextUsage("")
344
+ ])
345
+ const counts = [held, read, written, markers]
346
+ if (!counts.every((count) => Number.isFinite(count))) return undefined
347
+ const promptTokens = held + read
348
+ const completionTokens = Math.max(0, written - markers)
349
+ return {
350
+ ...buildBaseUsage({ promptTokens, completionTokens, totalTokens: promptTokens + completionTokens }),
351
+ // A new session read all of it in this step: none was cached.
352
+ promptTokensDetails: { cachedTokens: goesOn ? held : 0 }
353
+ }
354
+ } catch {
355
+ return undefined
356
+ }
357
+ }
358
+
359
+ async *chatStream(options: TextOptions<PromptApiTextModelOptions>): AsyncIterable<AdapterYieldChunk> {
360
+ const runId = options.runId ?? nextId("run")
361
+ const { threadId } = options
362
+ const messageId = nextId("msg")
363
+ const model = this.model
364
+ const signal = signalOf(options)
365
+ const timestamp = () => Date.now()
366
+ yield {
367
+ type: EventType.RUN_STARTED,
368
+ runId,
369
+ threadId,
370
+ model,
371
+ timestamp: timestamp(),
372
+ ...(options.parentRunId && { parentRunId: options.parentRunId })
373
+ }
374
+
375
+ let step: Awaited<ReturnType<typeof this.begin>> | undefined
376
+ let failed = false
377
+ // Stops the generation once a tool call is read.
378
+ const stop = new AbortController()
379
+ try {
380
+ step = await this.begin(options, signal)
381
+ const { session, kept, keys, input, tools, goesOn } = step
382
+ // Without tools to offer, the reply is all text.
383
+ const reader = tools.length > 0 ? toolCallReader(tools.map((tool) => tool.name)) : undefined
384
+ let text = ""
385
+ const textEvents = function* (delta: string): Generator<AdapterYieldChunk> {
386
+ if (!delta) return
387
+ if (!text) yield { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant", model, timestamp: timestamp() }
388
+ text += delta
389
+ yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, content: text, model, timestamp: timestamp() }
390
+ }
391
+ const generation = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal
392
+ // What the session holds before this prompt, and all the model writes (a tool call's text too).
393
+ const held = session.contextUsage
394
+ let written = ""
395
+ try {
396
+ for await (const piece of session.promptStreaming(input, { signal: generation })) {
397
+ written += piece
398
+ yield* textEvents(reader ? reader.push(piece) : piece)
399
+ if (reader?.call) {
400
+ stop.abort()
401
+ break
402
+ }
403
+ }
404
+ } catch (error) {
405
+ if (!stop.signal.aborted || signal?.aborted) throw error
406
+ }
407
+ const call = reader?.call
408
+ if (reader) {
409
+ yield* textEvents(reader.end())
410
+ // Text after the call, in the piece that closed it, is shown too.
411
+ yield* textEvents(reader.trailing)
412
+ }
413
+ if (text) yield { type: EventType.TEXT_MESSAGE_END, messageId, model, timestamp: timestamp() }
414
+ // A finished reply is in the session now. One stopped at a tool call
415
+ // isn't: Chrome drops a stopped turn whole, so the next step sends it.
416
+ if (kept && !stop.signal.aborted) kept.seen = [...keys, messageKey({ role: "assistant", content: text })]
417
+ if (call) {
418
+ const toolCallId = nextId("call")
419
+ const args = JSON.stringify(call.arguments)
420
+ yield {
421
+ type: EventType.TOOL_CALL_START,
422
+ toolCallId,
423
+ toolCallName: call.name,
424
+ parentMessageId: messageId,
425
+ index: 0,
426
+ model,
427
+ timestamp: timestamp()
428
+ }
429
+ yield { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: args, args, model, timestamp: timestamp() }
430
+ yield {
431
+ type: EventType.TOOL_CALL_END,
432
+ toolCallId,
433
+ toolCallName: call.name,
434
+ args,
435
+ input: call.arguments,
436
+ model,
437
+ timestamp: timestamp()
438
+ }
439
+ }
440
+ const usage = await this.usage(session, held, goesOn, input, written)
441
+ yield {
442
+ type: EventType.RUN_FINISHED,
443
+ runId,
444
+ threadId,
445
+ model,
446
+ timestamp: timestamp(),
447
+ finishReason: call ? "tool_calls" : "stop",
448
+ ...(usage && { usage })
449
+ }
450
+ } catch (error) {
451
+ // An abort keeps the thread's session: Chrome drops the stopped turn.
452
+ const aborted = !!signal?.aborted
453
+ failed = !aborted
454
+ options.logger.errors("prompt-api chatStream failed", { error, source: "prompt-api.chatStream" })
455
+ const message = error instanceof Error ? error.message : String(error)
456
+ yield {
457
+ type: EventType.RUN_ERROR,
458
+ runId,
459
+ model,
460
+ timestamp: timestamp(),
461
+ message,
462
+ error: { message, ...(aborted && { code: "aborted" }) }
463
+ }
464
+ } finally {
465
+ if (step) this.end(step.key, Promise.resolve(step.session), step.kept, failed)
466
+ }
467
+ }
468
+
469
+ /**
470
+ * A JSON answer for `chat({ outputSchema })`: the thread's session
471
+ * answers the conversation under the Prompt API's `responseConstraint`,
472
+ * which holds the model to the schema as given. `chat()` validates the
473
+ * result.
474
+ */
475
+ async structuredOutput(
476
+ options: StructuredOutputOptions<PromptApiTextModelOptions>
477
+ ): Promise<StructuredOutputResult<unknown>> {
478
+ const { chatOptions, outputSchema } = options
479
+ const signal = signalOf(chatOptions)
480
+ let step: Awaited<ReturnType<typeof this.begin>> | undefined
481
+ let failed = false
482
+ try {
483
+ step = await this.begin(chatOptions, signal)
484
+ const { session, kept, keys, input, goesOn } = step
485
+ const held = session.contextUsage
486
+ const rawText = await session.prompt(input, { responseConstraint: outputSchema, signal })
487
+ if (kept) kept.seen = [...keys, messageKey({ role: "assistant", content: rawText })]
488
+ const data = JSON.parse(rawText) as unknown
489
+ const usage = await this.usage(session, held, goesOn, input, rawText)
490
+ return { data, rawText, model: this.model, ...(usage && { usage }) }
491
+ } catch (error) {
492
+ failed = true
493
+ throw error
494
+ } finally {
495
+ if (step) this.end(step.key, Promise.resolve(step.session), step.kept, failed)
496
+ }
497
+ }
498
+ }
499
+
500
+ /**
501
+ * A TanStack AI text adapter for the browser's built-in model, through the
502
+ * Prompt API: Gemini Nano in Chrome, Phi-4-mini in Edge. The model is
503
+ * whichever the browser has ("text", as other Prompt API libraries name it), so naming it is optional.
504
+ *
505
+ * @example
506
+ * ```ts
507
+ * const adapter = promptApiText()
508
+ * if ((await adapter.availability()) !== "unavailable") {
509
+ * chat({ adapter, messages, tools })
510
+ * }
511
+ * ```
512
+ */
513
+ export function promptApiText(config?: PromptApiConfig): PromptApiTextAdapter
514
+ export function promptApiText(model: PromptApiChatModel, config?: PromptApiConfig): PromptApiTextAdapter
515
+ export function promptApiText(
516
+ modelOrConfig?: PromptApiChatModel | PromptApiConfig,
517
+ config: PromptApiConfig = {}
518
+ ): PromptApiTextAdapter {
519
+ return typeof modelOrConfig === "string"
520
+ ? new PromptApiTextAdapter(config, modelOrConfig)
521
+ : new PromptApiTextAdapter(modelOrConfig ?? {}, "text")
522
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * TanStack AI adapter for the browser's built-in language model, through
3
+ * the Prompt API: Gemini Nano in Chrome, Phi-4-mini in Edge. The model
4
+ * catalog (model-meta.ts) is exported here too.
5
+ */
6
+ export { PromptApiTextAdapter, promptApiText } from "./adapter.js"
7
+ export type {
8
+ PromptApiConfig,
9
+ PromptApiTextModelOptions
10
+ } from "./adapter.js"
11
+ export type { Availability, DownloadProgressCallback } from "./types.js"
12
+ export { PROMPT_API_CHAT_MODELS, PROMPT_API_MODEL_META } from "./model-meta.js"
13
+ export type { PromptApiChatModel, PromptApiModelMeta } from "./model-meta.js"
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The conversation as the Prompt API reads it: user messages with their
3
+ * images and audio (loaded as Blobs), the model's messages with their tool
4
+ * calls as ```tool_call fences, and tool results as user messages in a
5
+ * ```tool_result fence.
6
+ */
7
+ import type { ContentPart, ModelMessage } from "@tanstack/ai"
8
+
9
+ import type { MediaInput } from "./model.js"
10
+ import { toolCallFence, toolResultFence, toolResultLine } from "./tools.js"
11
+ import type { LanguageModelContent, LanguageModelMessage } from "./types.js"
12
+
13
+ /** The text of a message's content, without its images or audio. */
14
+ export function contentText(content: ModelMessage["content"]) {
15
+ if (typeof content === "string") return content
16
+ return (content ?? []).map((part) => (part.type === "text" ? part.content : "")).join("")
17
+ }
18
+
19
+ /** The kinds of media in a conversation, which its session must expect. */
20
+ export function mediaIn(messages: ReadonlyArray<ModelMessage>): Array<MediaInput> {
21
+ const kinds = new Set<MediaInput>()
22
+ for (const m of messages) {
23
+ if (m.role !== "user" || typeof m.content === "string") continue
24
+ for (const part of m.content ?? []) {
25
+ if (part.type === "image" || part.type === "audio") kinds.add(part.type)
26
+ }
27
+ }
28
+ return [...kinds].sort()
29
+ }
30
+
31
+ function base64Blob(value: string, mimeType: string | undefined) {
32
+ const data = value.startsWith("data:") ? value.slice(value.indexOf(",") + 1) : value
33
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
34
+ return new Blob([bytes], { type: mimeType ?? "" })
35
+ }
36
+
37
+ async function loadMedia(type: MediaInput, source: { type: string; value: string; mimeType?: string }) {
38
+ if (source.type === "data") return base64Blob(source.value, source.mimeType)
39
+ if (source.type !== "url") {
40
+ throw new Error(`The Prompt API takes ${type} as data or a URL, not a ${source.type} source.`)
41
+ }
42
+ const response = await fetch(source.value)
43
+ if (!response.ok) throw new Error(`${source.value.slice(0, 120)}: HTTP ${response.status}`)
44
+ return response.blob()
45
+ }
46
+
47
+ async function userContent(content: ModelMessage["content"]): Promise<LanguageModelMessage["content"]> {
48
+ if (typeof content === "string" || !content) return content ?? ""
49
+ if (content.every((part) => part.type === "text")) return contentText(content)
50
+ return Promise.all(
51
+ content.map(async (part: ContentPart): Promise<LanguageModelContent> => {
52
+ if (part.type === "text") return { type: "text", value: part.content }
53
+ if (part.type === "image" || part.type === "audio") {
54
+ return { type: part.type, value: await loadMedia(part.type, part.source) }
55
+ }
56
+ throw new Error(`The Prompt API takes text, images and audio, not ${part.type}.`)
57
+ })
58
+ )
59
+ }
60
+
61
+ /**
62
+ * The conversation as Prompt API messages, from `start` on (a kept session
63
+ * holds the rest); a step's tool results share one message. Tool names come
64
+ * from the whole conversation.
65
+ */
66
+ export async function toLanguageModelMessages(
67
+ messages: ReadonlyArray<ModelMessage>,
68
+ start = 0
69
+ ): Promise<Array<LanguageModelMessage>> {
70
+ const out: Array<LanguageModelMessage> = []
71
+ const toolNames = new Map<string, string>()
72
+ for (const m of messages.slice(0, start)) {
73
+ for (const call of m.toolCalls ?? []) toolNames.set(call.id, call.function.name)
74
+ }
75
+ let results: Array<string> | undefined
76
+ for (const m of messages.slice(start)) {
77
+ if (m.role === "tool") {
78
+ const id = m.toolCallId ?? ""
79
+ const line = toolResultLine(id, toolNames.get(id) ?? "tool", contentText(m.content), m.error)
80
+ if (results) {
81
+ results.push(line)
82
+ out[out.length - 1] = { role: "user", content: toolResultFence(results) }
83
+ } else {
84
+ results = [line]
85
+ out.push({ role: "user", content: toolResultFence(results) })
86
+ }
87
+ continue
88
+ }
89
+ results = undefined
90
+ if (m.role === "assistant") {
91
+ for (const call of m.toolCalls ?? []) toolNames.set(call.id, call.function.name)
92
+ const content = [contentText(m.content), ...(m.toolCalls?.length ? [toolCallFence(m.toolCalls)] : [])]
93
+ .filter(Boolean)
94
+ .join("\n")
95
+ if (content) out.push({ role: "assistant", content })
96
+ } else {
97
+ const content = await userContent(m.content)
98
+ if (content.length > 0) out.push({ role: "user", content })
99
+ }
100
+ }
101
+ return out
102
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The model the Prompt API gives: the browser's own text model ("text", as other Prompt API libraries name it), Gemini
3
+ * Nano in Chrome and Phi-4-mini in Edge, with what it supports.
4
+ */
5
+
6
+ /** What the browser's model supports. */
7
+ export type PromptApiModelMeta = {
8
+ /** The model's name for `promptApiText`: "text". */
9
+ name: string
10
+ supports: {
11
+ input: ReadonlyArray<"text" | "image" | "audio">
12
+ output: ReadonlyArray<"text">
13
+ capabilities: ReadonlyArray<"tools">
14
+ }
15
+ }
16
+
17
+ const BUILT_IN = {
18
+ name: "text",
19
+ supports: {
20
+ input: ["text", "image", "audio"],
21
+ output: ["text"],
22
+ capabilities: ["tools"]
23
+ }
24
+ } as const satisfies PromptApiModelMeta
25
+
26
+ /** The model: whichever one the browser provides. */
27
+ export const PROMPT_API_CHAT_MODELS = [BUILT_IN.name] as const
28
+
29
+ /** The model: whichever one the browser provides. */
30
+ export type PromptApiChatModel = (typeof PROMPT_API_CHAT_MODELS)[number]
31
+
32
+ /** The model's metadata, by name. */
33
+ export const PROMPT_API_MODEL_META: Readonly<Record<PromptApiChatModel, PromptApiModelMeta>> = {
34
+ [BUILT_IN.name]: BUILT_IN
35
+ }