flashlite 0.0.0-stage → 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/dist/client.js ADDED
@@ -0,0 +1,463 @@
1
+ // RouterClient — HTTP against any OpenAI- or Anthropic-compatible chat endpoint:
2
+ // envelope parsing, per-status hints, and digit-only Retry-After handling.
3
+ import { AnthropicStreamParser, parseAnthropicCompleteBody, toAnthropicBody } from "./adapters/anthropic.js";
4
+ import { OpenAiStreamParser, parseCompleteBody, toWireBody } from "./adapters/openai.js";
5
+ import { sseFrames } from "./sse.js";
6
+ export const ANTHROPIC_VERSION = '2023-06-01';
7
+ const MAX_ERROR_TEXT = 1000;
8
+ export class RouterError extends Error {
9
+ status;
10
+ type;
11
+ code;
12
+ param;
13
+ requestId;
14
+ retryAfterSeconds;
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'RouterError';
18
+ }
19
+ }
20
+ export function statusHint(status) {
21
+ switch (status) {
22
+ case 400:
23
+ return 'Check the request and model ID (run /model for the live catalog).';
24
+ case 401:
25
+ return 'The API key is missing or invalid — update it with /settings or edit ~/.config/flashlite/config.json.';
26
+ case 403:
27
+ return 'This model is not permitted for this API key.';
28
+ case 429:
29
+ return 'Rate limit reached; try again later.';
30
+ case 502:
31
+ return 'The provider is disconnected or its credentials need renewal.';
32
+ default:
33
+ return 'The endpoint could not complete the request.';
34
+ }
35
+ }
36
+ function bounded(value) {
37
+ const trimmed = value.trim();
38
+ return trimmed.length > MAX_ERROR_TEXT ? trimmed.slice(0, MAX_ERROR_TEXT) + '…' : trimmed;
39
+ }
40
+ /** Display copy for any failure: bare fetch network errors get actionable text. */
41
+ export function errorMessage(err) {
42
+ if (err instanceof TypeError) {
43
+ // undici wraps the real socket/DNS failure in `cause`; surface it without leaking anything but the host.
44
+ const cause = err.cause;
45
+ const host = typeof cause?.hostname === 'string' ? cause.hostname : 'the endpoint host';
46
+ switch (cause?.code) {
47
+ case 'ENOTFOUND':
48
+ case 'EAI_AGAIN':
49
+ return `Network error — could not resolve ${host} (DNS lookup failed). Check the base URL, your DNS and any VPN or filter.`;
50
+ case 'ECONNREFUSED':
51
+ return 'Network error — the endpoint refused the connection. Check the base URL and port.';
52
+ case 'ETIMEDOUT':
53
+ case 'UND_ERR_CONNECT_TIMEOUT':
54
+ return 'Network error — timed out connecting to the endpoint.';
55
+ }
56
+ return 'Network error — could not reach the endpoint.';
57
+ }
58
+ return err instanceof Error ? err.message : String(err);
59
+ }
60
+ function scalarString(value) {
61
+ if (typeof value === 'string')
62
+ return bounded(value);
63
+ if (typeof value === 'number' || typeof value === 'boolean')
64
+ return String(value);
65
+ return undefined;
66
+ }
67
+ function safeJson(text) {
68
+ try {
69
+ return JSON.parse(text);
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ /** Race an operation against cancellation, without retaining a signal listener. */
76
+ async function abortable(operation, signal) {
77
+ if (!signal)
78
+ return await operation;
79
+ if (signal.aborted)
80
+ return await Promise.race([operation, Promise.reject(signal.reason)]);
81
+ let onAbort = () => { };
82
+ const cancelled = new Promise((_, reject) => {
83
+ onAbort = () => reject(signal.reason);
84
+ signal.addEventListener('abort', onAbort, { once: true });
85
+ });
86
+ try {
87
+ return await Promise.race([operation, cancelled]);
88
+ }
89
+ finally {
90
+ signal.removeEventListener('abort', onAbort);
91
+ }
92
+ }
93
+ /** Read at most cap bytes, not an unbounded response.text() followed by slicing. */
94
+ async function readBody(response, signal, cap = Infinity) {
95
+ if (!response.body)
96
+ return '';
97
+ const reader = response.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let text = '';
100
+ let remaining = cap;
101
+ let finished = false;
102
+ try {
103
+ while (remaining > 0) {
104
+ signal?.throwIfAborted();
105
+ const { value, done } = await abortable(reader.read(), signal);
106
+ if (done) {
107
+ finished = true;
108
+ break;
109
+ }
110
+ const bytes = value.subarray(0, remaining);
111
+ text += decoder.decode(bytes, { stream: true });
112
+ remaining -= bytes.byteLength;
113
+ }
114
+ return text + decoder.decode();
115
+ }
116
+ finally {
117
+ // Do not let slow/rejecting upstream cancellation replace the result/error.
118
+ if (!finished)
119
+ void reader.cancel().catch(() => { });
120
+ reader.releaseLock();
121
+ }
122
+ }
123
+ async function drainBody(response, cap = 4096, signal) {
124
+ try {
125
+ return await readBody(response, signal, cap);
126
+ }
127
+ catch {
128
+ return '';
129
+ }
130
+ }
131
+ /** A fresh deadline per attempt, retained until the response body is consumed. */
132
+ async function withResponse(url, init, timeoutMs, caller, consume) {
133
+ caller?.throwIfAborted();
134
+ const controller = new AbortController();
135
+ const onAbort = () => controller.abort(caller?.reason);
136
+ caller?.addEventListener('abort', onAbort, { once: true });
137
+ const timer = setTimeout(() => controller.abort(new DOMException('The request timed out.', 'TimeoutError')), timeoutMs);
138
+ timer.unref();
139
+ let response;
140
+ try {
141
+ const fetching = fetch(url, { ...init, signal: controller.signal });
142
+ // Synthetic fetches (and a late resolution during cancellation) may ignore abort.
143
+ void fetching.then((res) => {
144
+ if (controller.signal.aborted && res.body && !res.body.locked)
145
+ void res.body.cancel().catch(() => { });
146
+ }, () => { });
147
+ response = await abortable(fetching, controller.signal);
148
+ const result = await consume(response, controller.signal);
149
+ caller?.throwIfAborted();
150
+ return result;
151
+ }
152
+ catch (err) {
153
+ // Diagnostic reads intentionally retain HTTP status on an internal timeout,
154
+ // but explicit caller cancellation must never be turned into an HTTP error.
155
+ caller?.throwIfAborted();
156
+ throw err;
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ caller?.removeEventListener('abort', onAbort);
161
+ if (response?.body && !response.body.locked)
162
+ void response.body.cancel().catch(() => { });
163
+ }
164
+ }
165
+ async function readJson(response, signal) {
166
+ return JSON.parse(await readBody(response, signal));
167
+ }
168
+ /** delta-seconds only: all digits, capped at 60s (Swift parity — no HTTP dates). */
169
+ function retryAfterSecondsFrom(headers) {
170
+ const raw = headers.get('retry-after')?.trim();
171
+ if (!raw || !/^\d+$/.test(raw))
172
+ return undefined;
173
+ const value = Number(raw);
174
+ if (!Number.isFinite(value))
175
+ return undefined;
176
+ return Math.min(value, 60);
177
+ }
178
+ async function routerErrorFromResponse(status, response, signal) {
179
+ const body = safeJson(await drainBody(response, 4096, signal));
180
+ const nested = body?.error;
181
+ const nestedObj = typeof nested === 'object' && nested !== null ? nested : undefined;
182
+ const message = (typeof nestedObj?.message === 'string' && nestedObj.message) ||
183
+ (typeof body?.message === 'string' && body.message) ||
184
+ (typeof nested === 'string' ? nested : undefined) ||
185
+ statusHint(status);
186
+ const err = new RouterError(bounded(message));
187
+ err.status = status;
188
+ if (nestedObj) {
189
+ err.type = scalarString(nestedObj.type);
190
+ err.code = scalarString(nestedObj.code);
191
+ err.param = scalarString(nestedObj.param);
192
+ err.requestId = scalarString(nestedObj.request_id ?? nestedObj.requestId);
193
+ }
194
+ const retryAfter = retryAfterSecondsFrom(response.headers);
195
+ if (retryAfter !== undefined) {
196
+ err.retryAfterSeconds = retryAfter;
197
+ err.message += ` Retry after ${retryAfter} seconds.`;
198
+ }
199
+ return err;
200
+ }
201
+ /**
202
+ * Which wire dialect a model id speaks. A provider prefix always wins, so
203
+ * `openai/claude-proxy` is not silently rerouted (Swift parity).
204
+ */
205
+ export function dialectFor(model) {
206
+ const id = model.trim().toLowerCase();
207
+ if (!id)
208
+ throw new RouterError('Model id is empty.');
209
+ if (id.startsWith('anthropic/'))
210
+ return 'anthropic';
211
+ if (id.includes('/'))
212
+ return 'openai';
213
+ if (id.startsWith('claude-') || id.startsWith('claude.'))
214
+ return 'anthropic';
215
+ if (id.startsWith('gpt-') || id.startsWith('grok-') || id.startsWith('deepseek-'))
216
+ return 'openai';
217
+ throw new RouterError(`Cannot route model "${model}" — use the full provider-prefixed id from /model (e.g. opencode/glm-5.3-flash).`);
218
+ }
219
+ /** Strip the /v1 suffix: the Anthropic endpoints hang off the bare root. */
220
+ export function rootFromBase(baseUrl) {
221
+ const url = new URL(baseUrl);
222
+ if (url.pathname === '/v1' || url.pathname === '/v1/') {
223
+ url.pathname = '';
224
+ }
225
+ else {
226
+ url.pathname = url.pathname.replace(/\/+$/, '');
227
+ }
228
+ return url.toString().replace(/\/$/, '');
229
+ }
230
+ /** Pure catalog parsing; accepts {"data":[...]} and {"models":[...]} (Swift parity). */
231
+ export function parseModelCatalog(json) {
232
+ if (typeof json !== 'object' || json === null)
233
+ return null;
234
+ const root = json;
235
+ const entries = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : null;
236
+ if (!entries)
237
+ return null;
238
+ const result = [];
239
+ for (const entry of entries) {
240
+ if (typeof entry === 'string') {
241
+ result.push({ id: entry });
242
+ continue;
243
+ }
244
+ if (typeof entry !== 'object' || entry === null)
245
+ continue;
246
+ const e = entry;
247
+ const id = typeof e.id === 'string' ? e.id : typeof e.name === 'string' ? e.name : undefined;
248
+ if (!id)
249
+ continue;
250
+ result.push({
251
+ id,
252
+ displayName: typeof e.display_name === 'string' ? e.display_name : undefined,
253
+ ownedBy: typeof e.owned_by === 'string' ? e.owned_by : undefined,
254
+ contextLength: positiveInteger(e.context_length),
255
+ maxOutputTokens: positiveInteger(e.max_output_tokens),
256
+ });
257
+ }
258
+ return result;
259
+ }
260
+ function positiveInteger(value) {
261
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
262
+ }
263
+ export class RouterClient {
264
+ baseUrl;
265
+ apiKey;
266
+ debug;
267
+ wait;
268
+ promptCaching;
269
+ constructor(options) {
270
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
271
+ this.apiKey = options.apiKey;
272
+ this.debug = options.debug ?? (() => { });
273
+ this.wait = options.sleep;
274
+ this.promptCaching = options.promptCaching ?? false;
275
+ }
276
+ /** Replace credentials for subsequent requests without exposing them. */
277
+ setApiKey(apiKey) {
278
+ this.apiKey = apiKey;
279
+ }
280
+ /** Point subsequent requests at another endpoint root (already validated by normalizeBaseUrl). */
281
+ setBaseUrl(baseUrl) {
282
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
283
+ }
284
+ /** Wire body for a chat request; Anthropic requests opt into prompt caching when enabled. */
285
+ requestBody(req) {
286
+ return JSON.stringify(req.dialect === 'anthropic' ? toAnthropicBody(req, { cache: this.promptCaching }) : toWireBody(req));
287
+ }
288
+ /** Bare root without /v1 — Anthropic endpoints hang off this. */
289
+ rootUrl() {
290
+ return rootFromBase(this.baseUrl);
291
+ }
292
+ /** The completion endpoint for a dialect (used by the harness and adapters). */
293
+ chatPath(dialect) {
294
+ return dialect === 'anthropic'
295
+ ? this.rootUrl() + '/v1/messages'
296
+ : this.baseUrl + '/chat/completions';
297
+ }
298
+ /** Auth headers per dialect; the router accepts Bearer everywhere (Swift parity). */
299
+ headers(dialect) {
300
+ const h = {
301
+ Authorization: `Bearer ${this.apiKey}`,
302
+ 'Content-Type': 'application/json',
303
+ };
304
+ if (dialect === 'anthropic') {
305
+ h['x-api-key'] = this.apiKey;
306
+ h['anthropic-version'] = ANTHROPIC_VERSION;
307
+ }
308
+ return h;
309
+ }
310
+ async listModels(signal) {
311
+ return await withResponse(this.baseUrl + '/models', {
312
+ headers: { Authorization: `Bearer ${this.apiKey}` },
313
+ cache: 'no-store',
314
+ }, 30_000, signal, async (res, deadline) => {
315
+ if (!res.ok)
316
+ throw await routerErrorFromResponse(res.status, res, deadline);
317
+ const entries = parseModelCatalog(await readJson(res, deadline));
318
+ if (!entries) {
319
+ throw new RouterError('GET /models: unexpected response shape (no "data" or "models" array).');
320
+ }
321
+ return entries;
322
+ });
323
+ }
324
+ /** Non-streaming chat. One retry for retryable failures (429/502/503/network). */
325
+ async complete(req, signal) {
326
+ return await this.withRetry(() => withResponse(this.chatPath(req.dialect), {
327
+ method: 'POST',
328
+ headers: this.headers(req.dialect),
329
+ body: this.requestBody(req),
330
+ }, 300_000, signal, async (res, deadline) => {
331
+ if (!res.ok)
332
+ throw await routerErrorFromResponse(res.status, res, deadline);
333
+ const json = await readJson(res, deadline);
334
+ return req.dialect === 'anthropic' ? parseAnthropicCompleteBody(json) : parseCompleteBody(json);
335
+ }), signal);
336
+ }
337
+ /**
338
+ * Streaming chat. Events flow to onEvent as they arrive; resolves with the assembled
339
+ * turn. Retried once on retryable failures, and only when nothing has been emitted yet
340
+ * (a mid-stream retry would duplicate text).
341
+ */
342
+ async chat(req, onEvent, signal) {
343
+ let emitted = false;
344
+ const wrap = (e) => {
345
+ emitted = true;
346
+ onEvent(e);
347
+ };
348
+ const attempt = () => withResponse(this.chatPath(req.dialect), {
349
+ method: 'POST',
350
+ headers: this.headers(req.dialect),
351
+ body: this.requestBody(req),
352
+ }, 300_000, signal, async (res, deadline) => {
353
+ if (!res.ok)
354
+ throw await routerErrorFromResponse(res.status, res, deadline);
355
+ if (!res.body)
356
+ throw new RouterError('The router returned an empty stream.');
357
+ const parser = req.dialect === 'anthropic' ? new AnthropicStreamParser() : new OpenAiStreamParser();
358
+ for await (const frame of sseFrames(res.body, deadline)) {
359
+ // Payloads and server-controlled event names can contain private content.
360
+ this.debug(`sse: bytes=${Buffer.byteLength(frame.data)}`);
361
+ if (req.dialect === 'openai' && frame.data.trim() === '[DONE]')
362
+ break;
363
+ if (req.dialect === 'anthropic' && frame.event === 'message_stop') {
364
+ const events = parser.push(frame.data);
365
+ return { message: parser.assistantMessage(), stopReason: events[0]?.kind === 'stop' ? events[0].stopReason : 'unknown', usage: events[0]?.kind === 'stop' ? events[0].usage : undefined };
366
+ }
367
+ const events = parser.push(frame.data);
368
+ for (const e of events) {
369
+ if (e.kind === 'stop') {
370
+ return { message: parser.assistantMessage(), stopReason: e.stopReason, usage: e.usage };
371
+ }
372
+ wrap(e);
373
+ }
374
+ }
375
+ throw new RouterError('The router closed the response before it finished.');
376
+ });
377
+ try {
378
+ return await attempt();
379
+ }
380
+ catch (err) {
381
+ if (emitted || !this.isRetryable(err))
382
+ throw err;
383
+ signal?.throwIfAborted();
384
+ await this.sleep(this.retryDelayMs(err), signal);
385
+ signal?.throwIfAborted();
386
+ return await attempt();
387
+ }
388
+ }
389
+ /**
390
+ * Exact token count via POST /v1/messages/count_tokens (Messages body only, free).
391
+ * Advisory: callers must fall back to estimation on any failure.
392
+ */
393
+ async countTokens(model, system, messages, tools, signal) {
394
+ const body = toAnthropicBody({
395
+ model,
396
+ dialect: 'anthropic',
397
+ messages,
398
+ system,
399
+ tools,
400
+ stream: false,
401
+ });
402
+ return await withResponse(this.rootUrl() + '/v1/messages/count_tokens', {
403
+ method: 'POST',
404
+ headers: this.headers('anthropic'),
405
+ body: JSON.stringify(body),
406
+ }, 30_000, signal, async (res, deadline) => {
407
+ if (!res.ok)
408
+ throw await routerErrorFromResponse(res.status, res, deadline);
409
+ const json = (await readJson(res, deadline));
410
+ if (typeof json.input_tokens !== 'number') {
411
+ throw new RouterError('count_tokens: unexpected response shape (no input_tokens).');
412
+ }
413
+ return json.input_tokens;
414
+ });
415
+ }
416
+ /** One retry for 429/502/503 and network failures; 400/401/403 pass through. */
417
+ async withRetry(attempt, signal) {
418
+ try {
419
+ return await attempt();
420
+ }
421
+ catch (err) {
422
+ if (!this.isRetryable(err))
423
+ throw err;
424
+ signal?.throwIfAborted();
425
+ await this.sleep(this.retryDelayMs(err), signal);
426
+ signal?.throwIfAborted();
427
+ return await attempt();
428
+ }
429
+ }
430
+ isRetryable(err) {
431
+ // A user abort (Ctrl+C) is never retried.
432
+ if (err instanceof DOMException && err.name === 'AbortError')
433
+ return false;
434
+ const e = err;
435
+ return (!e?.status ||
436
+ e.status === 429 ||
437
+ e.status === 502 ||
438
+ e.status === 503 ||
439
+ err instanceof TypeError // fetch network failure
440
+ );
441
+ }
442
+ retryDelayMs(err) {
443
+ if (err.retryAfterSeconds)
444
+ return err.retryAfterSeconds * 1000;
445
+ return err.status === 429 ? 2000 : 1000;
446
+ }
447
+ async sleep(ms, signal) {
448
+ signal?.throwIfAborted();
449
+ this.debug(`retrying after ${ms}ms`);
450
+ if (this.wait) {
451
+ await abortable(this.wait(ms), signal);
452
+ return;
453
+ }
454
+ let timer;
455
+ try {
456
+ await abortable(new Promise((resolve) => { timer = setTimeout(resolve, ms); }), signal);
457
+ }
458
+ finally {
459
+ clearTimeout(timer);
460
+ }
461
+ }
462
+ }
463
+ export { routerErrorFromResponse, drainBody, bounded };
@@ -0,0 +1,72 @@
1
+ // User-triggered macOS clipboard access only; never exposed as an agent tool.
2
+ import { execFile } from 'node:child_process';
3
+ import { imageFromBytes, MAX_IMAGE_BYTES } from "./images.js";
4
+ // Fixed JXA reads AppKit's pasteboard directly. No shell, temp files, dependencies,
5
+ // or clipboard-derived code/arguments. Screenshots commonly arrive as TIFF.
6
+ export const CLIPBOARD_SCRIPT = `
7
+ ObjC.import('AppKit');
8
+ function encode(data) {
9
+ if (data.length > ${MAX_IMAGE_BYTES}) return JSON.stringify({ error: 'large' });
10
+ return JSON.stringify({ data: ObjC.unwrap(data.base64EncodedStringWithOptions(0)) });
11
+ }
12
+ function run() {
13
+ const board = $.NSPasteboard.generalPasteboard;
14
+ const png = board.dataForType($.NSPasteboardTypePNG);
15
+ if (png && !png.isNil()) return encode(png);
16
+ const tiff = board.dataForType($.NSPasteboardTypeTIFF);
17
+ if (!tiff || tiff.isNil()) return JSON.stringify({ error: 'empty' });
18
+ if (tiff.length > 40 * 1024 * 1024) return JSON.stringify({ error: 'large' });
19
+ const image = $.NSBitmapImageRep.imageRepWithData(tiff);
20
+ if (!image || image.isNil()) return JSON.stringify({ error: 'invalid' });
21
+ if (image.pixelsWide * image.pixelsHigh > 40000000) return JSON.stringify({ error: 'dimensions' });
22
+ const data = image.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
23
+ if (!data || data.isNil()) return JSON.stringify({ error: 'invalid' });
24
+ return encode(data);
25
+ }
26
+ `;
27
+ const MAX_BASE64_LENGTH = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
28
+ const unreadable = 'could not read the clipboard image — copy it again and press Ctrl+V';
29
+ export function decodeClipboardImage(output) {
30
+ if (output.length > MAX_BASE64_LENGTH + 100)
31
+ throw new Error('clipboard image exceeds the 10 MiB attachment limit');
32
+ let result;
33
+ try {
34
+ result = JSON.parse(output);
35
+ }
36
+ catch {
37
+ throw new Error(unreadable);
38
+ }
39
+ if (!result || typeof result !== 'object')
40
+ throw new Error(unreadable);
41
+ const record = result;
42
+ if (record.error === 'empty')
43
+ throw new Error('no image in the clipboard — copy an image or use Ctrl+Cmd+Shift+4; Cmd+V pastes text');
44
+ if (record.error === 'large')
45
+ throw new Error('clipboard image exceeds the 10 MiB attachment limit');
46
+ if (record.error === 'dimensions')
47
+ throw new Error('clipboard image is too large to convert — copy a smaller screenshot');
48
+ if (typeof record.data !== 'string' || !record.data || /[^A-Za-z0-9+/=]/.test(record.data))
49
+ throw new Error(unreadable);
50
+ const bytes = Buffer.from(record.data, 'base64');
51
+ if (bytes.toString('base64') !== record.data)
52
+ throw new Error(unreadable);
53
+ return imageFromBytes(bytes, 'clipboard.png');
54
+ }
55
+ export async function readClipboardImage(signal) {
56
+ signal?.throwIfAborted();
57
+ if (process.platform !== 'darwin')
58
+ throw new Error('clipboard images are supported on macOS only');
59
+ const output = await new Promise((resolve, reject) => {
60
+ execFile('/usr/bin/osascript', ['-l', 'JavaScript', '-e', CLIPBOARD_SCRIPT], {
61
+ encoding: 'utf8', shell: false, timeout: 10_000,
62
+ maxBuffer: MAX_BASE64_LENGTH + 1024, signal,
63
+ }, (err, stdout) => {
64
+ if (err)
65
+ reject(signal?.aborted ? signal.reason : new Error(unreadable));
66
+ else
67
+ resolve(stdout);
68
+ });
69
+ });
70
+ signal?.throwIfAborted();
71
+ return decodeClipboardImage(output);
72
+ }