agent-accelerator 0.3.0 → 0.4.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.
@@ -10,7 +10,7 @@
10
10
  * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
11
11
  *
12
12
  * This is the STABLE transport. The beta Responses skin
13
- * (`src/providers/openrouter-responses.ts`) is discontinued: no video shape,
13
+ * was removed from core: no video shape,
14
14
  * lossy error codes, beta event-vocabulary drift.
15
15
  *
16
16
  * Notes:
@@ -40,11 +40,21 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
40
40
  import { SSEParser } from "../streaming/sse-parser.ts";
41
41
  import { AgentResponse } from "../types/response.ts";
42
42
  import { getApiKey, getEnv } from "../utils/env.ts";
43
- import { buildSessionHeaders } from "../utils/headers.ts";
43
+ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
44
44
  import { normalizeMediaInput } from "../utils/media.ts";
45
45
  import { safeStringify } from "../utils/serialization.ts";
46
46
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
47
47
  import { withRetries } from "../utils/retry.ts";
48
+ import {
49
+ parseArguments,
50
+ normalizeThinkingParts,
51
+ newToolCallId,
52
+ redactedHeaders,
53
+ readErrorPayload,
54
+ incompleteStreamError,
55
+ combinedSignal,
56
+ timeoutFor,
57
+ } from "./shared.ts";
48
58
  import { createGenericModelSpec } from "../models/catalog.ts";
49
59
  import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
