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.
@@ -2,6 +2,19 @@ import type { TokenUsage } from "./core.ts";
2
2
  import type { ToolCallRecord, ToolResultRecord } from "./tool.ts";
3
3
  import type { ProviderRawData, ProviderId } from "./model.ts";
4
4
 
5
+ /** One logged step inside a sub-agent worker run (surfaced via `agent.track(id)`). */
6
+ export interface SubAgentStep {
7
+ turn: number;
8
+ type: "assistant" | "tool_call" | "tool_result";
9
+ name?: string;
10
+ text?: string;
11
+ thinking?: string;
12
+ isError?: boolean;
13
+ timestamp: number;
14
+ /** True while the worker is still streaming this step; finalized on turn end. */
15
+ partial?: boolean;
16
+ }
17
+
5
18
  /** Per-worker result and usage metadata attached to an AgentResponse. */
6
19
  export interface SubAgentExecutionMetadata {
7
20
  name: string;
@@ -20,6 +33,69 @@ export interface SubAgentExecutionMetadata {
20
33
  raw?: ProviderRawData;
21
34
  isError?: boolean;
22
35
  error?: string;
36
+ /** Stable display id: `SUBAGENT-NAME-{TrackingID}` (32-char hex, see `createTrackingId`). */
37
+ trackingId?: string;
38
+ /** Provider session id used by this worker (≤64 chars, affinity-safe). */
39
+ sessionId?: string;
40
+ /** Parent session this worker was spawned from. */
41
+ parentSessionId?: string;
42
+ /** Ordered step log for this worker (also persisted in the session file). */
43
+ steps?: SubAgentStep[];
44
+ }
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 };
23
99
  }
24
100
 
25
101
  /** JSON representation returned by AgentResponse.toJSON(). */
@@ -34,7 +110,9 @@ export interface AgentResponseJSON {
34
110
  responseId?: string;
35
111
  model: string;
36
112
  provider: ProviderId | string;
37
- finishReason?: string;
113
+ finishReason?: FinishReason;
114
+ /** Original vendor finish string when it differs from canonical `finishReason` (Q-04). */
115
+ rawFinishReason?: string;
38
116
  durationMs: number;
39
117
  raw: ProviderRawData;
40
118
  turns: number;
@@ -52,7 +130,9 @@ export class AgentResponse {
52
130
  readonly responseId?: string;
53
131
  readonly model: string;
54
132
  readonly provider: ProviderId | string;
55
- readonly finishReason?: string;
133
+ readonly finishReason?: FinishReason;
134
+ /** Original vendor finish string when it differs from canonical `finishReason` (Q-04). */
135
+ readonly rawFinishReason?: string;
56
136
  readonly durationMs: number;
57
137
  readonly raw: ProviderRawData;
58
138
  readonly turns: number;
@@ -75,7 +155,8 @@ export class AgentResponse {
75
155
  responseId?: string;
76
156
  model: string;
77
157
  provider: ProviderId | string;
78
- finishReason?: string;
158
+ finishReason?: FinishReason;
159
+ rawFinishReason?: string;
79
160
  durationMs: number;
80
161
  raw: ProviderRawData;
81
162
  turns?: number;
@@ -91,6 +172,7 @@ export class AgentResponse {
91
172
  this.model = data.model;
92
173
  this.provider = data.provider;
93
174
  this.finishReason = data.finishReason;
175
+ this.rawFinishReason = data.rawFinishReason;
94
176
  this.durationMs = data.durationMs;
95
177
  this.raw = data.raw;
96
178
  this.turns = data.turns ?? 1;
@@ -115,6 +197,7 @@ export class AgentResponse {
115
197
  model: this.model,
116
198
  provider: this.provider,
117
199
  finishReason: this.finishReason,
200
+ rawFinishReason: this.rawFinishReason,
118
201
  durationMs: this.durationMs,
119
202
  raw: this.raw,
120
203
  turns: this.turns,
@@ -135,7 +218,12 @@ export type StreamEventType =
135
218
  | "tool_call_delta"
136
219
  | "tool_call_complete"
137
220
  | "tool_result"
221
+ | "tool_start"
222
+ | "tool_end"
138
223
  | "subagent_complete"
224
+ | "subagent_delta"
225
+ | "steer_injected"
226
+ | "queued"
139
227
  | "usage"
140
228
  | "done"
141
229
  | "error";
@@ -143,16 +231,75 @@ export type StreamEventType =
143
231
  /** Payload for one streaming text, thinking, tool, usage, or lifecycle event. */
144
232
  export interface StreamEvent {
145
233
  type: StreamEventType;
234
+ /** Schema version for typed consumers (Q-40). Currently always 1 when present. */
235
+ schemaVersion?: 1;
146
236
  delta?: string;
147
237
  thinkingDelta?: string;
148
238
  toolCall?: ToolCallRecord;
149
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[];
150
244
  subagent?: SubAgentExecutionMetadata;
245
+ /** Tracking id of the worker emitting a `subagent_delta` event. */
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;
151
253
  usage?: TokenUsage;
152
254
  responseId?: string;
255
+ /** Canonical finish reason when known; vendor strings pass through as-is
256
+ * for provider compat (normalize with `normalizeFinishReason`). */
153
257
  finishReason?: string;
258
+ /** Original vendor finish string when it differs from canonical (Q-04). */
259
+ rawFinishReason?: string;
154
260
  error?: Error | unknown;
155
261
  partialText?: string;
156
262
  partialThinking?: string;
157
263
  raw?: ProviderRawData;
158
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;
@@ -9,6 +20,15 @@ export interface ToolExecutionContext {
9
20
  signal?: AbortSignal;
10
21
  /** Session identifier associated with this invocation. */
11
22
  sessionId?: string;
23
+ /** Forwards live sub-agent deltas (used by `spawn_subagents` for realtime tracking). */
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;
12
32
  }
13
33
 
14
34
  export type ToolExecuteFn<TInput = any, TOutput = any> = (
@@ -35,6 +55,57 @@ export interface ToolDefinition<TInput = any, TOutput = any> {
35
55
  maxConcurrency?: number | string;
36
56
  /** Function executed after validation; its result is made JSON-safe for model context. */
37
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";
38
109
  }
39
110
 
40
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