afm-bridge 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/dist/client.d.ts +74 -0
  4. package/dist/client.js +216 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/errors.d.ts +111 -0
  7. package/dist/errors.js +152 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.js +8 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/node/index.d.ts +28 -0
  13. package/dist/node/index.js +177 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/dist/rpc.d.ts +37 -0
  16. package/dist/rpc.js +172 -0
  17. package/dist/rpc.js.map +1 -0
  18. package/dist/schema.d.ts +53 -0
  19. package/dist/schema.js +149 -0
  20. package/dist/schema.js.map +1 -0
  21. package/dist/session.d.ts +63 -0
  22. package/dist/session.js +202 -0
  23. package/dist/session.js.map +1 -0
  24. package/dist/tauri/index.d.ts +29 -0
  25. package/dist/tauri/index.js +80 -0
  26. package/dist/tauri/index.js.map +1 -0
  27. package/dist/tool.d.ts +39 -0
  28. package/dist/tool.js +48 -0
  29. package/dist/tool.js.map +1 -0
  30. package/dist/transport.d.ts +37 -0
  31. package/dist/transport.js +24 -0
  32. package/dist/transport.js.map +1 -0
  33. package/dist/types.d.ts +152 -0
  34. package/dist/types.js +2 -0
  35. package/dist/types.js.map +1 -0
  36. package/dist/zod/index.d.ts +21 -0
  37. package/dist/zod/index.js +44 -0
  38. package/dist/zod/index.js.map +1 -0
  39. package/package.json +79 -0
  40. package/src/client.ts +312 -0
  41. package/src/errors.ts +170 -0
  42. package/src/index.ts +22 -0
  43. package/src/node/index.ts +203 -0
  44. package/src/rpc.ts +204 -0
  45. package/src/schema.ts +206 -0
  46. package/src/session.ts +290 -0
  47. package/src/tauri/index.ts +100 -0
  48. package/src/tool.ts +80 -0
  49. package/src/transport.ts +60 -0
  50. package/src/types.ts +142 -0
  51. package/src/zod/index.ts +53 -0
