agent-accelerator 0.2.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). */
@@ -108,7 +107,7 @@ export function clearSessionRouting(sessionId?: string): void {
108
107
 
109
108
  /**
110
109
  * Maps a canonical ThinkingLevel onto Google Interactions `thinking_level`.
111
- * Google values: minimal|low|medium|high (no off switch, no xhigh).
110
+ * Google values: minimal|low|medium|high (no off switch, no xhigh/max).
112
111
  */
113
112
  export function mapThinkingLevelToGoogle(
114
113
  level: ThinkingLevel | string | undefined
@@ -126,7 +125,7 @@ export function mapThinkingLevelToGoogle(
126
125
  });
127
126
  return {};
128
127
  }
129
- if (norm === "xhigh") {
128
+ if (norm === "xhigh" || norm === "max") {
130
129
  emitProviderWarning({
131
130
  provider: "google",
132
131
  capability: "thinking level",
@@ -155,7 +154,7 @@ export function mapServiceTierToGoogle(tier: ServiceTier | undefined): "flex" |
155
154
  * Completions documents `reasoning_effort: xhigh|high|medium|low|minimal|none`
156
155
  * (parameters.md) and live probes accept `xhigh` verbatim (`03`), so — unlike
157
156
  * the discontinued Responses skin, which clamped `xhigh` — everything passes
158
- * through. `dynamic` omits (server default).
157
+ * through. `max` rides the same verbatim path. `dynamic` omits (server default).
159
158
  */
160
159
  export function mapThinkingLevelToOpenRouterChat(
161
160
  level: ThinkingLevel | string | undefined
@@ -169,7 +168,8 @@ export function mapThinkingLevelToOpenRouterChat(
169
168
  norm === "low" ||
170
169
  norm === "medium" ||
171
170
  norm === "high" ||
172
- norm === "xhigh"
171
+ norm === "xhigh" ||
172
+ norm === "max"
173
173
  ) {
174
174
  return { effort: norm };
175
175
  }
@@ -179,11 +179,11 @@ export function mapThinkingLevelToOpenRouterChat(
179
179
  /**
180
180
  * Maps a canonical ThinkingLevel onto OpenRouter Responses `reasoning.effort`.
181
181
  * Documented efforts: minimal|low|medium|high (server default medium).
182
- * Wire-validated extras: `none` disables reasoning output; `xhigh` is echoed
183
- * but is NOT a documented level, so it clamps to `high` with a warning.
182
+ * Wire-validated extras: `none` disables reasoning output; `xhigh`/`max` are
183
+ * echoed but are NOT documented levels, so they clamp to `high` with a warning.
184
184
  *
185
185
  * @deprecated The Responses skin is discontinued for OpenRouter
186
- * (`src/providers/openrouter-responses.ts`, archived). Use
186
+ * (removed from core). Use
187
187
  * {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
188
188
  * transport instead.
189
189
  */
@@ -194,7 +194,7 @@ export function mapThinkingLevelToOpenRouter(
194
194
  const norm = String(level).toLowerCase().trim();
195
195
  if (norm === "dynamic") return {}; // server default (medium)
196
196
  if (norm === "none") return { effort: "none" };
197
- if (norm === "xhigh") {
197
+ if (norm === "xhigh" || norm === "max") {
198
198
  emitProviderWarning({
199
199
  provider: "openrouter",
200
200
  capability: "thinking level",
@@ -327,7 +327,7 @@ export function mapToolChoiceToOpenRouter(
327
327
  * Maps a canonical ThinkingLevel onto OpenAI Responses `reasoning.effort`.
328
328
  * Documented efforts: none|minimal|low|medium|high|xhigh|max (server default
329
329
  * medium). Unlike OpenRouter (which clamps xhigh), OpenAI documents xhigh
330
- * natively so it passes through verbatim. `dynamic` omits (server default).
330
+ * and max natively so they pass through verbatim. `dynamic` omits (server default).
331
331
  */
332
332
  export function mapThinkingLevelToOpenAI(
333
333
  level: ThinkingLevel | string | undefined
@@ -341,7 +341,8 @@ export function mapThinkingLevelToOpenAI(
341
341
  norm === "low" ||
342
342
  norm === "medium" ||
343
343
  norm === "high" ||
344
- norm === "xhigh"
344
+ norm === "xhigh" ||
345
+ norm === "max"
345
346
  ) {
346
347
  return { effort: norm };
347
348
  }