agent-accelerator 0.5.5 → 0.5.6

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.
@@ -9,6 +9,8 @@ import { toConciseProviderError } from "../utils/errors.ts";
9
9
  import { withRetries } from "../utils/retry.ts";
10
10
  import { redactHeadersCentral } from "../utils/cache.ts";
11
11
  import { TRUNCATED_ARGS_KEY, isTruncatedArguments } from "../types/tool.ts";
12
+ import { SSEParser } from "../streaming/sse-parser.ts";
13
+ import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
12
14
 
13
15
  /** Parses a JSON tool-argument string; non-objects degrade to the truncated-arguments marker. */
14
16
  export function parseArguments(raw: string | undefined): Record<string, unknown> {
@@ -43,17 +45,17 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
43
45
  }
44
46
 
45
47
  /**
46
- * Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
47
- * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
48
+ * Extracts `{ message, code, errorType, raw, limitSource }` from a non-2xx JSON body. Handles
49
+ * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
48
50
  * `status` code shapes; unknown bodies degrade to a 300-char slice.
49
51
  */
50
52
  export function readErrorPayload(
51
53
  bodyText: string
52
- ): { message: string; code?: string | number; errorType?: string; raw?: string } {
54
+ ): { message: string; code?: string | number; errorType?: string; raw?: string; limitSource?: string } {
53
55
  try {
54
56
  const parsed: unknown = JSON.parse(bodyText);
55
57
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
56
- const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
58
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown } } })?.error;
57
59
  if (err && typeof err.message === "string") {
58
60
  const code = err.code ?? err.status;
59
61
  const errorType =
@@ -67,6 +69,7 @@ export function readErrorPayload(
67
69
  ...(code !== undefined ? { code } : {}),
68
70
  ...(typeof errorType === "string" ? { errorType } : {}),
69
71
  ...(raw ? { raw } : {}),
72
+ ...(typeof err.metadata?.limit_source === "string" ? { limitSource: err.metadata.limit_source } : {}),
70
73
  };
71
74
  }
72
75
  return { message: bodyText.slice(0, 300) };
@@ -180,6 +183,12 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
180
183
  return isTruncatedArguments(args);
181
184
  }
182
185
 
186
+ // True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
187
+ export function hasUnfinishedToolCall(calls: Iterable<{ startArgs: string; deltaArgs: string }>, toolCalls: Array<{ arguments: unknown }>): boolean {
188
+ for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
189
+ return toolCalls.some((c) => isTruncatedArguments(c.arguments));
190
+ }
191
+
183
192
  /** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
184
193
  export function shortHashId(value: string): string {
185
194
  let h = 0x811c9dc5;
@@ -198,7 +207,10 @@ export function shortHashId(value: string): string {
198
207
  export function clampToolCallId(id: string): string {
199
208
  const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
200
209
  if (!clean.includes("|")) {
201
- return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
210
+ if (clean.length <= 40) return clean || "call_unknown";
211
+ // A hard cut would give two long ids with the same first 40 characters the same id; a hash of the whole id keeps them apart.
212
+ const hash = shortHashId(id);
213
+ return `${clean.slice(0, 40 - hash.length - 1)}_${hash}`;
202
214
  }
203
215
  const sep = clean.indexOf("|");
204
216
  const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
@@ -210,6 +222,16 @@ export function clampToolCallId(id: string): string {
210
222
  return `${prefix}_${hash}`;
211
223
  }
212
224
 
225
+ // The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
226
+ export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
227
+ return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments)) ? "length" : "tool_calls";
228
+ }
229
+
230
+ // The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
231
+ export function toolArgumentsJson(args: unknown): string {
232
+ return isTruncatedArguments(args) ? "{}" : JSON.stringify(args || {});
233
+ }
234
+
213
235
  /** Plain lower-cased header record from a fetch `Headers` object. */