package/src/session.ts ADDED
@@ -0,0 +1,290 @@
1
+ import { AbortError, AfmError, ClientClosedError, SessionLostError, SessionNotFoundError } from './errors.js'
2
+ import type { RequestOptions } from './rpc.js'
3
+ import { type SchemaInput, type StructuredSchema, toStructuredSchema } from './schema.js'
4
+ import type {
5
+ GenerateResult,
6
+ GenerationOptions,
7
+ ModelId,
8
+ Prompt,
9
+ RespondOptions,
10
+ SessionInfo,
11
+ StreamChunk,
12
+ StructuredOptions,
13
+ StructuredResult,
14
+ StructuredStreamChunk,
15
+ Usage,
16
+ } from './types.js'
17
+
18
+ /** What a session needs from the client. Internal. */
19
+ export interface SessionHost {
20
+ request<T>(epoch: number, method: string, params: unknown, options?: RequestOptions): Promise<T>
21
+ listen(epoch: number, requestId: number, listener: (params: DeltaParams) => void): () => void
22
+ disposed(epoch: number, sessionId: string): void
23
+ }
24
+
25
+ export interface DeltaParams {
26
+ requestId: number
27
+ delta?: string
28
+ reset?: boolean
29
+ text?: string
30
+ partial?: unknown
31
+ }
32
+
33
+ interface RespondResult {
34
+ text: string
35
+ content?: unknown
36
+ usage?: Usage
37
+ }
38
+
39
+ type AnyOptions = RespondOptions & { schema?: SchemaInput<unknown> }
40
+
41
+ /**
42
+ * A conversation with the model. The sidecar keeps its history, so every call sees
43
+ * the previous prompts and responses. Only one `respond`/`stream` may run at a time.
44
+ */
45
+ export class Session {
46
+ private disposed = false
47
+
48
+ /** @internal Use `AppleFoundationModels.createSession()`. */
49
+ constructor(
50
+ private readonly host: SessionHost,
51
+ private readonly epoch: number,
52
+ readonly id: string,
53
+ /** Context window in tokens. */
54
+ readonly contextSize: number,
55
+ readonly model: ModelId,
56
+ ) {}
57
+
58
+ /**
59
+ * Generates a complete response. Returns the text, or with `schema` the parsed and
60
+ * validated value.
61
+ */
62
+ respond(prompt: Prompt, options?: RespondOptions & { schema?: undefined }): Promise<string>
63
+ respond<T>(prompt: Prompt, options: StructuredOptions<T>): Promise<T>
64
+ async respond(prompt: Prompt, options: AnyOptions = {}): Promise<unknown> {
65
+ const result = await this.generate(prompt, options as StructuredOptions<unknown>)
66
+ return options.schema ? (result as StructuredResult<unknown>).content : result.text
67
+ }
68
+
69
+ /** Like `respond`, but also returns token usage (macOS 27+). */
70
+ generate(prompt: Prompt, options?: RespondOptions & { schema?: undefined }): Promise<GenerateResult>
71
+ generate<T>(prompt: Prompt, options: StructuredOptions<T>): Promise<StructuredResult<T>>
72
+ async generate(
73
+ prompt: Prompt,
74
+ options: AnyOptions = {},
75
+ ): Promise<GenerateResult | StructuredResult<unknown>> {
76
+ const { signal, timeoutMs } = options
77
+ const schema = options.schema ? toStructuredSchema(options.schema) : undefined
78
+ const result = await this.call<RespondResult>(
79
+ 'session/respond',
80
+ this.respondParams(prompt, options, schema),
81
+ {
82
+ signal,
83
+ timeoutMs,
84
+ },
85
+ )
86
+ if (!schema) return withUsage({ text: result.text }, result.usage)
87
+ return withUsage({ content: parseContent(schema, result), text: result.text }, result.usage)
88
+ }
89
+
90
+ /**
91
+ * Streams the response. Text: each chunk carries the new `delta` and the full `text`
92
+ * so far. With `schema`: each chunk carries the `partial` value so far. The last chunk
93
+ * has `done: true` with the complete result. Breaking out of the loop cancels the generation.
94
+ */
95
+ stream(
96
+ prompt: Prompt,
97
+ options?: RespondOptions & { schema?: undefined },
98
+ ): AsyncGenerator<StreamChunk, void, undefined>
99
+ stream<T>(
100
+ prompt: Prompt,
101
+ options: StructuredOptions<T>,
102
+ ): AsyncGenerator<StructuredStreamChunk<T>, void, undefined>
103
+ async *stream(
104
+ prompt: Prompt,
105
+ options: AnyOptions = {},
106
+ ): AsyncGenerator<StreamChunk | StructuredStreamChunk<unknown>, void, undefined> {
107
+ const { signal, timeoutMs } = options
108
+ if (signal?.aborted) throw new AbortError()
109
+ this.assertUsable()
110
+ const schema = options.schema ? toStructuredSchema(options.schema) : undefined
111
+
112
+ const controller = new AbortController()
113
+ const onAbort = () => controller.abort()
114
+ signal?.addEventListener('abort', onAbort, { once: true })
115
+
116
+ const queue = new ChunkQueue()
117
+ let text = ''
118
+ let unsubscribe = () => {}
119
+ const request = this.host.request<RespondResult>(
120
+ this.epoch,
121
+ 'session/respond',
122
+ { ...this.respondParams(prompt, options, schema), stream: true },
123
+ {
124
+ signal: controller.signal,
125
+ timeoutMs,
126
+ onStart: (id) => {
127
+ unsubscribe = this.host.listen(this.epoch, id, (p) => {
128
+ if (schema) {
129
+ if (p.partial !== undefined) queue.push({ done: false, partial: p.partial })
130
+ } else if (p.reset) {
131
+ text = p.text ?? ''
132
+ queue.push({ done: false, delta: '', text, reset: true })
133
+ } else if (p.delta) {
134
+ text += p.delta
135
+ queue.push({ done: false, delta: p.delta, text })
136
+ }
137
+ })
138
+ },
139
+ },
140
+ )
141
+ request.then(
142
+ (result) => queue.end(result),
143
+ (error: unknown) => queue.fail(error),
144
+ )
145
+
146
+ let finished = false
147
+ try {
148
+ while (true) {
149
+ const item = await queue.next()
150
+ if (item.kind === 'chunk') {
151
+ yield item.chunk
152
+ continue
153
+ }
154
+ finished = true
155
+ const { result } = item
156
+ yield schema
157
+ ? withUsage(
158
+ { done: true as const, content: parseContent(schema, result), text: result.text },
159
+ result.usage,
160
+ )
161
+ : withUsage({ done: true as const, delta: '' as const, text: result.text }, result.usage)
162
+ return
163
+ }
164
+ } finally {
165
+ unsubscribe()
166
+ signal?.removeEventListener('abort', onAbort)
167
+ if (!finished) {
168
+ // The consumer stopped early or an error was thrown: make sure the
169
+ // generation is cancelled and the session is free before we return.
170
+ controller.abort()
171
+ await request.catch(() => {})
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Loads model resources ahead of time to reduce the latency of the first response. */
177
+ async prewarm(promptPrefix?: string): Promise<void> {
178
+ await this.call('session/prewarm', { sessionId: this.id, ...(promptPrefix ? { promptPrefix } : {}) })
179
+ }
180
+
181
+ /** Context usage of the session. */
182
+ async info(): Promise<SessionInfo> {
183
+ return this.call<SessionInfo>('session/info', { sessionId: this.id })
184
+ }
185
+
186
+ /** Frees the session in the sidecar and cancels a running generation. Safe to call twice. */
187
+ async dispose(): Promise<void> {
188
+ if (this.disposed) return
189
+ this.disposed = true
190
+ this.host.disposed(this.epoch, this.id)
191
+ try {
192
+ await this.host.request(this.epoch, 'session/dispose', { sessionId: this.id })
193
+ } catch (error) {
194
+ // Nothing to free when the process is already gone.
195
+ if (error instanceof SessionLostError || error instanceof ClientClosedError) return
196
+ throw error
197
+ }
198
+ }
199
+
200
+ private call<T>(method: string, params: unknown, options?: RequestOptions): Promise<T> {
201
+ try {
202
+ this.assertUsable()
203
+ } catch (error) {
204
+ return Promise.reject(error)
205
+ }
206
+ return this.host.request<T>(this.epoch, method, params, options)
207
+ }
208
+
209
+ private assertUsable() {
210
+ if (this.disposed) {
211
+ throw new SessionNotFoundError(`Session ${this.id} was disposed`, {
212
+ type: 'sessionNotFound',
213
+ data: { sessionId: this.id },
214
+ })
215
+ }
216
+ }
217
+
218
+ private respondParams(prompt: Prompt, options: AnyOptions, schema: StructuredSchema<unknown> | undefined) {
219
+ const { temperature, maximumResponseTokens, sampling, toolCalling, reasoningLevel } = options
220
+ const generation: GenerationOptions = {
221
+ temperature,
222
+ maximumResponseTokens,
223
+ sampling,
224
+ toolCalling,
225
+ reasoningLevel,
226
+ }
227
+ const defined = Object.fromEntries(Object.entries(generation).filter(([, v]) => v !== undefined))
228
+ return {
229
+ sessionId: this.id,
230
+ prompt,
231
+ ...(Object.keys(defined).length > 0 ? { options: defined } : {}),
232
+ ...(schema ? { schema: schema.jsonSchema } : {}),
233
+ ...(schema && options.includeSchemaInPrompt !== undefined
234
+ ? { includeSchemaInPrompt: options.includeSchemaInPrompt }
235
+ : {}),
236
+ }
237
+ }
238
+ }
239
+
240
+ function parseContent(schema: StructuredSchema<unknown>, result: RespondResult): unknown {
241
+ return schema.parse(result.content !== undefined ? result.content : JSON.parse(result.text))
242
+ }
243
+
244
+ function withUsage<T extends object>(value: T, usage: Usage | undefined): T & { usage?: Usage } {
245
+ return usage ? { ...value, usage } : value
246
+ }
247
+
248
+ type QueueItem =
249
+ | { kind: 'chunk'; chunk: StreamChunk | StructuredStreamChunk<unknown> }
250
+ | { kind: 'end'; result: RespondResult }
251
+
252
+ /** Single-consumer queue that delivers buffered chunks before an error. */
253
+ class ChunkQueue {
254
+ private readonly items: QueueItem[] = []
255
+ private error: { value: unknown } | undefined
256
+ private waiter: (() => void) | undefined
257
+
258
+ push(chunk: StreamChunk | StructuredStreamChunk<unknown>) {
259
+ this.items.push({ kind: 'chunk', chunk })
260
+ this.wake()
261
+ }
262
+
263
+ end(result: RespondResult) {
264
+ this.items.push({ kind: 'end', result })
265
+ this.wake()
266
+ }
267
+
268
+ fail(error: unknown) {
269
+ this.error = { value: error }
270
+ this.wake()
271
+ }
272
+
273
+ async next(): Promise<QueueItem> {
274
+ while (true) {
275
+ const item = this.items.shift()
276
+ if (item) return item
277
+ if (this.error)
278
+ throw this.error.value instanceof Error ? this.error.value : new AfmError(String(this.error.value))
279
+ await new Promise<void>((resolve) => {
280
+ this.waiter = resolve
281
+ })
282
+ }
283
+ }
284
+
285
+ private wake() {
286
+ const waiter = this.waiter
287
+ this.waiter = undefined
288
+ waiter?.()
289
+ }
290
+ }
@@ -0,0 +1,100 @@
1
+ import { type Child, Command } from '@tauri-apps/plugin-shell'
2
+ import { TransportError } from '../errors.js'
3
+ import type { Connection, Transport, TransportHandlers } from '../transport.js'
4
+
5
+ export interface TauriSidecarTransportOptions {
6
+ /**
7
+ * Sidecar name as listed in `tauri.conf.json > bundle > externalBin` (without the
8
+ * target-triple suffix). Default `binaries/afm-bridge-server`.
9
+ */
10
+ program?: string
11
+ args?: string[]
12
+ /** Extra environment variables for the sidecar. */
13
+ env?: Record<string, string>
14
+ /** Sidecar log level (stderr). Default `warn`. */
15
+ logLevel?: 'error' | 'warn' | 'info' | 'debug'
16
+ /** How long `close()` waits for the sidecar to exit after `shutdown` before killing it. Default 2000 ms. */
17
+ shutdownTimeoutMs?: number
18
+ /**
19
+ * Kill the sidecar when the page goes away (reload, dev-server HMR, navigation).
20
+ * The Rust side keeps the process alive across reloads otherwise. Default true.
21
+ */
22
+ killOnPageHide?: boolean
23
+ }
24
+
25
+ /**
26
+ * Runs the sidecar through Tauri's shell plugin, straight from the webview.
27
+ * Requires `@tauri-apps/plugin-shell` and the capabilities described in the README.
28
+ */
29
+ export class TauriSidecarTransport implements Transport {
30
+ constructor(private readonly options: TauriSidecarTransportOptions = {}) {}
31
+
32
+ async connect(handlers: TransportHandlers): Promise<Connection> {
33
+ const program = this.options.program ?? 'binaries/afm-bridge-server'
34
+ const command = Command.sidecar(program, this.options.args ?? [], {
35
+ env: { AFM_BRIDGE_LOG: this.options.logLevel ?? 'warn', ...this.options.env },
36
+ })
37
+
38
+ let exited = false
39
+ let resolveExit = () => {}
40
+ const exit = new Promise<void>((resolve) => {
41
+ resolveExit = resolve
42
+ })
43
+ command.on('close', ({ code, signal }) => {
44
+ exited = true
45
+ removePageHide()
46
+ handlers.onClose({ code, signal: signal === null ? null : String(signal) })
47
+ resolveExit()
48
+ })
49
+ command.on('error', (error) => handlers.onLog?.(`[tauri shell] ${error}`))
50
+ // The shell plugin emits one event per line; tolerate a trailing newline either way.
51
+ command.stdout.on('data', (data) => forEachLine(data, handlers.onMessage))
52
+ command.stderr.on('data', (data) => forEachLine(data, (line) => handlers.onLog?.(line)))
53
+
54
+ let child: Child
55
+ try {
56
+ child = await command.spawn()
57
+ } catch (error) {
58
+ throw new TransportError(
59
+ `Could not start sidecar '${program}': ${error}. Check bundle.externalBin in tauri.conf.json, the binary name suffix (-aarch64-apple-darwin) and the shell:allow-spawn capability.`,
60
+ { type: 'transportError', cause: error },
61
+ )
62
+ }
63
+
64
+ const onPageHide = () => {
65
+ if (!exited) void child.kill()
66
+ }
67
+ const removePageHide = () => {
68
+ if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide)
69
+ }
70
+ if ((this.options.killOnPageHide ?? true) && typeof window !== 'undefined') {
71
+ window.addEventListener('pagehide', onPageHide)
72
+ }
73
+
74
+ const shutdownTimeoutMs = this.options.shutdownTimeoutMs ?? 2000
75
+ return {
76
+ send(line) {
77
+ if (exited) return
78
+ child
79
+ .write(`${line}\n`)
80
+ .catch((error: unknown) => handlers.onLog?.(`[tauri shell] write failed: ${error}`))
81
+ },
82
+ async close() {
83
+ removePageHide()
84
+ if (exited) return
85
+ // The client sends `shutdown` first, so the sidecar is normally already exiting.
86
+ const timedOut = await Promise.race([
87
+ exit.then(() => false),
88
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(true), shutdownTimeoutMs)),
89
+ ])
90
+ if (timedOut && !exited) await child.kill()
91
+ },
92
+ }
93
+ }
94
+ }
95
+
96
+ function forEachLine(data: string, onLine: (line: string) => void) {
97
+ for (const line of data.split(/\r?\n/)) {
98
+ if (line.length > 0) onLine(line)
99
+ }
100
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { AfmError } from './errors.js'
2
+ import { type SchemaInput, toStructuredSchema } from './schema.js'
3
+
4
+ export interface ToolContext {
5
+ /** Aborted when the generation is cancelled or the tool times out. */
6
+ signal: AbortSignal
7
+ sessionId: string
8
+ }
9
+
10
+ /**
11
+ * A function the model may call. Arguments are generated by the model to match
12
+ * `parameters`, validated, and passed to `execute`. The return value goes back to the
13
+ * model: strings as they are, anything else as JSON.
14
+ */
15
+ export interface Tool<Args = unknown> {
16
+ /** `^[a-zA-Z_][a-zA-Z0-9_]{0,63}$` */
17
+ name: string
18
+ /** Tells the model when to use the tool. */
19
+ description: string
20
+ /** Object schema of the arguments (JSON Schema or `zodSchema()`). */
21
+ parameters: SchemaInput<Args>
22
+ includeSchemaInInstructions?: boolean
23
+ /** Default 60 000 ms. */
24
+ timeoutMs?: number
25
+ execute(args: Args, context: ToolContext): unknown
26
+ }
27
+
28
+ /** Identity helper that infers the argument type from `parameters`. */
29
+ export function tool<Args>(definition: Tool<Args>): Tool<Args> {
30
+ return definition
31
+ }
32
+
33
+ /** Wire format for `session/create`. */
34
+ export function toolDefinition(t: Tool<unknown>) {
35
+ return {
36
+ name: t.name,
37
+ description: t.description,
38
+ parameters: toStructuredSchema(t.parameters).jsonSchema,
39
+ ...(t.includeSchemaInInstructions === undefined
40
+ ? {}
41
+ : { includeSchemaInInstructions: t.includeSchemaInInstructions }),
42
+ }
43
+ }
44
+
45
+ const DEFAULT_TOOL_TIMEOUT_MS = 60_000
46
+
47
+ /** Runs a tool for a `tool/call` request. Errors become the error reply to the sidecar. */
48
+ export async function runTool(
49
+ t: Tool<unknown>,
50
+ rawArguments: unknown,
51
+ context: { signal: AbortSignal; sessionId: string },
52
+ ): Promise<{ output: string }> {
53
+ const args = toStructuredSchema(t.parameters).parse(rawArguments)
54
+ const timeoutMs = t.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS
55
+ const timeout = AbortSignal.timeout(timeoutMs)
56
+ const signal = AbortSignal.any([context.signal, timeout])
57
+
58
+ // Settle as soon as the signal aborts, even if `execute` ignores it.
59
+ let onAbort = () => {}
60
+ const aborted = new Promise<never>((_, reject) => {
61
+ onAbort = () =>
62
+ reject(
63
+ timeout.aborted
64
+ ? new AfmError(`Tool '${t.name}' timed out after ${timeoutMs} ms`, { type: 'toolTimeout' })
65
+ : new AfmError(`Tool '${t.name}' was cancelled`, { type: 'cancelled' }),
66
+ )
67
+ if (signal.aborted) onAbort()
68
+ else signal.addEventListener('abort', onAbort, { once: true })
69
+ })
70
+ try {
71
+ const result = await Promise.race([
72
+ Promise.resolve().then(() => t.execute(args, { signal, sessionId: context.sessionId })),
73
+ aborted,
74
+ ])
75
+ return { output: typeof result === 'string' ? result : JSON.stringify(result ?? null) }
76
+ } finally {
77
+ signal.removeEventListener('abort', onAbort)
78
+ aborted.catch(() => {})
79
+ }
80
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A transport starts the sidecar and moves NDJSON lines in both directions.
3
+ * Implement it to run the sidecar somewhere else (for example behind Electron IPC).
4
+ */
5
+ export interface Transport {
6
+ /** Starts a new sidecar process. Called again after a crash to restart it. */
7
+ connect(handlers: TransportHandlers): Promise<Connection>
8
+ }
9
+
10
+ export interface TransportHandlers {
11
+ /** One complete protocol line from the sidecar's stdout, without the trailing newline. */
12
+ onMessage(line: string): void
13
+ /** The connection ended (process exited). Called exactly once. */
14
+ onClose(info: CloseInfo): void
15
+ /** One line of the sidecar's stderr log. */
16
+ onLog?(line: string): void
17
+ }
18
+
19
+ export interface CloseInfo {
20
+ code: number | null
21
+ signal: string | null
22
+ error?: Error
23
+ }
24
+
25
+ export interface Connection {
26
+ /** Sends one protocol line. The transport appends the newline. */
27
+ send(line: string): void
28
+ /** Ends the connection gracefully (close stdin, wait, then kill). Resolves when the process is gone. */
29
+ close(): Promise<void>
30
+ /**
31
+ * Hint from the client: `true` while requests are in flight. Node transports use it to
32
+ * keep the event loop alive only while needed, so an idle sidecar never blocks process exit.
33
+ */
34
+ setActive?(active: boolean): void
35
+ }
36
+
37
+ /** Splits a stream of text chunks into lines. Handles `\n` and `\r\n`. */
38
+ export function createLineSplitter(onLine: (line: string) => void): {
39
+ push(chunk: string): void
40
+ flush(): void
41
+ } {
42
+ let buffer = ''
43
+ return {
44
+ push(chunk) {
45
+ buffer += chunk
46
+ let index = buffer.indexOf('\n')
47
+ while (index !== -1) {
48
+ const line = buffer.slice(0, index).replace(/\r$/, '')
49
+ buffer = buffer.slice(index + 1)
50
+ if (line.length > 0) onLine(line)
51
+ index = buffer.indexOf('\n')
52
+ }
53
+ },
54
+ flush() {
55
+ const rest = buffer.replace(/\r$/, '')
56
+ buffer = ''
57
+ if (rest.length > 0) onLine(rest)
58
+ },
59
+ }
60
+ }
package/src/types.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { DeepPartial, SchemaInput } from './schema.js'
2
+ import type { Tool } from './tool.js'
3
+
4
+ /** Which model to use. `private-cloud` is reserved; see PROTOCOL.md section 10. */
5
+ export type ModelId = 'on-device' | 'private-cloud'
6
+
7
+ export type UnavailableReason =
8
+ /** The Mac cannot run Apple Intelligence (Apple silicon required). */
9
+ | 'deviceNotEligible'
10
+ /** Apple Intelligence is switched off in System Settings. */
11
+ | 'appleIntelligenceNotEnabled'
12
+ /** Model assets are still downloading or being prepared. Try again later. */
13
+ | 'modelNotReady'
14
+ /** Private Cloud Compute: the system is not ready yet. */
15
+ | 'systemNotReady'
16
+ /** macOS is too old (afm-bridge needs macOS 26 or newer). */
17
+ | 'unsupportedOS'
18
+ /** Not macOS on Apple silicon (detected by the client, the sidecar never starts). */
19
+ | 'unsupportedPlatform'
20
+ /** The model needs an entitlement afm-bridge does not have. */
21
+ | 'requiresEntitlement'
22
+ | 'unknown'
23
+
24
+ export type Capability = 'vision' | 'guidedGeneration' | 'reasoning' | 'toolCalling'
25
+
26
+ export type ModelStatus =
27
+ | {
28
+ available: true
29
+ /** Context window in tokens (instructions + history + prompt + response). */
30
+ contextSize: number
31
+ /** Model variant name, macOS 27+. */
32
+ variant?: string
33
+ /** Empty on macOS 26, which does not report capabilities. */
34
+ capabilities: Capability[]
35
+ /** BCP 47 codes, for example `en`, `en-GB`, `de`. */
36
+ supportedLanguages: string[]
37
+ }
38
+ | { available: false; reason: UnavailableReason; message: string }
39
+
40
+ export type Sampling =
41
+ | { mode: 'greedy' }
42
+ | { mode: 'topK'; k: number; seed?: number }
43
+ | { mode: 'topP'; threshold: number; seed?: number }
44
+
45
+ export interface GenerationOptions {
46
+ temperature?: number
47
+ maximumResponseTokens?: number
48
+ sampling?: Sampling
49
+ /** macOS 27+. */
50
+ toolCalling?: 'allowed' | 'required' | 'disallowed'
51
+ /** macOS 27+, only for models with the `reasoning` capability. */
52
+ reasoningLevel?: 'light' | 'moderate' | 'deep'
53
+ }
54
+
55
+ export type PromptPart =
56
+ | { type: 'text'; text: string }
57
+ | { type: 'image'; path: string }
58
+ | { type: 'image'; data: string; mimeType?: string }
59
+
60
+ export type Prompt = string | PromptPart[]
61
+
62
+ export interface Usage {
63
+ inputTokens: number
64
+ cachedInputTokens: number
65
+ outputTokens: number
66
+ reasoningTokens: number
67
+ }
68
+
69
+ export interface ServerInfo {
70
+ protocolVersion: number
71
+ server: { name: string; version: string }
72
+ os: { version: string }
73
+ models: Record<ModelId, ModelStatus>
74
+ }
75
+
76
+ export interface SessionOptions {
77
+ model?: ModelId
78
+ /** System instructions that apply to the whole conversation. */
79
+ instructions?: string
80
+ guardrails?: 'default' | 'permissiveContentTransformations'
81
+ useCase?: 'general' | 'contentTagging'
82
+ /** Functions the model may call while responding. */
83
+ // biome-ignore lint/suspicious/noExplicitAny: tools with different argument types
84
+ tools?: Array<Tool<any>>
85
+ signal?: AbortSignal
86
+ }
87
+
88
+ export interface CallOptions {
89
+ /** Cancels the request. The promise rejects with `AbortError`. */
90
+ signal?: AbortSignal
91
+ /** Client-side timeout; rejects with `RequestTimeoutError` and cancels the request. */
92
+ timeoutMs?: number
93
+ }
94
+
95
+ export type RespondOptions = GenerationOptions &
96
+ CallOptions & {
97
+ /** Whether the schema is also described in the prompt (default true). Only with `schema`. */
98
+ includeSchemaInPrompt?: boolean
99
+ }
100
+
101
+ /** Options that request structured output: the response is parsed and validated as `T`. */
102
+ export type StructuredOptions<T> = RespondOptions & { schema: SchemaInput<T> }
103
+
104
+ export interface StructuredResult<T> {
105
+ /** Parsed and validated value. */
106
+ content: T
107
+ /** The same value as JSON text. */
108
+ text: string
109
+ usage?: Usage
110
+ }
111
+
112
+ export type StructuredStreamChunk<T> =
113
+ | { done: false; partial: DeepPartial<T> }
114
+ | { done: true; content: T; text: string; usage?: Usage }
115
+
116
+ export interface GenerateResult {
117
+ text: string
118
+ /** Tokens used by this request (macOS 27+). */
119
+ usage?: Usage
120
+ }
121
+
122
+ export type StreamChunk =
123
+ | {
124
+ done: false
125
+ /** Text appended since the previous chunk. Empty when `reset` is true. */
126
+ delta: string
127
+ /** Full text so far. */
128
+ text: string
129
+ /** The model rewrote earlier output; replace what you showed with `text`. */
130
+ reset?: boolean
131
+ }
132
+ | { done: true; delta: ''; text: string; usage?: Usage }
133
+
134
+ export interface SessionInfo {
135
+ model: ModelId
136
+ contextSize: number
137
+ isResponding: boolean
138
+ /** Tokens currently occupied by the conversation history (macOS 26.4+). */
139
+ historyTokenCount?: number
140
+ /** Cumulative usage of the session (macOS 27+). */
141
+ usage?: Usage
142
+ }