50
60
  import {
@@ -156,6 +166,12 @@ interface ChatResponse {
156
166
 
157
167
  const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
158
168
 
169
+ /** Q-18: Chat transport never echoes thought signatures (no wire field).
170
+ * Local helper documents the namespacing decision per provider file. */
171
+ function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
172
+ return {};
173
+ }
174
+
159
175
  /** Internal headers that must never leak onto native REST requests. */
160
176
  const INTERNAL_HEADERS = new Set([
161
177
  "x-thought-signature-map",
@@ -382,7 +398,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
382
398
  const queue = (m.name && idsByName.get(m.name)) || [];
383
399
  messages.push({
384
400
  role: "tool",
385
- tool_call_id: queue.shift() || "call_0",
401
+ tool_call_id: queue.shift() || newToolCallId(),
386
402
  content: m.content,
387
403
  name: m.name,
388
404
  });
@@ -429,27 +445,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
429
445
  return usage;
430
446
  }
431
447
 
432
- function parseArguments(raw: string | undefined): Record<string, unknown> {
433
- if (!raw) return {};
434
- try {
435
- const parsed: unknown = JSON.parse(raw);
436
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
437
- return parsed as Record<string, unknown>;
438
- }
439
- return { raw };
440
- } catch {
441
- return { raw };
442
- }
443
- }
444
-
445
- /** Trims/collapses assembled thinking parts (no edge-tripling). */
446
- function normalizeThinkingParts(parts: string[]): string | undefined {
447
- const cleaned = parts
448
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
449
- .filter((p) => p.length > 0);
450
- return cleaned.length > 0 ? cleaned.join("\n") : undefined;
451
- }
452
-
453
448
  function throwResponseError(response: ChatResponse, modelId: string): void {
454
449
  const message =
455
450
  response.error && typeof response.error.message === "string" && response.error.message
@@ -476,7 +471,9 @@ function parseResponse(
476
471
 
477
472
  const choice = response.choices?.[0];
478
473
  const message = choice?.message;
479
- const text = typeof message?.content === "string" ? message.content : "";
474
+ // Q-23: refusal content surfaces as text with a `refusal` finish reason.
475
+ const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
476
+ const text = typeof message?.content === "string" && message.content ? message.content : refusal;
480
477
  // Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
481
478
  // both ride along (observed live) — prefer details, else the plain field.
482
479
  const thinkingParts: string[] = [];
@@ -493,7 +490,7 @@ function parseResponse(
493
490
  for (const tc of message?.tool_calls ?? []) {
494
491
  const args = parseArguments(tc.function?.arguments);
495
492
  toolCalls.push({
496
- id: tc.id || `call_${Math.random().toString(36).slice(2, 9)}`,
493
+ id: tc.id || newToolCallId(),
497
494
  name: tc.function?.name || "unknown",
498
495
  arguments: args,
499
496
  rawArguments:
@@ -504,15 +501,17 @@ function parseResponse(
504
501
  }
505
502
 
506
503
  // Normalized finish reasons: tool_calls|stop|length|content_filter|error.
504
+ // Q-23: refusal content forces `refusal` (even when the wire says stop).
507
505
  const wireReason = choice?.finish_reason ?? undefined;
506
+ const finishReason =
507
+ toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
508
508
  return {
509
509
  text,
510
510
  thinking: normalizeThinkingParts(thinkingParts),
511
511
  thoughtSignature: undefined,
512
512
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
513
513
  usage: mapUsage(response.usage),
514
- finishReason:
515
- toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
514
+ finishReason,
516
515
  responseId: response.id,
517
516
  model: modelId,
518
517
  provider: "openrouter",
@@ -521,33 +520,6 @@ function parseResponse(
521
520
  };
522
521
  }
523
522
 
524
- function redactedHeaders(headers: Record<string, string>): Record<string, string> {
525
- const out: Record<string, string> = {};
526
- for (const [k, v] of Object.entries(headers)) {
527
- out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
528
- }
529
- return out;
530
- }
531
-
532
- function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
533
- try {
534
- const parsed: unknown = JSON.parse(bodyText);
535
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
536
- const err = (first as { error?: { message?: string; code?: string | number; metadata?: { error_type?: string } } })?.error;
537
- if (err && typeof err.message === "string") {
538
- const errorType = (err as { metadata?: { error_type?: string } })?.metadata?.error_type;
539
- return {
540
- message: err.message,
541
- code: err.code,
542
- ...(typeof errorType === "string" ? { errorType } : {}),
543
- };
544
- }
545
- return { message: bodyText.slice(0, 300) };
546
- } catch {
547
- return { message: bodyText.slice(0, 300) };
548
- }
549
- }
550
-
551
523
  // ---------------------------------------------------------------------------
552
524
  // Provider
553
525
  // ---------------------------------------------------------------------------
@@ -635,8 +607,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
635
607
  for (const [k, v] of Object.entries(headers)) {
636
608
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
637
609
  }
638
- headers["Content-Type"] = "application/json";
639
- headers["Authorization"] = `Bearer ${apiKey}`;
610
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
611
+ setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
640
612
  return { method: "POST", headers, body: JSON.stringify(body), signal };
641
613
  }
642
614
 
@@ -648,13 +620,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
648
620
  sessionId: string | undefined,
649
621
  signal?: AbortSignal
650
622
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
651
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
652
- const text = await res.text();
653
- const headers: Record<string, string> = {};
654
- res.headers.forEach((v, k) => {
655
- headers[k] = v;
656
- });
657
- return { status: res.status, statusText: res.statusText, headers, text };
623
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
624
+ try {
625
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
626
+ const text = await res.text();
627
+ const headers: Record<string, string> = {};
628
+ res.headers.forEach((v, k) => {
629
+ headers[k] = v;
630
+ });
631
+ return { status: res.status, statusText: res.statusText, headers, text };
632
+ } finally {
633
+ cleanup();
634
+ }
658
635
  }
659
636
 
660
637
  private throwIfError(
@@ -814,7 +791,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
814
791
  body,
815
792
  };
816
793
 
817
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
794
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
795
+ const streamTimeoutTimer =
796
+ streamTimeoutMs && streamTimeoutMs > 0
797
+ ? setTimeout(() => {
798
+ try {
799
+ linked.abort();
800
+ } catch {}
801
+ }, streamTimeoutMs)
802
+ : undefined;
803
+ let res: Response;
804
+ try {
805
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
806
+ } finally {
807
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
808
+ }
818
809
  if (!res.ok || !res.body) {
819
810
  const text = !res.ok ? await res.text().catch(() => "") : "";
820
811
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -824,7 +815,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
824
815
  res.headers.forEach((v, k) => {
825
816
  responseHeaders[k] = v;
826
817
  });
827
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
818
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
828
819
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
829
820
 
830
821
  const parser = new SSEParser();
@@ -839,6 +830,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
839
830
  let lastToolIndex = 0;
840
831
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
841
832
  let finishReason = "stop";
833
+ let sawFinishReason = false;
834
+ let sawUsage = false;
835
+ let refusalSeen = false;
836
+ let badFrames = 0;
837
+ let framesSeen = 0;
842
838
  let responseId: string | undefined;
843
839
  let completedBody: unknown = undefined;
844
840
  let aborted = false;
@@ -854,11 +850,17 @@ export class OpenRouterChatCompletionsProvider implements Provider {
854
850
  );
855
851
 
856
852
  const handleMessage = (data: string): void => {
857
- if (!data || data === "[DONE]") return;
853
+ if (!data) return;
854
+ if (data === "[DONE]") {
855
+ sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
856
+ return;
857
+ }
858
858
  let msg: Record<string, unknown>;
859
859
  try {
860
860
  msg = JSON.parse(data) as Record<string, unknown>;
861
+ framesSeen += 1;
861
862
  } catch {
863
+ badFrames += 1;
862
864
  return;
863
865
  }
864
866
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
@@ -881,6 +883,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
881
883
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
882
884
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
883
885
  // Text delta (content-free accounting frames carry "" — ignored).
886
+ // Q-23: refusal deltas surface as text with a `refusal` finish.
887
+ const deltaRefusal = delta["refusal"];
888
+ if (typeof deltaRefusal === "string" && deltaRefusal) {
889
+ text += deltaRefusal;
890
+ refusalSeen = true;
891
+ eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
892
+ }
884
893
  if (typeof delta["content"] === "string" && delta["content"]) {
885
894
  text += delta["content"] as string;
886
895
  eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
@@ -950,10 +959,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
950
959
  throw toConciseProviderError(failure, "openrouter", clean);
951
960
  }
952
961
  finishReason = fr;
962
+ sawFinishReason = true;
953
963
  }
954
964
  const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
955
965
  if (chunkUsage) {
956
966
  usage = mapUsage(chunkUsage);
967
+ sawUsage = true;
957
968
  completedBody = completedBody ?? msg;
958
969
  eventStream.push({ type: "usage", usage });
959
970
  }
@@ -984,7 +995,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
984
995
  for (const c of calls.values()) {
985
996
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
986
997
  const record: ToolCallRecord = {
987
- id: c.id || `call_${Math.random().toString(36).slice(2, 9)}`,
998
+ id: c.id || newToolCallId(),
988
999
  name: c.name,
989
1000
  arguments: args,
990
1001
  rawArguments: c.startArgs + c.deltaArgs,
@@ -994,9 +1005,30 @@ export class OpenRouterChatCompletionsProvider implements Provider {
994
1005
  }
995
1006
 
996
1007
  noteProviderTurn(sessionId, "openrouter");
997
- if (toolCalls.length > 0) finishReason = "tool_calls";
1008
+ if (toolCalls.length > 0) {
1009
+ finishReason = "tool_calls";
1010
+ sawFinishReason = true;
1011
+ } else if (refusalSeen && finishReason !== "content_filter") {
1012
+ finishReason = "refusal";
1013
+ sawFinishReason = true;
1014
+ }
998
1015
 
999
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1016
+ // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1017
+ // instead of resolving empty success. Skipped for non-SSE payloads
1018
+ // (JSON mocks) where no SSE frames were ever seen.
1019
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1020
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1021
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1022
+ const isSSEPayload =
1023
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1024
+ framesSeen > 0 ||
1025
+ badFrames > 0;
1026
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1027
+ eventStream.fail(incompleteStreamError("openrouter", clean));
1028
+ return;
1029
+ }
1030
+
1031
+ const cleanThinking = cleanThinkingEarly;
1000
1032
  const finalResponse = new AgentResponse({
1001
1033
  text,
1002
1034
  thinking: cleanThinking || undefined,
@@ -1007,7 +1039,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1007
1039
  responseId,
1008
1040
  model: clean,
1009
1041
  provider: "openrouter",
1010
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1042
+ raw: {
1043
+ request: rawRequest,
1044
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1045
+ },
1011
1046
  durationMs: Date.now() - startTime,
1012
1047
  });
1013
1048
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
@@ -80,12 +80,25 @@ export function ensureCustomProvider(
80
80
  ): Provider {
81
81
  assertProviderNotRemoved(prefix);
82
82
  const id = normalizeProviderPrefix(prefix) as ProviderId;
83
+ if (FIRST_CLASS.has(id.toLowerCase())) {
84
+ if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
85
+ throw new Error(
86
+ `[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
87
+ );
88
+ }
89
+ return providerRegistry.get(id)!;
90
+ }
83
91
  const existing = providerRegistry.get(id);
84
- if (existing && !FIRST_CLASS.has(id.toLowerCase())) return existing;
85
- if (existing && opts?.baseUrl === undefined && opts?.apiKey === undefined) {
92
+ if (existing) {
93
+ const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
94
+ if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
95
+ throw new Error(
96
+ `[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
97
+ );
98
+ }
86
99
  return existing;
87
100
  }
88
- const created = new OpenAICompatibleChatProvider(id, opts);
101
+ const created = new OpenAICompatibleChatProvider(id, { baseUrl: opts?.baseUrl, name: opts?.name });
89
102
  providerRegistry.set(id, created);
90
103
  return created;
91
104
  }
@@ -199,11 +212,6 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
199
212
  parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
200
213
 
201
214
  if (!looksLikeOpenRouterScope) {
202
- if (hasCustomOpenAIBase && !hasDedicatedEnv) {
203
- const p = getProvider("openai");
204
- const spec = p.getModel(remainingModel) || getModelFromCatalog(remainingModel, remainingModel);
205
- return { provider: p, modelId: remainingModel, modelSpec: spec as ModelSpec };
206
- }
207
215
  const custom = ensureCustomProvider(providerPrefix);
208
216
  const spec =
209
217
  custom.getModel(remainingModel) ||
@@ -372,10 +380,6 @@ export const ModelProvider = {
372
380
  options?: { thinkingLevel?: ThinkingLevel; baseUrl?: string }
373
381
  ): ModelProviderInstance {
374
382
  const normalized = model.includes("/") ? model : `custom/${model}`;
375
- const prefix = normalized.split("/")[0]!;
376
- try {
377
- ensureCustomProvider(prefix, { baseUrl: options?.baseUrl, apiKey });
378
- } catch {}
379
383
  return {
380
384
  model: normalized,
381
385
  apiKey,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared provider helpers (Q-45-partial).
3
+ *
4
+ * Single home for the `redactedHeaders` / `readErrorPayload` /
5
+ * `parseArguments` / `normalizeThinkingParts` helpers previously duplicated
6
+ * across the four REST transports, plus timeout/signal and id helpers.
7
+ */
8
+ import { toConciseProviderError } from "../utils/errors.ts";
9
+
10
+ /** Parses a JSON tool-argument string; non-objects degrade to `{ raw }`. */
11
+ export function parseArguments(raw: string | undefined): Record<string, unknown> {
12
+ if (!raw) return {};
13
+ try {
14
+ const parsed: unknown = JSON.parse(raw);
15
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16
+ return parsed as Record<string, unknown>;
17
+ }
18
+ return { raw };
19
+ } catch {
20
+ return { raw };
21
+ }
22
+ }
23
+
24
+ /** Trims/collapses assembled thinking parts (no edge-tripling). */
25
+ export function normalizeThinkingParts(parts: string[]): string | undefined {
26
+ const cleaned = parts
27
+ .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
28
+ .filter((p) => p.length > 0);
29
+ return cleaned.length > 0 ? cleaned.join("\n") : undefined;
30
+ }
31
+
32
+ /**
33
+ * Redacts secret-bearing headers for the raw audit trail. Superset of the
34
+ * four adapter versions: `authorization` (any case) and `x-goog-api-key` are
35
+ * redacted; all other headers pass through untouched.
36
+ */
37
+ export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const [k, v] of Object.entries(headers)) {
40
+ const lower = k.toLowerCase();
41
+ out[k] = lower === "authorization" || lower === "x-goog-api-key" ? "[REDACTED]" : v;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Extracts `{ message, code, errorType }` from a non-2xx JSON body. Handles
48
+ * OpenAI `type`, OpenRouter `metadata.error_type`, and Google `status` code
49
+ * shapes; unknown bodies degrade to a 300-char slice.
50
+ */
51
+ export function readErrorPayload(
52
+ bodyText: string
53
+ ): { message: string; code?: string | number; errorType?: string } {
54
+ try {
55
+ const parsed: unknown = JSON.parse(bodyText);
56
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
57
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string } } })?.error;
58
+ if (err && typeof err.message === "string") {
59
+ const code = err.code ?? err.status;
60
+ const errorType =
61
+ err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
62
+ return {
63
+ message: err.message,
64
+ ...(code !== undefined ? { code } : {}),
65
+ ...(typeof errorType === "string" ? { errorType } : {}),
66
+ };
67
+ }
68
+ return { message: bodyText.slice(0, 300) };
69
+ } catch {
70
+ return { message: bodyText.slice(0, 300) };
71
+ }
72
+ }
73
+
74
+ /** Retryable incomplete-stream error for truncated SSE (Q-23). */
75
+ export function incompleteStreamError(provider: string, modelId: string): Error {
76
+ const err = toConciseProviderError(
77
+ Object.assign(new Error("Stream ended without terminal event"), {
78
+ statusCode: 502,
79
+ transient: true,
80
+ retryable: true,
81
+ url: provider,
82
+ }),
83
+ provider,
84
+ modelId
85
+ );
86
+ (err as any).code = "incomplete_stream";
87
+ return err;
88
+ }
89
+
90
+ /** Per-request timeout ms from provider options (Q-22). */
91
+ export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
92
+ const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
93
+ return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.floor(t) : undefined;
94
+ }
95
+
96
+ /** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
97
+ export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
98
+ if (timeoutMs === undefined) return { signal, cleanup: () => {} };
99
+ const ctrl = new AbortController();
100
+ const timer = setTimeout(() => {
101
+ try { ctrl.abort(); } catch {}
102
+ }, timeoutMs);
103
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
104
+ const cleanup = () => {
105
+ try { clearTimeout(timer); } catch {}
106
+ };
107
+ if (signal) {
108
+ if (signal.aborted) {
109
+ cleanup();
110
+ ctrl.abort();
111
+ } else {
112
+ signal.addEventListener("abort", () => {
113
+ cleanup();
114
+ try { ctrl.abort(); } catch {}
115
+ }, { once: true });
116
+ }
117
+ }
118
+ return { signal: ctrl.signal, cleanup };
119
+ }
120
+
121
+ /** Q-34: crypto-backed tool-call id, no Math.random primary. */
122
+ export function newToolCallId(prefix = "call"): string {
123
+ try {
124
+ const c = (globalThis as any)?.crypto;
125
+ if (c?.randomUUID) return `${prefix}_${c.randomUUID().replace(/-/g, "").slice(0, 12)}`;
126
+ } catch {}
127
+ try {
128
+ const nc: any = (globalThis as any)?.process?.getBuiltinModule?.("node:crypto") ?? null;
129
+ if (nc?.randomUUID) return `${prefix}_${nc.randomUUID().replace(/-/g, "").slice(0, 12)}`;
130
+ } catch {}
131
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
132
+ }
package/src/providers.ts CHANGED
@@ -50,9 +50,8 @@ export function emitProviderWarning(options: {
50
50
  // Leading newline: warnings fire at the start of a turn's request build,
51
51
  // when the previous turn's streamed text may have left the terminal cursor
52
52
  // mid-line. Without it the warning glues onto streamed output.
53
- console.warn(
54
- `\n[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`
55
- );
53
+ // Q-41 dual: telemetry bus (new) + console.warn (compat for existing captureWarnings tests; /cli logger replaces in 0.5).
54
+ { const msg = `[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`; try { (globalThis as any).__agentAccelTelemetry?.emitWarning?.({ code: "provider_warning", provider: options.provider, capability: options.capability, message: msg }); } catch {} try { (globalThis as any).__agentAccelWarnSink?.(msg, options); } catch {} try { console.warn(msg); } catch {} }
56
55
  }
57
56
 
58
57
  /** Clears deduped-warning state (mainly for tests). */
@@ -184,7 +183,7 @@ export function mapThinkingLevelToOpenRouterChat(
184
183
  * echoed but are NOT documented levels, so they clamp to `high` with a warning.
185
184
  *
186
185
  * @deprecated The Responses skin is discontinued for OpenRouter
187
- * (`src/providers/openrouter-responses.ts`, archived). Use
186
+ * (removed from core). Use
188
187
  * {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
189
188
  * transport instead.
190
189
  */