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.
@@ -43,6 +43,61 @@ export interface SubAgentExecutionMetadata {
43
43
  steps?: SubAgentStep[];
44
44
  }
45
45
 
46
+ /** Canonical finish reasons surfaced on AgentResponse (provider-agnostic).
47
+ * Vendors use varied strings (`end_turn`, `stop_sequence`, `max_tokens`,
48
+ * `tool_use`, ...); adapters map them to these values and preserve the
49
+ * original in `rawFinishReason`. Unknown non-empty reasons map to `"error"`.
50
+ * The `(string & {})` extension keeps vendor passthrough assignable at
51
+ * provider call sites that forward raw strings (normalize with
52
+ * `normalizeFinishReason` to canonicalize). */
53
+ export type FinishReason =
54
+ | "stop"
55
+ | "tool_calls"
56
+ | "length"
57
+ | "content_filter"
58
+ | "refusal"
59
+ | "max_turns"
60
+ | "error"
61
+ | (string & {});
62
+
63
+ /** Maps a vendor finish reason to canonical FinishReason + raw passthrough.
64
+ * Known aliases collapse to the canonical set; empty/undefined stays
65
+ * undefined; any other non-empty string maps to `"error"` with the original
66
+ * preserved in `rawFinishReason` (Q-04). */
67
+ export function normalizeFinishReason(raw?: string): {
68
+ finishReason?: FinishReason;
69
+ rawFinishReason?: string;
70
+ } {
71
+ if (raw === undefined || raw === null || raw === "") return {};
72
+ const r = String(raw);
73
+ const lower = r.toLowerCase();
74
+ const map: Record<string, FinishReason> = {
75
+ stop: "stop",
76
+ end_turn: "stop",
77
+ stop_sequence: "stop",
78
+ completed: "stop",
79
+ complete: "stop",
80
+ done: "stop",
81
+ tool_calls: "tool_calls",
82
+ tool_use: "tool_calls",
83
+ function_call: "tool_calls",
84
+ tool_call: "tool_calls",
85
+ length: "length",
86
+ max_tokens: "length",
87
+ max_turns: "max_turns",
88
+ content_filter: "content_filter",
89
+ refusal: "refusal",
90
+ refused: "refusal",
91
+ error: "error",
92
+ failed: "error",
93
+ cancelled: "error",
94
+ canceled: "error",
95
+ };
96
+ const mapped = map[lower] ?? map[r];
97
+ if (mapped) return r === mapped ? { finishReason: mapped } : { finishReason: mapped, rawFinishReason: r };
98
+ return { finishReason: "error", rawFinishReason: r };
99
+ }
100
+
46
101
  /** JSON representation returned by AgentResponse.toJSON(). */
