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.
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/client.d.ts +74 -0
- package/dist/client.js +216 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.js +152 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/node/index.d.ts +28 -0
- package/dist/node/index.js +177 -0
- package/dist/node/index.js.map +1 -0
- package/dist/rpc.d.ts +37 -0
- package/dist/rpc.js +172 -0
- package/dist/rpc.js.map +1 -0
- package/dist/schema.d.ts +53 -0
- package/dist/schema.js +149 -0
- package/dist/schema.js.map +1 -0
- package/dist/session.d.ts +63 -0
- package/dist/session.js +202 -0
- package/dist/session.js.map +1 -0
- package/dist/tauri/index.d.ts +29 -0
- package/dist/tauri/index.js +80 -0
- package/dist/tauri/index.js.map +1 -0
- package/dist/tool.d.ts +39 -0
- package/dist/tool.js +48 -0
- package/dist/tool.js.map +1 -0
- package/dist/transport.d.ts +37 -0
- package/dist/transport.js +24 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zod/index.d.ts +21 -0
- package/dist/zod/index.js +44 -0
- package/dist/zod/index.js.map +1 -0
- package/package.json +79 -0
- package/src/client.ts +312 -0
- package/src/errors.ts +170 -0
- package/src/index.ts +22 -0
- package/src/node/index.ts +203 -0
- package/src/rpc.ts +204 -0
- package/src/schema.ts +206 -0
- package/src/session.ts +290 -0
- package/src/tauri/index.ts +100 -0
- package/src/tool.ts +80 -0
- package/src/transport.ts +60 -0
- package/src/types.ts +142 -0
- 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
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|