214
236
  export function headersToRecord(headers: Headers): Record<string, string> {
215
237
  const out: Record<string, string> = {};
@@ -282,3 +304,110 @@ export function retryOptionsFrom(options: {
282
304
  retryBudgetMs: options?.retryBudgetMs,
283
305
  };
284
306
  }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Streaming scaffold shared by the SSE adapters (abort linking, request timer, read loop, end-of-stream check, failure)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ // Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
313
+ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal: AbortSignal | undefined): { linked: AbortController; release: () => void } {
314
+ const linked = new AbortController();
315
+ const forwardUserAbort = (): void => {
316
+ try {
317
+ linked.abort((signal as { reason?: unknown } | undefined)?.reason);
318
+ } catch {
319
+ try {
320
+ linked.abort();
321
+ } catch {}
322
+ }
323
+ };
324
+ if (signal?.aborted) forwardUserAbort();
325
+ else signal?.addEventListener("abort", forwardUserAbort, { once: true });
326
+ const removeStreamCancel = eventStream.onCancel(() => {
327
+ try {
328
+ linked.abort();
329
+ } catch {}
330
+ });
331
+ return {
332
+ linked,
333
+ release: () => {
334
+ try {
335
+ signal?.removeEventListener("abort", forwardUserAbort);
336
+ } catch {}
337
+ try {
338
+ removeStreamCancel();
339
+ } catch {}
340
+ },
341
+ };
342
+ }
343
+
344
+ // Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
345
+ export async function withAbortTimer<T>(controller: AbortController, ms: number | undefined, run: () => Promise<T>): Promise<T> {
346
+ const timer =
347
+ ms && ms > 0
348
+ ? setTimeout(() => {
349
+ try {
350
+ controller.abort();
351
+ } catch {}
352
+ }, ms)
353
+ : undefined;
354
+ try {
355
+ return await run();
356
+ } finally {
357
+ if (timer) clearTimeout(timer);
358
+ }
359
+ }
360
+
361
+ // Reads an SSE response body to its end, handing each `data:` payload to `onData`; stops with an AbortError as soon as the stream is aborted or cancelled (cancelling the body, whose rejection is absorbed); in: the opened Response body of an SSE adapter, out: calls to onData.
362
+ export async function readSseData(
363
+ body: ReadableStream<Uint8Array>,
364
+ linked: AbortController,
365
+ eventStream: AssistantMessageEventStream,
366
+ onData: (data: string) => void
367
+ ): Promise<void> {
368
+ const parser = new SSEParser();
369
+ const reader = body.getReader();
370
+ const decoder = new TextDecoder();
371
+ const abort = (): Error => Object.assign(new Error("Stream aborted"), { name: "AbortError" });
372
+ linked.signal.addEventListener("abort", () => void reader.cancel().catch(() => {}), { once: true }); // an aborted body is already errored: its cancel() rejects, and nobody else listens
373
+ while (true) {
374
+ if (linked.signal.aborted || eventStream.isCancelled()) {
375
+ try {
376
+ await reader.cancel();
377
+ } catch {}
378
+ throw abort();
379
+ }
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ for (const m of parser.feed(decoder.decode(value, { stream: true }))) onData(m.data);
383
+ }
384
+ for (const m of parser.flush()) onData(m.data);
385
+ try {
386
+ reader.releaseLock();
387
+ } catch {}
388
+ if (linked.signal.aborted || eventStream.isCancelled()) throw abort();
389
+ }
390
+
391
+ // The error to fail with when an SSE stream ended without its final event while carrying no content or an unfinished tool call (checked only for real SSE payloads, not JSON mocks); in: the adapter's state after reading, out: `eventStream.fail`, else undefined.
392
+ export function incompleteStreamCheck(a: {
393
+ provider: string;
394
+ modelId: string;
395
+ hasTerminal: boolean;
396
+ empty: boolean;
397
+ unfinishedCall: boolean;
398
+ responseHeaders: Record<string, string>;
399
+ framesSeen: number;
400
+ badFrames: number;
401
+ }): Error | undefined {
402
+ if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
403
+ const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
404
+ const isSSEPayload = (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) || a.framesSeen > 0 || a.badFrames > 0;
405
+ return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
406
+ }
407
+
408
+ // Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
409
+ export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
410
+ const raw = err instanceof Error ? err : new Error(String(err));
411
+ const isAbort = linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
412
+ eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
413
+ }
package/src/providers.ts CHANGED
@@ -248,17 +248,17 @@ export function resolveCache(
248
248
  return undefined;
249
249
  }
250
250
 