47
102
  export interface AgentResponseJSON {
48
103
  text: string;
@@ -55,7 +110,9 @@ export interface AgentResponseJSON {
55
110
  responseId?: string;
56
111
  model: string;
57
112
  provider: ProviderId | string;
58
- finishReason?: string;
113
+ finishReason?: FinishReason;
114
+ /** Original vendor finish string when it differs from canonical `finishReason` (Q-04). */
115
+ rawFinishReason?: string;
59
116
  durationMs: number;
60
117
  raw: ProviderRawData;
61
118
  turns: number;
@@ -73,7 +130,9 @@ export class AgentResponse {
73
130
  readonly responseId?: string;
74
131
  readonly model: string;
75
132
  readonly provider: ProviderId | string;
76
- readonly finishReason?: string;
133
+ readonly finishReason?: FinishReason;
134
+ /** Original vendor finish string when it differs from canonical `finishReason` (Q-04). */
135
+ readonly rawFinishReason?: string;
77
136
  readonly durationMs: number;
78
137
  readonly raw: ProviderRawData;
79
138
  readonly turns: number;
@@ -96,7 +155,8 @@ export class AgentResponse {
96
155
  responseId?: string;
97
156
  model: string;
98
157
  provider: ProviderId | string;
99
- finishReason?: string;
158
+ finishReason?: FinishReason;
159
+ rawFinishReason?: string;
100
160
  durationMs: number;
101
161
  raw: ProviderRawData;
102
162
  turns?: number;
@@ -112,6 +172,7 @@ export class AgentResponse {
112
172
  this.model = data.model;
113
173
  this.provider = data.provider;
114
174
  this.finishReason = data.finishReason;
175
+ this.rawFinishReason = data.rawFinishReason;
115
176
  this.durationMs = data.durationMs;
116
177
  this.raw = data.raw;
117
178
  this.turns = data.turns ?? 1;
@@ -136,6 +197,7 @@ export class AgentResponse {
136
197
  model: this.model,
137
198
  provider: this.provider,
138
199
  finishReason: this.finishReason,
200
+ rawFinishReason: this.rawFinishReason,
139
201
  durationMs: this.durationMs,
140
202
  raw: this.raw,
141
203
  turns: this.turns,
@@ -156,8 +218,12 @@ export type StreamEventType =
156
218
  | "tool_call_delta"
157
219
  | "tool_call_complete"
158
220
  | "tool_result"
221
+ | "tool_start"
222
+ | "tool_end"
159
223
  | "subagent_complete"
160
224
  | "subagent_delta"
225
+ | "steer_injected"
226
+ | "queued"
161
227
  | "usage"
162
228
  | "done"
163
229
  | "error";
@@ -165,18 +231,75 @@ export type StreamEventType =
165
231
  /** Payload for one streaming text, thinking, tool, usage, or lifecycle event. */
166
232
  export interface StreamEvent {
167
233
  type: StreamEventType;
234
+ /** Schema version for typed consumers (Q-40). Currently always 1 when present. */
235
+ schemaVersion?: 1;
168
236
  delta?: string;
169
237
  thinkingDelta?: string;
170
238
  toolCall?: ToolCallRecord;
171
239
  toolResult?: ToolResultRecord;
240
+ /** Batch tool calls for `tool_start` events (Q-40). */
241
+ toolCalls?: ToolCallRecord[];
242
+ /** Batch tool results for `tool_end` events (Q-40). */
243
+ toolResults?: ToolResultRecord[];
172
244
  subagent?: SubAgentExecutionMetadata;
173
245
  /** Tracking id of the worker emitting a `subagent_delta` event. */
174
246
  subagentTrackingId?: string;
247
+ /** Steered prompt text injected via `steer()` (`steer_injected` events). */
248
+ injectedPrompt?: string;
249
+ /** Queued prompt text accepted as a next-turn follow-up (`queued` events). */
250
+ queuedPrompt?: string;
251
+ /** Pending queue length at the time of a `queued`/`steer_injected` event. */
252
+ queueLength?: number;
175
253
  usage?: TokenUsage;
176
254
  responseId?: string;
255
+ /** Canonical finish reason when known; vendor strings pass through as-is
256
+ * for provider compat (normalize with `normalizeFinishReason`). */
177
257
  finishReason?: string;
258
+ /** Original vendor finish string when it differs from canonical (Q-04). */
259
+ rawFinishReason?: string;
178
260
  error?: Error | unknown;
179
261
  partialText?: string;
180
262
  partialThinking?: string;
181
263
  raw?: ProviderRawData;
182
264
  }
265
+
266
+ /** Typed (discriminated) stream event schema, version 1 (Q-40).
267
+ * `StreamEvent` stays the permissive compat shape; use this union when you
268
+ * want exhaustive switching (e.g. `tool_start`/`tool_end` lifecycle). */
269
+ export type StreamEventV2 =
270
+ | { schemaVersion: 1; type: "start"; responseId?: string }
271
+ | { schemaVersion: 1; type: "text_start"; responseId?: string }
272
+ | { schemaVersion: 1; type: "text_delta"; delta: string; partialText?: string }
273
+ | { schemaVersion: 1; type: "text_end"; partialText?: string }
274
+ | { schemaVersion: 1; type: "thinking_start"; responseId?: string }
275
+ | { schemaVersion: 1; type: "thinking_delta"; thinkingDelta: string; partialThinking?: string }
276
+ | { schemaVersion: 1; type: "thinking_end"; partialThinking?: string }
277
+ | { schemaVersion: 1; type: "tool_call_start"; toolCall: ToolCallRecord }
278
+ | { schemaVersion: 1; type: "tool_call_delta"; toolCall: ToolCallRecord; delta?: string }
279
+ | { schemaVersion: 1; type: "tool_call_complete"; toolCall: ToolCallRecord }
280
+ | { schemaVersion: 1; type: "tool_result"; toolResult: ToolResultRecord }
281
+ | { schemaVersion: 1; type: "tool_start"; toolCalls: ToolCallRecord[] }
282
+ | { schemaVersion: 1; type: "tool_end"; toolResults: ToolResultRecord[] }
283
+ | { schemaVersion: 1; type: "subagent_complete"; subagent: SubAgentExecutionMetadata }
284
+ | {
285
+ schemaVersion: 1;
286
+ type: "subagent_delta";
287
+ subagentTrackingId: string;
288
+ delta?: string;
289
+ thinkingDelta?: string;
290
+ partialText?: string;
291
+ partialThinking?: string;
292
+ }
293
+ | { schemaVersion: 1; type: "steer_injected"; injectedPrompt: string; queueLength?: number }
294
+ | { schemaVersion: 1; type: "queued"; queuedPrompt: string; queueLength?: number }
295
+ | { schemaVersion: 1; type: "usage"; usage: TokenUsage }
296
+ | {
297
+ schemaVersion: 1;
298
+ type: "done";
299
+ delta?: string;
300
+ usage?: TokenUsage;
301
+ finishReason?: FinishReason | string;
302
+ rawFinishReason?: string;
303
+ responseId?: string;
304
+ }
305
+ | { schemaVersion: 1; type: "error"; error: Error | unknown };
package/src/types/tool.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import type { z } from "zod";
2
2
 
3
+ /** Minimal logger surface accepted by tool contexts (Q-54/Q-55, additive). */
4
+ export interface ToolLogger {
5
+ debug?: (...args: unknown[]) => void;
6
+ info?: (...args: unknown[]) => void;
7
+ warn?: (...args: unknown[]) => void;
8
+ error?: (...args: unknown[]) => void;
9
+ }
10
+
11
+ /** Live-event emitter handed to tools that stream progress (Q-55, additive). */
12
+ export type ToolEmitFn = (event: { type: string; [k: string]: unknown }) => void;
13
+
3
14
  export interface ToolExecutionContext {
4
15
  /** Unique ID for the model-generated invocation. */
5
16
  toolCallId: string;
@@ -11,6 +22,13 @@ export interface ToolExecutionContext {
11
22
  sessionId?: string;
12
23
  /** Forwards live sub-agent deltas (used by `spawn_subagents` for realtime tracking). */
13
24
  onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
25
+ // Q-55 Tool API v2 context additions (all optional, additive).
26
+ /** Stable id for this run (`newRunId`). Mirrors SubAgentRunIds.runId. */
27
+ runId?: string;
28
+ /** Live-event emitter for tools that stream progress. */
29
+ emit?: ToolEmitFn;
30
+ /** Structured logger for tool implementations (defaults to no-op). */
31
+ logger?: ToolLogger;
14
32
  }
15
33
 
16
34
  export type ToolExecuteFn<TInput = any, TOutput = any> = (
@@ -37,6 +55,57 @@ export interface ToolDefinition<TInput = any, TOutput = any> {
37
55
  maxConcurrency?: number | string;
38
56
  /** Function executed after validation; its result is made JSON-safe for model context. */
39
57
  execute: ToolExecuteFn<TInput, TOutput>;
58
+ // Q-55 Tool API v2 additions (all optional, additive; loop reads via `(toolDef as any).*`).
59
+ /**
60
+ * Canonical alias for `maxTries`. When both are set, `maxTries` wins.
61
+ * Normalized by `normalizeMaxAttempts` (see tools/tool.ts).
62
+ */
63
+ maxAttempts?: number | string;
64
+ /**
65
+ * Whether this tool is safe to retry on transient failures. Retry defaults
66
+ * to a single attempt unless this is `true` (bounded retries) or `maxTries`/
67
+ * `maxAttempts` explicitly overrides the attempt count.
68
+ */
69
+ idempotent?: boolean;
70
+ /**
71
+ * Explicit retry policy. Preferred over bare `maxTries` for new code;
72
+ * `maxTries`/`maxAttempts` map to `{ maxRetries: n - 1 }`.
73
+ */
74
+ retry?: { maxRetries?: number; baseDelayMs?: number; maxDelayMs?: number };
75
+ /**
76
+ * Whether an identical call may repeat across consecutive turns. Defaults to
77
+ * `true` for idempotent tools, `false` otherwise. Honored by repeat guards.
78
+ */
79
+ repeatable?: boolean;
80
+ /**
81
+ * Maximum characters of a string tool result kept for model context.
82
+ * Longer strings are truncated with a `[Truncated: ...]` marker.
83
+ * `0`/omitted uses the executor default (20000).
84
+ */
85
+ maxResultChars?: number | string;
86
+ /** Optional hook to redact/replace the error message sent back to the model. */
87
+ redactError?: (err: unknown) => string;
88
+ /**
89
+ * Canonical alias for `redactError`. When both are set, `redactError` wins.
90
+ * Kept short for Q-55 Tool API v2 parity.
91
+ */
92
+ redact?: (err: unknown) => string;
93
+ /** Free-form tags for grouping/filtering tools (e.g. `["web", "readonly"]`). */
94
+ tags?: string[];
95
+ /**
96
+ * When true, the host must approve this call before execution (human-in-the-
97
+ * loop). The loop reads via `(toolDef as any).needsApproval`; enforcement
98
+ * is wired by the host, default open (undefined = no gate).
99
+ */
100
+ needsApproval?: boolean;
101
+ /**
102
+ * Optional output schema (Zod or JSON Schema) used to validate/coerce the
103
+ * tool's return value before it reaches model context. Stored as `unknown`
104
+ * so `zod` stays an optional peer for type-only consumers.
105
+ */
106
+ output?: unknown;
107
+ /** Opaque brand marker narrowing bare object literals to `tool()` output. */
108
+ readonly __brand?: "ToolDefinition";
40
109
  }
41
110
 
42
111
  export interface StandardToolDeclaration {
@@ -8,10 +8,12 @@
8
8
  * bun src/update-models.ts --ttl=24h
9
9
  */
10
10
 
11
- import { refreshModelCatalog, getCatalogStatus } from "./index.ts";
11
+ import { refreshModelCatalog } from "./index.ts";
12
+ import { ConfigError } from "./types/errors.ts";
12
13
 
13
14
  const args = process.argv.slice(2);
14
- const force = args.includes("--force") || !args.some((a) => a.startsWith("--ttl"));
15
+ // Q-34: force is true only with explicit --force (previously also true when no --ttl).
16
+ const force = args.includes("--force");
15
17
 
16
18
  let customTtlMs: number | undefined;
17
19
  for (const arg of args) {
@@ -24,6 +26,10 @@ for (const arg of args) {
24
26
  } else {
25
27
  customTtlMs = parseFloat(val);
26
28
  }
29
+ // Q-34: reject non-numeric TTLs (e.g. --ttl=abc) instead of silently caching NaN.
30
+ if (!Number.isFinite(customTtlMs)) {
31
+ throw new ConfigError(`Invalid --ttl value "${val}": expected a number with optional h/m suffix (e.g. --ttl=24h).`);
32
+ }
27
33
  }
28
34
  }
29
35
 
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import type { CacheRetention } from "../types/core.ts";
11
- import type { ModelSpec } from "../types/model.ts";
12
11
  import { getApiKey } from "./env.ts";
13
12
 
14
13
  const CACHE_KEY_MAX = 64;
@@ -144,3 +143,71 @@ export async function createExplicitCache(
144
143
 
145
144
  return (await response.json()) as CachedContentMetadata;
146
145
  }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Central redaction helpers (Q-11, temporary home to avoid owner conflicts).
149
+ // Providers must call these instead of maintaining local deny-lists.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** Header-name deny-list (case-insensitive substring match). */
153
+ const REDACT_HEADER_SUBSTRINGS = ["key", "token", "secret", "cookie", "authorization"];
154
+
155
+ /** Secret-looking value prefixes. */
156
+ const REDACT_VALUE_PREFIXES = ["sk-", "AIza", "gsk-", "xox-"];
157
+
158
+ function headerNameIsSensitive(name: string): boolean {
159
+ const lower = String(name ?? "").toLowerCase();
160
+ return REDACT_HEADER_SUBSTRINGS.some((s) => lower.includes(s));
161
+ }
162
+
163
+ function valueLooksSensitive(value: unknown): boolean {
164
+ if (typeof value !== "string") return false;
165
+ const v = value.trim();
166
+ if (REDACT_VALUE_PREFIXES.some((p) => v.includes(p))) return true;
167
+ // Bearer-style long tokens: "Bearer <20+ chars>"
168
+ if (/^bearer\s+\S{20,}/i.test(v)) return true;
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Redacts a single value: sensitive strings become "[REDACTED]" (Q-11).
174
+ */
175
+ export function redactValue(value: unknown): unknown {
176
+ if (typeof value !== "string") return value;
177
+ if (valueLooksSensitive(value)) return "[REDACTED]";
178
+ return value;
179
+ }
180
+
181
+ /**
182
+ * Redacts sensitive header values (Q-11). Returns a shallow copy; input untouched.
183
+ * Deny-list (name contains): *key*, *token*, *secret*, cookie, authorization.
184
+ * Value patterns (redacted regardless of name): sk-, AIza, gsk-, xox-.
185
+ */
186
+ export function redactHeadersCentral(
187
+ headers: Record<string, unknown> | undefined | null
188
+ ): Record<string, unknown> {
189
+ if (!headers || typeof headers !== "object") return {};
190
+ const out: Record<string, unknown> = {};
191
+ for (const [k, v] of Object.entries(headers)) {
192
+ if (headerNameIsSensitive(k) || valueLooksSensitive(v)) out[k] = "[REDACTED]";
193
+ else out[k] = v;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Redacts secret-looking substrings inside free text (Q-11).
200
+ * Replaces `sk-...`, `AIza...`, `gsk-...`, `xox-...` tokens with "[REDACTED]".
201
+ */
202
+ export function redactText(text: string): string {
203
+ if (typeof text !== "string" || !text) return text;
204
+ // Note: longer prefixes (gsk-/gsk_) first so the generic sk- pattern
205
+ // does not partially match inside them (e.g. "gsk-abc" -> one token).
206
+ return text
207
+ .replace(/gsk_[A-Za-z0-9]{8,}/g, "[REDACTED]")
208
+ .replace(/gsk-[A-Za-z0-9-_]{8,}/g, "[REDACTED]")
209
+ .replace(/xox-[A-Za-z0-9-]{8,}/g, "[REDACTED]")
210
+ .replace(/AIza[A-Za-z0-9-_]{10,}/g, "[REDACTED]")
211
+ .replace(/sk-[A-Za-z0-9-_]{8,}/g, "[REDACTED]")
212
+ .replace(/Bearer\s+[A-Za-z0-9\-._~+/=]{20,}/gi, "Bearer [REDACTED]");
213
+ }
package/src/utils/env.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Safe environment lookup for API keys and configurations
3
3
  */
4
4
  /**
5
- * Reads an environment/global value without assuming a Node-only runtime.
5
+ * Reads an environment value. Process env only. No globalThis fallback:
6
+ * DOM-clobberable in browsers (Q-05).
6
7
  *
7
8
  * @example `const apiKey = getEnv("OPENAI_API_KEY");`
8
9
  */
@@ -10,9 +11,6 @@ export function getEnv(key: string, fallback?: string): string | undefined {
10
11
  if (typeof process !== "undefined" && process.env && process.env[key]) {
11
12
  return process.env[key];
12
13
  }
13
- if (typeof globalThis !== "undefined" && (globalThis as any)[key]) {
14
- return (globalThis as any)[key];
15
- }
16
14
  return fallback;
17
15
  }
18
16
 
@@ -53,8 +51,6 @@ export function getApiKey(provider: string, explicitKey?: string, env?: Provider
53
51
  return getEnv("OPENROUTER_API_KEY");
54
52
  case "openai":
55
53
  return getEnv("OPENAI_BASE_API_KEY") || getEnv("OPENAI_API_KEY");
56
- case "anthropic":
57
- return getEnv("ANTHROPIC_API_KEY");
58
54
  default:
59
55
  return getEnv(`${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`);
60
56
  }
@@ -18,14 +18,117 @@ export function isBrowserRuntime(): boolean {
18
18
  }
19
19
  }
20
20
 
21
+ /** Package version for User-Agent strings. Reads the npm-injected env first. */
22
+ export function getPackageVersion(): string {
23
+ try {
24
+ const v = (globalThis as any).process?.env?.npm_package_version;
25
+ if (typeof v === "string" && v) return v;
26
+ } catch {}
27
+ return "0.4.0";
28
+ }
29
+
21
30
  function getAgentAccelUserAgent(): string {
31
+ const version = getPackageVersion();
22
32
  try {
23
33
  const os = (globalThis as any).process?.getBuiltinModule?.("node:os") ?? null;
24
34
  if (os) {
25
- return `agent-accel (${os.platform()} ${os.release()}; ${os.arch()})`;
35
+ return `agent-accel/${version} (${os.platform()} ${os.release()}; ${os.arch()})`;
26
36
  }
27
37
  } catch {}
28
- return "agent-accel (linux; x64)";
38
+ return `agent-accel/${version} (linux; x64)`;
39
+ }
40
+
41
+ // Re-exported for tests/diagnostics (unused by wire headers directly).
42
+ export const AGENT_ACCEL_USER_AGENT = getAgentAccelUserAgent;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Attribution (Q-17): configurable, default none except OpenRouter legacy.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Configurable attribution headers (OpenRouter `HTTP-Referer` / `X-Title`). */
49
+ export interface AttributionConfig {
50
+ referer?: string;
51
+ title?: string;
52
+ userAgent?: string;
53
+ }
54
+
55
+ let attribution: AttributionConfig = {};
56
+
57
+ /**
58
+ * Configures global attribution headers. Defaults to none (no hardcoded
59
+ * site); OpenRouter keeps its legacy `sashvat.com` / `Agent Accelerator`
60
+ * fallback when no custom attribution is set (backward compat).
61
+ * @example `setAttribution({ referer: "https://example.com", title: "My App" });`
62
+ */
63
+ export function setAttribution(cfg: AttributionConfig): void {
64
+ attribution = { ...attribution, ...cfg };
65
+ if (cfg.referer === undefined && cfg.title === undefined && cfg.userAgent === undefined) {
66
+ // Explicit empty reset: `setAttribution({})` clears prior values.
67
+ if (Object.keys(cfg).length === 0) attribution = {};
68
+ }
69
+ }
70
+
71
+ /** Returns a copy of the current attribution config (default `{}`). */
72
+ export function getAttribution(): AttributionConfig {
73
+ return { ...attribution };
74
+ }
75
+
76
+ /** First-class providers with documented session-affinity headers. */
77
+ const FIRST_CLASS = new Set(["google", "openai", "openrouter"]);
78
+
79
+ /** Case-insensitive header lookup. */
80
+ function findHeaderKey(headers: Record<string, string>, name: string): string | undefined {
81
+ const lower = name.toLowerCase();
82
+ for (const k of Object.keys(headers)) {
83
+ if (k.toLowerCase() === lower) return k;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Sets a header case-insensitively (removes any case-variant duplicate
90
+ * first). Use when applying canonical `Authorization` / `Content-Type` over
91
+ * user-supplied `customHeaders` so `authorization` + `Authorization` never
92
+ * coexist on the wire.
93
+ * @example `setHeaderCaseInsensitive(headers, "Authorization", "Bearer k");`
94
+ */
95
+ export function setHeaderCaseInsensitive(
96
+ headers: Record<string, string>,
97
+ name: string,
98
+ value: string
99
+ ): void {
100
+ const existing = findHeaderKey(headers, name);
101
+ if (existing !== undefined && existing !== name) delete headers[existing];
102
+ headers[name] = value;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Thought-signature namespacing (Q-18).
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Tags a captured thought signature with its issuing provider so history
111
+ * builders echo it only on the same provider. Untagged (legacy) signatures
112
+ * echo everywhere for backward compat.
113
+ */
114
+ export function withProviderSignature(
115
+ sig: string | undefined,
116
+ provider: string
117
+ ): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
118
+ if (!sig) return {};
119
+ return { thoughtSignature: sig, thoughtSignatureProvider: provider };
120
+ }
121
+
122
+ /**
123
+ * Returns true when a stored signature may be echoed on `currentProvider`:
124
+ * untagged (legacy) echoes everywhere, tagged echoes only on a match.
125
+ */
126
+ export function shouldEchoSignature(
127
+ storedProvider: unknown,
128
+ currentProvider: string
129
+ ): boolean {
130
+ if (!storedProvider) return true;
131
+ return storedProvider === currentProvider;
29
132
  }
30
133
 
31
134
  const BROWSER_DROPPED = new Set([
@@ -48,6 +151,13 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
48
151
  /**
49
152
  * Builds provider-specific session, cache-affinity, and attribution headers.
50
153
  *
154
+ * Q-17: attribution is configurable via `setAttribution()` (default none).
155
+ * OpenRouter keeps its legacy `https://sashvat.com` / `Agent Accelerator`
156
+ * fallback when no custom attribution is set. Custom (non-first-class)
157
+ * prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
158
+ * (no `session_id` alias, no attribution) when a session id is present.
159
+ * `customHeaders` merge case-insensitively against canonical names.
160
+ *
51
161
  * Browser note: custom `x-*` session/affinity headers force a CORS preflight
52
162
  * (`OPTIONS`) and providers only allow-list their own documented headers, so a
53
163
  * preflight failure surfaces as a bare `TypeError: Failed to fetch`. In browser
@@ -63,35 +173,59 @@ export function buildSessionHeaders(
63
173
  explicitSessionId?: string
64
174
  ): Record<string, string> {
65
175
  const browser = isBrowserRuntime();
66
- const headers: Record<string, string> = {
67
- ...(browser ? {} : { "User-Agent": "Agent-Accelerator/1.0" }),
68
- ...(customHeaders ?? {}),
69
- };
176
+ const attr = getAttribution();
177
+ const headers: Record<string, string> = {};
178
+ if (!browser) {
179
+ setHeaderCaseInsensitive(headers, "User-Agent", attr.userAgent ?? `Agent-Accelerator/${getPackageVersion()}`);
180
+ }
181
+ for (const [k, v] of Object.entries(customHeaders ?? {})) {
182
+ const existing = findHeaderKey(headers, k);
183
+ if (existing !== undefined && existing !== k) delete headers[existing];
184
+ headers[k] = v;
185
+ }
70
186
 
71
187
  const rawSessionId = explicitSessionId || cache?.sessionId;
72
188
  const sessionId = clampCacheKey(rawSessionId);
189
+ const isFirstClass = FIRST_CLASS.has(provider);
190
+
191
+ const getHeader = (name: string): string | undefined => {
192
+ const key = findHeaderKey(headers, name);
193
+ return key !== undefined ? headers[key] : undefined;
194
+ };
73
195
 
74
196
  if (sessionId && !browser) {
75
197
  if (provider === "openrouter") {
76
- headers["x-session-id"] = sessionId;
77
- headers["x-client-request-id"] = sessionId;
78
- headers["HTTP-Referer"] = "https://sashvat.com";
79
- headers["X-Title"] = "Agent Accelerator";
198
+ setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
199
+ setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
200
+ const referer = attr.referer ?? "https://sashvat.com";
201
+ const title = attr.title ?? "Agent Accelerator";
202
+ if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
203
+ if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
80
204
  } else if (provider === "google") {
81
- headers["x-goog-api-client"] = "agent-accel/1.0";
82
- headers["x-session-id"] = sessionId;
83
- headers["x-client-request-id"] = sessionId;
205
+ if (getHeader("x-goog-api-client") === undefined) {
206
+ headers["x-goog-api-client"] = "agent-accel/1.0";
207
+ }
208
+ setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
209
+ setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
210
+ } else if (isFirstClass) {
211
+ setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
212
+ setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
213
+ setHeaderCaseInsensitive(headers, "session_id", sessionId);
84
214
  } else {
85
- headers["x-session-id"] = sessionId;
86
- headers["x-client-request-id"] = sessionId;
87
- headers["session_id"] = sessionId;
215
+ // Custom OpenAI-compatible prefix: headers-only affinity. Bodies carry
216
+ // no affinity key on strict endpoints, so only the minimal pair is
217
+ // sent (no `session_id` alias, no attribution).
218
+ setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
219
+ setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
88
220
  }
89
221
  } else if (provider === "openrouter") {
90
- if (!headers["HTTP-Referer"]) headers["HTTP-Referer"] = "https://sashvat.com";
91
- if (!headers["X-Title"]) headers["X-Title"] = "Agent Accelerator";
222
+ const referer = attr.referer ?? "https://sashvat.com";
223
+ const title = attr.title ?? "Agent Accelerator";
224
+ if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
225
+ if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
92
226
  }
93
227
 
94
- if (provider === "google" && !browser && !headers["x-goog-api-client"]) {
228
+ if (provider === "google" && !browser && getHeader("x-goog-api-client") === undefined) {
95
229
  headers["x-goog-api-client"] = "agent-accel/1.0";
96
230
  }
97
231