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.
- package/package.json +1 -1
- package/src/agent/agent.ts +830 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +711 -109
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +154 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +97 -18
- package/src/providers/google.ts +99 -39
- package/src/providers/openai-compat.ts +140 -80
- package/src/providers/openai.ts +149 -69
- package/src/providers/openrouter.ts +106 -71
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +132 -0
- package/src/providers.ts +3 -4
- package/src/session/store.ts +512 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +229 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +1 -1
- package/src/types/response.ts +126 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +2 -6
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/types/response.ts
CHANGED
|
@@ -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?:
|
|
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?:
|
|
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?:
|
|
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 {
|
package/src/update-models.ts
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* bun src/update-models.ts --ttl=24h
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { refreshModelCatalog
|
|
11
|
+
import { refreshModelCatalog } from "./index.ts";
|
|
12
|
+
import { ConfigError } from "./types/errors.ts";
|
|
12
13
|
|
|
13
14
|
const args = process.argv.slice(2);
|
|
14
|
-
|
|
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
|
|
package/src/utils/cache.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/utils/headers.ts
CHANGED
|
@@ -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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
77
|
-
headers
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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 &&
|
|
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
|
|