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/client.ts ADDED
@@ -0,0 +1,312 @@
1
+ import {
2
+ AfmError,
3
+ ClientClosedError,
4
+ ModelUnavailableError,
5
+ ProtocolError,
6
+ RequestTimeoutError,
7
+ SessionLostError,
8
+ SidecarCrashedError,
9
+ } from './errors.js'
10
+ import { type RequestOptions, RpcPeer } from './rpc.js'
11
+ import { type DeltaParams, Session, type SessionHost } from './session.js'
12
+ import { runTool, type Tool, toolDefinition } from './tool.js'
13
+ import type { CloseInfo, Connection, Transport } from './transport.js'
14
+ import type { CallOptions, ModelId, ModelStatus, ServerInfo, SessionOptions } from './types.js'
15
+
16
+ /** Protocol version this client speaks. See PROTOCOL.md section 3. */
17
+ export const PROTOCOL_VERSION = 1
18
+ export const CLIENT_VERSION = '0.1.0'
19
+
20
+ export interface AppleFoundationModelsOptions {
21
+ /** How to reach the sidecar, e.g. `new NodeStdioTransport()` from `afm-bridge/node`. */
22
+ transport: Transport
23
+ /** Reported to the sidecar in `initialize` (shows up in its debug log). */
24
+ clientInfo?: { name: string; version: string }
25
+ /**
26
+ * Automatic restart after the sidecar crashes. The process is restarted on the next
27
+ * call; after `maxRestarts` crashes within `windowMs` calls fail with `SidecarCrashedError`
28
+ * until the window passes. Default: 3 restarts per 60 s.
29
+ */
30
+ restart?: { maxRestarts?: number; windowMs?: number }
31
+ /** Timeout for starting the sidecar and the `initialize` handshake. Default 10 s. */
32
+ initializeTimeoutMs?: number
33
+ /** Default client-side timeout for every call. Default: none (use `AbortSignal`). */
34
+ requestTimeoutMs?: number
35
+ /** Receives every stderr line of the sidecar. */
36
+ onLog?: (line: string) => void
37
+ }
38
+
39
+ interface Live {
40
+ epoch: number
41
+ peer: RpcPeer
42
+ connection: Connection
43
+ info: ServerInfo
44
+ slot: Slot
45
+ }
46
+
47
+ /** Per-process state that exists before the handshake finishes. */
48
+ interface Slot {
49
+ peer?: RpcPeer
50
+ intentional: boolean
51
+ exited?: CloseInfo
52
+ listeners: Map<number, (params: DeltaParams) => void>
53
+ /** Tools by session id, then by name. */
54
+ tools: Map<string, Map<string, Tool<unknown>>>
55
+ }
56
+
57
+ const LOG_TAIL = 20
58
+
59
+ /**
60
+ * Entry point of afm-bridge. The sidecar starts lazily on the first call.
61
+ *
62
+ * ```ts
63
+ * const afm = new AppleFoundationModels({ transport: new NodeStdioTransport() })
64
+ * const session = await afm.createSession({ instructions: 'Answer briefly.' })
65
+ * console.log(await session.respond('Hello'))
66
+ * await afm.close()
67
+ * ```
68
+ */
69
+ export class AppleFoundationModels {
70
+ private readonly transport: Transport
71
+ private readonly options: AppleFoundationModelsOptions
72
+ private live: Live | undefined
73
+ private starting: Promise<Live> | undefined
74
+ private epoch = 0
75
+ private crashes: number[] = []
76
+ private closed = false
77
+ private readonly logTail: string[] = []
78
+ private readonly host: SessionHost
79
+
80
+ constructor(options: AppleFoundationModelsOptions) {
81
+ this.transport = options.transport
82
+ this.options = options
83
+ this.host = {
84
+ request: (epoch, method, params, opts) => this.requestInEpoch(epoch, method, params, opts),
85
+ listen: (epoch, requestId, listener) => {
86
+ const slot = this.live?.epoch === epoch ? this.live.slot : undefined
87
+ slot?.listeners.set(requestId, listener)
88
+ return () => slot?.listeners.delete(requestId)
89
+ },
90
+ disposed: (epoch, sessionId) => {
91
+ if (this.live?.epoch === epoch) this.live.slot.tools.delete(sessionId)
92
+ },
93
+ }
94
+ }
95
+
96
+ /** Handshake result: server version, macOS version and status of every model. Starts the sidecar. */
97
+ async serverInfo(): Promise<ServerInfo> {
98
+ return (await this.connect()).info
99
+ }
100
+
101
+ /**
102
+ * Fresh availability of a model. Never throws because the model is unavailable:
103
+ * check `available` and `reason` instead.
104
+ */
105
+ async availability(model: ModelId = 'on-device', options: CallOptions = {}): Promise<ModelStatus> {
106
+ try {
107
+ return await this.request<ModelStatus>('model/status', { model }, options)
108
+ } catch (error) {
109
+ if (error instanceof ModelUnavailableError) {
110
+ return { available: false, reason: error.reason, message: error.message }
111
+ }
112
+ throw error
113
+ }
114
+ }
115
+
116
+ /** Number of tokens `text` occupies in the model's context (macOS 26.4+). */
117
+ async tokenCount(text: string, options: CallOptions & { model?: ModelId } = {}): Promise<number> {
118
+ const { model, ...call } = options
119
+ const result = await this.request<{ tokenCount: number }>(
120
+ 'model/tokenCount',
121
+ { text, ...(model ? { model } : {}) },
122
+ call,
123
+ )
124
+ return result.tokenCount
125
+ }
126
+
127
+ /** Creates a conversation. Throws `ModelUnavailableError` (with `reason`) when the model cannot be used. */
128
+ async createSession(options: SessionOptions = {}): Promise<Session> {
129
+ const live = await this.connect()
130
+ const { signal, tools, ...params } = options
131
+ const result = await this.requestInEpoch<{ sessionId: string; contextSize: number }>(
132
+ live.epoch,
133
+ 'session/create',
134
+ tools?.length ? { ...params, tools: tools.map(toolDefinition) } : params,
135
+ { signal },
136
+ )
137
+ if (tools?.length) live.slot.tools.set(result.sessionId, new Map(tools.map((t) => [t.name, t])))
138
+ return new Session(
139
+ this.host,
140
+ live.epoch,
141
+ result.sessionId,
142
+ result.contextSize,
143
+ options.model ?? 'on-device',
144
+ )
145
+ }
146
+
147
+ /** Stops the sidecar. Pending calls reject with `ClientClosedError`. The client cannot be reused. */
148
+ async close(): Promise<void> {
149
+ if (this.closed) return
150
+ this.closed = true
151
+ const live = this.live ?? (await this.starting?.catch(() => undefined))
152
+ this.live = undefined
153
+ if (!live) return
154
+ live.slot.intentional = true
155
+ try {
156
+ await live.peer.request('shutdown', {}, { timeoutMs: 2000, cancelGraceMs: 0 })
157
+ } catch {
158
+ // The process is killed below anyway.
159
+ }
160
+ live.peer.close(new ClientClosedError('The afm-bridge client was closed'))
161
+ await live.connection.close()
162
+ }
163
+
164
+ // MARK: - Internals
165
+
166
+ private async request<T>(method: string, params: unknown, options: CallOptions = {}): Promise<T> {
167
+ const live = await this.connect()
168
+ return this.requestInEpoch<T>(live.epoch, method, params, options)
169
+ }
170
+
171
+ private requestInEpoch<T>(
172
+ epoch: number,
173
+ method: string,
174
+ params: unknown,
175
+ options: RequestOptions = {},
176
+ ): Promise<T> {
177
+ if (this.closed) return Promise.reject(new ClientClosedError('The afm-bridge client was closed'))
178
+ const live = this.live
179
+ if (!live || live.epoch !== epoch) {
180
+ return Promise.reject(
181
+ new SessionLostError(
182
+ 'This session belonged to an afm-bridge-server process that is no longer running (it crashed or was restarted). Create a new session.',
183
+ { type: 'sessionLost' },
184
+ ),
185
+ )
186
+ }
187
+ return live.peer.request<T>(method, params, {
188
+ ...options,
189
+ timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs,
190
+ })
191
+ }
192
+
193
+ private connect(): Promise<Live> {
194
+ if (this.closed) return Promise.reject(new ClientClosedError('The afm-bridge client was closed'))
195
+ if (this.live) return Promise.resolve(this.live)
196
+ if (!this.starting) {
197
+ this.starting = this.start().finally(() => {
198
+ this.starting = undefined
199
+ })
200
+ }
201
+ return this.starting
202
+ }
203
+
204
+ private async start(): Promise<Live> {
205
+ const { maxRestarts = 3, windowMs = 60_000 } = this.options.restart ?? {}
206
+ const now = Date.now()
207
+ this.crashes = this.crashes.filter((t) => now - t < windowMs)
208
+ if (this.crashes.length > maxRestarts) {
209
+ throw new SidecarCrashedError(
210
+ `afm-bridge-server crashed ${this.crashes.length} times within ${Math.round(windowMs / 1000)} s; not restarting it again yet.${this.formatLogTail()}`,
211
+ { type: 'sidecarCrashed', data: { crashes: this.crashes.length } },
212
+ )
213
+ }
214
+
215
+ const epoch = ++this.epoch
216
+ const slot: Slot = { intentional: false, listeners: new Map(), tools: new Map() }
217
+ const connection = await this.transport.connect({
218
+ onMessage: (line) => slot.peer?.handleLine(line),
219
+ onLog: (line) => {
220
+ this.logTail.push(line)
221
+ if (this.logTail.length > LOG_TAIL) this.logTail.shift()
222
+ this.options.onLog?.(line)
223
+ },
224
+ onClose: (info) => this.handleClose(epoch, slot, info),
225
+ })
226
+
227
+ const peer = new RpcPeer(connection, {
228
+ onNotification: (method, params) => {
229
+ if (method === 'session/delta') {
230
+ const p = params as DeltaParams
231
+ slot.listeners.get(p.requestId)?.(p)
232
+ }
233
+ },
234
+ onRequest: async (method, params, signal) => {
235
+ if (method !== 'tool/call') {
236
+ throw new AfmError(`Method not found: ${method}`, { type: 'methodNotFound', code: -32601 })
237
+ }
238
+ const {
239
+ sessionId,
240
+ name,
241
+ arguments: args,
242
+ } = params as { sessionId: string; name: string; arguments: unknown }
243
+ const found = slot.tools.get(sessionId)?.get(name)
244
+ if (!found) throw new AfmError(`Unknown tool '${name}'`, { type: 'toolNotFound', code: -32000 })
245
+ return runTool(found, args, { signal, sessionId })
246
+ },
247
+ onPendingChange: (count) => connection.setActive?.(count > 0),
248
+ onProtocolError: (error) => this.options.onLog?.(`[afm-bridge client] ${error.message}`),
249
+ })
250
+ slot.peer = peer
251
+ if (slot.exited) peer.close(this.crashError(slot.exited))
252
+
253
+ try {
254
+ const info = await peer.request<ServerInfo>(
255
+ 'initialize',
256
+ {
257
+ protocolVersion: PROTOCOL_VERSION,
258
+ client: this.options.clientInfo ?? { name: 'afm-bridge', version: CLIENT_VERSION },
259
+ },
260
+ { timeoutMs: this.options.initializeTimeoutMs ?? 10_000, cancelGraceMs: 0 },
261
+ )
262
+ if (info.protocolVersion !== PROTOCOL_VERSION) {
263
+ throw new ProtocolError(
264
+ `afm-bridge-server ${info.server.version} speaks protocol version ${info.protocolVersion}, this client needs ${PROTOCOL_VERSION}. Install matching versions of afm-bridge and afm-bridge-server.`,
265
+ { type: 'protocolVersionMismatch' },
266
+ )
267
+ }
268
+ if (this.closed) throw new ClientClosedError('The afm-bridge client was closed')
269
+ const live: Live = { epoch, peer, connection, info, slot }
270
+ this.live = live
271
+ return live
272
+ } catch (error) {
273
+ slot.intentional = true
274
+ peer.close(error instanceof Error ? error : new Error(String(error)))
275
+ await connection.close().catch(() => {})
276
+ if (error instanceof RequestTimeoutError) {
277
+ throw new SidecarCrashedError(
278
+ `afm-bridge-server did not answer the initialize handshake within ${this.options.initializeTimeoutMs ?? 10_000} ms.${this.formatLogTail()}`,
279
+ { type: 'sidecarCrashed', cause: error },
280
+ )
281
+ }
282
+ throw error
283
+ }
284
+ }
285
+
286
+ private handleClose(epoch: number, slot: Slot, info: CloseInfo) {
287
+ slot.exited = info
288
+ if (this.live?.epoch === epoch) this.live = undefined
289
+ if (slot.intentional || this.closed) {
290
+ slot.peer?.close(new ClientClosedError('The afm-bridge client was closed'))
291
+ return
292
+ }
293
+ this.crashes.push(Date.now())
294
+ slot.peer?.close(this.crashError(info))
295
+ }
296
+
297
+ private crashError(info: CloseInfo): SidecarCrashedError {
298
+ const how = info.signal ? `signal ${info.signal}` : `exit code ${info.code}`
299
+ return new SidecarCrashedError(
300
+ `afm-bridge-server stopped unexpectedly (${how}).${this.formatLogTail()}`,
301
+ {
302
+ type: 'sidecarCrashed',
303
+ data: { exitCode: info.code, signal: info.signal },
304
+ cause: info.error,
305
+ },
306
+ )
307
+ }
308
+
309
+ private formatLogTail(): string {
310
+ return this.logTail.length > 0 ? `\nLast log lines:\n${this.logTail.join('\n')}` : ''
311
+ }
312
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { UnavailableReason } from './types.js'
2
+
3
+ export interface RpcErrorPayload {
4
+ code: number
5
+ message: string
6
+ data?: Record<string, unknown> & { type?: string }
7
+ }
8
+
9
+ /** Base class of every error thrown by afm-bridge. `type` matches PROTOCOL.md section 7. */
10
+ export class AfmError extends Error {
11
+ override name = 'AfmError'
12
+ readonly type: string
13
+ readonly code: number | undefined
14
+ readonly data: Record<string, unknown>
15
+
16
+ constructor(
17
+ message: string,
18
+ init: { type?: string; code?: number; data?: Record<string, unknown>; cause?: unknown } = {},
19
+ ) {
20
+ super(message, init.cause === undefined ? undefined : { cause: init.cause })
21
+ this.type = init.type ?? 'generationFailed'
22
+ this.code = init.code
23
+ this.data = init.data ?? {}
24
+ }
25
+ }
26
+
27
+ /** Framing, version or JSON-RPC level problem between client and sidecar. */
28
+ export class ProtocolError extends AfmError {
29
+ override name = 'ProtocolError'
30
+ }
31
+ export class InvalidParamsError extends AfmError {
32
+ override name = 'InvalidParamsError'
33
+ }
34
+ /** The request was cancelled through an `AbortSignal`. Has `name === 'AbortError'` like DOM aborts. */
35
+ export class AbortError extends AfmError {
36
+ override name = 'AbortError'
37
+ constructor(message = 'The operation was aborted', init: ConstructorParameters<typeof AfmError>[1] = {}) {
38
+ super(message, { type: 'cancelled', code: -32800, ...init })
39
+ }
40
+ }
41
+ export class ModelUnavailableError extends AfmError {
42
+ override name = 'ModelUnavailableError'
43
+ get reason(): UnavailableReason {
44
+ return (this.data.reason as UnavailableReason | undefined) ?? 'unknown'
45
+ }
46
+ }
47
+ export class UnsupportedOSError extends AfmError {
48
+ override name = 'UnsupportedOSError'
49
+ }
50
+ export class SessionNotFoundError extends AfmError {
51
+ override name = 'SessionNotFoundError'
52
+ }
53
+ /** Another `respond`/`stream` is already running in this session. */
54
+ export class SessionBusyError extends AfmError {
55
+ override name = 'SessionBusyError'
56
+ }
57
+ export class ContextWindowExceededError extends AfmError {
58
+ override name = 'ContextWindowExceededError'
59
+ get contextSize(): number | undefined {
60
+ return this.data.contextSize as number | undefined
61
+ }
62
+ get tokenCount(): number | undefined {
63
+ return this.data.tokenCount as number | undefined
64
+ }
65
+ }
66
+ export class GuardrailViolationError extends AfmError {
67
+ override name = 'GuardrailViolationError'
68
+ }
69
+ export class RefusalError extends AfmError {
70
+ override name = 'RefusalError'
71
+ }
72
+ export class UnsupportedLanguageError extends AfmError {
73
+ override name = 'UnsupportedLanguageError'
74
+ get languageCode(): string | undefined {
75
+ return this.data.languageCode as string | undefined
76
+ }
77
+ }
78
+ export class UnsupportedCapabilityError extends AfmError {
79
+ override name = 'UnsupportedCapabilityError'
80
+ }
81
+ export class UnsupportedSchemaError extends AfmError {
82
+ override name = 'UnsupportedSchemaError'
83
+ }
84
+ export class DecodingError extends AfmError {
85
+ override name = 'DecodingError'
86
+ }
87
+ export class RateLimitedError extends AfmError {
88
+ override name = 'RateLimitedError'
89
+ }
90
+ export class GenerationTimeoutError extends AfmError {
91
+ override name = 'GenerationTimeoutError'
92
+ }
93
+ export class ToolExecutionError extends AfmError {
94
+ override name = 'ToolExecutionError'
95
+ }
96
+ export class QuotaExceededError extends AfmError {
97
+ override name = 'QuotaExceededError'
98
+ }
99
+ export class NetworkError extends AfmError {
100
+ override name = 'NetworkError'
101
+ }
102
+ export class ServiceUnavailableError extends AfmError {
103
+ override name = 'ServiceUnavailableError'
104
+ }
105
+
106
+ // Client-only errors (never sent over the wire).
107
+
108
+ /** The sidecar process died and could not be (re)started. */
109
+ export class SidecarCrashedError extends AfmError {
110
+ override name = 'SidecarCrashedError'
111
+ }
112
+ /** The session lived in a sidecar process that has since crashed or been closed. Create a new one. */
113
+ export class SessionLostError extends AfmError {
114
+ override name = 'SessionLostError'
115
+ }
116
+ /** Spawning the sidecar failed (binary missing, not executable, ...). */
117
+ export class TransportError extends AfmError {
118
+ override name = 'TransportError'
119
+ }
120
+ /** The client-side `timeoutMs` elapsed. */
121
+ export class RequestTimeoutError extends AfmError {
122
+ override name = 'RequestTimeoutError'
123
+ }
124
+ /** `close()` was called on the client. */
125
+ export class ClientClosedError extends AfmError {
126
+ override name = 'ClientClosedError'
127
+ }
128
+
129
+ type ErrorClass = new (message: string, init: ConstructorParameters<typeof AfmError>[1]) => AfmError
130
+
131
+ const byType: Record<string, ErrorClass> = {
132
+ parseError: ProtocolError,
133
+ invalidRequest: ProtocolError,
134
+ methodNotFound: ProtocolError,
135
+ protocolVersionMismatch: ProtocolError,
136
+ notInitialized: ProtocolError,
137
+ invalidParams: InvalidParamsError,
138
+ cancelled: AbortError,
139
+ modelUnavailable: ModelUnavailableError,
140
+ unsupportedOS: UnsupportedOSError,
141
+ sessionNotFound: SessionNotFoundError,
142
+ sessionBusy: SessionBusyError,
143
+ contextWindowExceeded: ContextWindowExceededError,
144
+ guardrailViolation: GuardrailViolationError,
145
+ refusal: RefusalError,
146
+ unsupportedLanguage: UnsupportedLanguageError,
147
+ unsupportedCapability: UnsupportedCapabilityError,
148
+ unsupportedSchema: UnsupportedSchemaError,
149
+ decodingFailure: DecodingError,
150
+ rateLimited: RateLimitedError,
151
+ timeout: GenerationTimeoutError,
152
+ toolExecution: ToolExecutionError,
153
+ quotaExceeded: QuotaExceededError,
154
+ networkFailure: NetworkError,
155
+ serviceUnavailable: ServiceUnavailableError,
156
+ }
157
+
158
+ /** Builds the typed error for a JSON-RPC error object. Unknown types become a plain `AfmError`. */
159
+ export function errorFromRpc(payload: RpcErrorPayload): AfmError {
160
+ const { type = 'unknown', ...data } = payload.data ?? {}
161
+ if (type === 'assetsUnavailable') {
162
+ return new ModelUnavailableError(payload.message, {
163
+ type,
164
+ code: payload.code,
165
+ data: { ...data, reason: 'modelNotReady' },
166
+ })
167
+ }
168
+ const Class = byType[type] ?? AfmError
169
+ return new Class(payload.message, { type, code: payload.code, data })
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Core entry point: no Node APIs, safe to import in a browser or webview.
2
+ export {
3
+ AppleFoundationModels,
4
+ type AppleFoundationModelsOptions,
5
+ CLIENT_VERSION,
6
+ PROTOCOL_VERSION,
7
+ } from './client.js'
8
+ export * from './errors.js'
9
+ export {
10
+ type DeepPartial,
11
+ isStructuredSchema,
12
+ type JSONSchema,
13
+ jsonSchema,
14
+ type SchemaInput,
15
+ type StructuredSchema,
16
+ validate,
17
+ } from './schema.js'
18
+ export { Session } from './session.js'
19
+ export { type Tool, type ToolContext, tool } from './tool.js'
20
+ export type { CloseInfo, Connection, Transport, TransportHandlers } from './transport.js'
21
+ export { createLineSplitter } from './transport.js'
22
+ export type * from './types.js'