251
- /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire); in: an adapter's request messages, out: telemetry bus only. */
252
- export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string): void {
251
+ /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire for the given kinds of media); in: an adapter's request messages, out: telemetry bus only. */
252
+ export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string, kinds: Array<"image" | "video"> = ["image", "video"]): void {
253
253
  const used = messages.some(
254
- (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
254
+ (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
255
255
  );
256
256
  if (!used) return;
257
257
  emitProviderWarning({
258
258
  provider,
259
259
  capability: "media resolution",
260
260
  requested: modelRef,
261
- reason: "this provider has no per-media detail control (Google only).",
261
+ reason: "this provider has no per-media detail control for this kind of media.",
262
262
  fallback: "the provider's default detail",
263
263
  });
264
264
  }
@@ -399,6 +399,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
399
399
  * @example `parseStreamedToolArguments("{}", '{"location":"Paris"}')`
400
400
  */
401
401
  export function parseStreamedToolArguments(startText: string, deltaText: string): Record<string, unknown> {
402
+ if (!startText.trim() && !deltaText.trim()) return {}; // no argument text at all is a call that takes none
402
403
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
403
404
  for (const candidate of candidates) {
404
405
  try {
@@ -422,6 +423,7 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
422
423
  * @example `selectRawArguments("{}", '{"location":"Paris"}') // '{"location":"Paris"}'`
423
424
  */
424
425
  export function selectRawArguments(startText: string, deltaText: string): string {
426
+ if (!startText.trim() && !deltaText.trim()) return "{}";
425
427
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
426
428
  for (const candidate of candidates) {
427
429
  try {
@@ -445,6 +447,8 @@ export interface GenerationParams {
445
447
  seed?: number;
446
448
  }
447
449
 
450
+ /** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
451
+ const MAX_CHAT_STOP = 4;
448
452
  const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
449
453
 
450
454
  /** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
@@ -460,11 +464,22 @@ export function normalizeStop(stop: string | string[] | undefined): string[] | u
460
464
  * `max_tokens`, `stop`, `seed`. Only set values are
461
465
  * emitted, so strict endpoints never see anything the caller did not ask for.
462
466
  */
463
- export function mapGenerationParamsToChat(params: GenerationParams | undefined): Record<string, unknown> {
467
+ export function mapGenerationParamsToChat(params: GenerationParams | undefined, provider = "chat", modelRef = ""): Record<string, unknown> {
464
468
  const out: Record<string, unknown> = {};
465
469
  if (!params) return out;
466
470
  if (finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0) out["max_tokens"] = Math.floor(params.maxOutputTokens);
467
- if (params.stop && params.stop.length > 0) out["stop"] = params.stop;
471
+ if (params.stop && params.stop.length > 0) {
472
+ out["stop"] = params.stop.slice(0, MAX_CHAT_STOP);
473
+ if (params.stop.length > MAX_CHAT_STOP) {
474
+ emitProviderWarning({
475
+ provider,
476
+ capability: "stop sequences",
477
+ requested: `${params.stop.length} sequences (${modelRef})`,
478
+ reason: `Chat Completions allows at most ${MAX_CHAT_STOP} stop sequences.`,
479
+ fallback: `the first ${MAX_CHAT_STOP}`,
480
+ });
481
+ }
482
+ }
468
483
  if (finiteNumber(params.seed)) out["seed"] = Math.floor(params.seed);
469
484
  return out;
470
485
  }
@@ -1391,7 +1391,7 @@ function resolveEffectivePricing(
1391
1391
  /**
1392
1392
  * Prices one usage snapshot with integer micro-USD accumulation (Q-36).
1393
1393
  *
1394
- * - Provider-reported `usage.cost.totalCost > 0` wins (source `provider`).
1394
+ * - A provider-reported `usage.cost.totalCost` (a number, 0 included: free or cached) wins (source `provider`).
1395
1395
  * - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
1396
1396
  * - Else returns `undefined` (source `unknown` is implicit) — unknown stays
1397
1397
  * unknown, never a fabricated 0 object.
@@ -1408,7 +1408,7 @@ export function priceUsage(
1408
1408
  opts?: { serviceTier?: ServiceTier | string; modelId?: string }
1409
1409
  ): PricedCost | undefined {
1410
1410
  const reported = usage?.cost;
1411
- if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
1411
+ if (reported && typeof reported.totalCost === "number" && reported.totalCost >= 0) {
1412
1412
  const micro = (d?: number): number => (typeof d === "number" && d > 0 ? Math.round(d * 1_000_000) : 0);
1413
1413
  const inputMicroUSD = micro(reported.inputCost);
1414
1414
  const outputMicroUSD = micro(reported.outputCost);
@@ -1492,7 +1492,7 @@ export function addCostToUsage(
1492
1492
  spec?: ModelSpec,
1493
1493
  opts?: { serviceTier?: ServiceTier | string }
1494
1494
  ): TokenUsage["cost"] | undefined {
1495
- if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost > 0) {
1495
+ if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost >= 0) {
1496
1496
  return usage.cost;
1497
1497
  }
1498
1498
  const priced = priceUsage(usage, spec, opts);
@@ -9,7 +9,7 @@ export interface TextPart {
9
9
  thoughtSignature?: string;
10
10
  }
11
11
 
12
- /** Detail level a provider spends on one image or video (more detail = more input tokens). Google only; other providers ignore it with a warning. */
12
+ /** Detail level a provider spends on one image or video (more detail = more input tokens). Google (images and videos) and OpenRouter (images, as `image_url.detail`) honour it; other providers ignore it with a warning. */
13
13
  export type MediaResolution = "low" | "medium" | "high" | "ultra_high";
14
14
 
15
15
  /** Image content accepted as a URL, data, path, or binary value. */
@@ -113,7 +113,8 @@ const PART_TO_MODALITY: Record<string, "image" | "audio" | "video" | "pdf"> = {
113
113
 
114
114
  /**
115
115
  * Fails fast — before any network call — when the catalog knows the model
116
- * lacks a requested input modality (image/audio/video/document). Unknown
116
+ * lacks a requested input modality (image/audio/video/document; files are
117
+ * not checked on OpenRouter, which parses them for any model). Unknown
117
118
  * models (custom providers, dynamic routers) are skipped: the provider
118
119
  * verdict stands and surfaces via toConciseProviderError instead.
119
120
  */
@@ -130,6 +131,8 @@ export function assertModalitiesSupported(
130
131
  if (mapped) needed.add(mapped);
131
132
  }
132
133
  }
134
+ // OpenRouter reads a file with the model's native support or its own parser, so the catalog's "pdf" flag never gates a file there.
135
+ if (providerId === "openrouter") needed.delete("pdf");
133
136
  if (needed.size === 0) return;
134
137
  let supported: readonly string[] | undefined;
135
138
  try {
@@ -23,7 +23,7 @@ export function getPackageVersion(): string {
23
23
  const v = (globalThis as any).process?.env?.npm_package_version;
24
24
  if (typeof v === "string" && v) return v;
25
25
  } catch {}
26
- return "0.5.5";
26
+ return "0.5.6";
27
27
  }
28
28
 
29
29
  function getAgentAccelUserAgent(): string {
@@ -26,6 +26,11 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
26
26
  if (clean.endsWith(".mp3")) return "audio/mp3";
27
27
  if (clean.endsWith(".wav")) return "audio/wav";
28
28
  if (clean.endsWith(".ogg")) return "audio/ogg";
29
+ if (clean.endsWith(".flac")) return "audio/flac";
30
+ if (clean.endsWith(".m4a")) return "audio/m4a";
31
+ if (clean.endsWith(".aac")) return "audio/aac";
32
+ if (clean.endsWith(".aiff") || clean.endsWith(".aif")) return "audio/aiff";
33
+ if (clean.endsWith(".mpeg") || clean.endsWith(".mpg")) return "video/mpeg";
29
34
  if (clean.endsWith(".mp4")) return "video/mp4";
30
35
  if (clean.endsWith(".webm")) return "video/webm";
31
36
  if (clean.endsWith(".mov")) return "video/quicktime";
@@ -53,8 +58,12 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
53
58
  if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WAVE")) return "audio/wav";
54
59
  if (ascii(bytes, 0, "%PDF")) return "application/pdf";
55
60
  if (ascii(bytes, 0, "OggS")) return "audio/ogg";
61
+ if (ascii(bytes, 0, "fLaC")) return "audio/flac";
62
+ if (ascii(bytes, 0, "FORM") && ascii(bytes, 8, "AIF")) return "audio/aiff";
63
+ if (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xf6) === 0xf0) return "audio/aac"; // ADTS: sync bits plus layer 00 (MP3 frames have a non-zero layer)
56
64
  if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
57
- if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
65
+ if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "M4A") || ascii(bytes, 8, "M4B") ? "audio/m4a" : ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
66
+ if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0xba) return "video/mpeg"; // MPEG program stream
58
67
  if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
59
68
  return undefined;
60
69
  }