@curatedmcp/tokenshield-core 0.2.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.
Files changed (61) hide show
  1. package/dist/index.d.ts +15 -0
  2. package/dist/index.js +11 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/ledger.d.ts +33 -0
  5. package/dist/ledger.js +141 -0
  6. package/dist/ledger.js.map +1 -0
  7. package/dist/pricing.d.ts +5 -0
  8. package/dist/pricing.js +83 -0
  9. package/dist/pricing.js.map +1 -0
  10. package/dist/processors/conversation-dedup.d.ts +23 -0
  11. package/dist/processors/conversation-dedup.js +71 -0
  12. package/dist/processors/conversation-dedup.js.map +1 -0
  13. package/dist/processors/pipeline.d.ts +10 -0
  14. package/dist/processors/pipeline.js +89 -0
  15. package/dist/processors/pipeline.js.map +1 -0
  16. package/dist/processors/response-cache.d.ts +53 -0
  17. package/dist/processors/response-cache.js +129 -0
  18. package/dist/processors/response-cache.js.map +1 -0
  19. package/dist/processors/types.d.ts +54 -0
  20. package/dist/processors/types.js +2 -0
  21. package/dist/processors/types.js.map +1 -0
  22. package/dist/providers/anthropic.d.ts +6 -0
  23. package/dist/providers/anthropic.js +216 -0
  24. package/dist/providers/anthropic.js.map +1 -0
  25. package/dist/providers/registry.d.ts +4 -0
  26. package/dist/providers/registry.js +7 -0
  27. package/dist/providers/registry.js.map +1 -0
  28. package/dist/providers/types.d.ts +79 -0
  29. package/dist/providers/types.js +2 -0
  30. package/dist/providers/types.js.map +1 -0
  31. package/dist/proxy/anthropic-passthrough.d.ts +13 -0
  32. package/dist/proxy/anthropic-passthrough.js +363 -0
  33. package/dist/proxy/anthropic-passthrough.js.map +1 -0
  34. package/dist/proxy/sse.d.ts +20 -0
  35. package/dist/proxy/sse.js +59 -0
  36. package/dist/proxy/sse.js.map +1 -0
  37. package/dist/proxy/usage.d.ts +25 -0
  38. package/dist/proxy/usage.js +82 -0
  39. package/dist/proxy/usage.js.map +1 -0
  40. package/dist/server.d.ts +18 -0
  41. package/dist/server.js +130 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/types.d.ts +36 -0
  44. package/dist/types.js +2 -0
  45. package/dist/types.js.map +1 -0
  46. package/package.json +38 -0
  47. package/src/index.ts +31 -0
  48. package/src/ledger.ts +232 -0
  49. package/src/pricing.ts +93 -0
  50. package/src/processors/conversation-dedup.ts +77 -0
  51. package/src/processors/pipeline.ts +104 -0
  52. package/src/processors/response-cache.ts +161 -0
  53. package/src/processors/types.ts +58 -0
  54. package/src/providers/anthropic.ts +236 -0
  55. package/src/providers/registry.ts +10 -0
  56. package/src/providers/types.ts +87 -0
  57. package/src/proxy/anthropic-passthrough.ts +393 -0
  58. package/src/proxy/sse.ts +58 -0
  59. package/src/proxy/usage.ts +98 -0
  60. package/src/server.ts +154 -0
  61. package/src/types.ts +47 -0
@@ -0,0 +1,104 @@
1
+ import type { Conversation } from "../providers/types.js";
2
+ import type {
3
+ Processor,
4
+ ProcessorContext,
5
+ ProcessorEffect,
6
+ PipelineOptions,
7
+ PipelineRunResult,
8
+ } from "./types.js";
9
+
10
+ interface BreakerState {
11
+ failuresInWindow: number;
12
+ windowStart: number;
13
+ trippedUntil: number;
14
+ }
15
+
16
+ /**
17
+ * Per-process circuit breaker. If a processor throws N times within W ms,
18
+ * disable it for D ms. Bounded recovery so a transient bug doesn't cost
19
+ * the user the whole session.
20
+ */
21
+ class CircuitBreaker {
22
+ private states = new Map<string, BreakerState>();
23
+ constructor(
24
+ private readonly threshold = 3,
25
+ private readonly windowMs = 60_000,
26
+ private readonly cooldownMs = 5 * 60_000,
27
+ ) {}
28
+
29
+ isOpen(id: string): boolean {
30
+ const s = this.states.get(id);
31
+ return s !== undefined && s.trippedUntil > Date.now();
32
+ }
33
+
34
+ recordFailure(id: string): void {
35
+ const now = Date.now();
36
+ let s = this.states.get(id);
37
+ if (s === undefined) {
38
+ s = { failuresInWindow: 0, windowStart: now, trippedUntil: 0 };
39
+ this.states.set(id, s);
40
+ }
41
+ if (now - s.windowStart > this.windowMs) {
42
+ s.windowStart = now;
43
+ s.failuresInWindow = 0;
44
+ }
45
+ s.failuresInWindow++;
46
+ if (s.failuresInWindow >= this.threshold) {
47
+ s.trippedUntil = now + this.cooldownMs;
48
+ }
49
+ }
50
+
51
+ recordSuccess(id: string): void {
52
+ const s = this.states.get(id);
53
+ if (s === undefined) return;
54
+ if (s.failuresInWindow > 0) s.failuresInWindow = Math.max(0, s.failuresInWindow - 1);
55
+ }
56
+ }
57
+
58
+ export class Pipeline {
59
+ private readonly processors: Processor[];
60
+ private readonly enabled: Set<string>;
61
+ private readonly breaker = new CircuitBreaker();
62
+
63
+ constructor(opts: PipelineOptions) {
64
+ this.processors = opts.processors;
65
+ this.enabled = opts.enabled;
66
+ }
67
+
68
+ run(input: Conversation, ctx: ProcessorContext, sizeOf: (c: Conversation) => number): PipelineRunResult {
69
+ const bytesIn = sizeOf(input);
70
+ let current = input;
71
+ const effects: ProcessorEffect[] = [];
72
+ const errors: Array<{ processor: string; message: string }> = [];
73
+
74
+ for (const p of this.processors) {
75
+ if (!this.enabled.has(p.id)) continue;
76
+ if (this.breaker.isOpen(p.id)) continue;
77
+ try {
78
+ const result = p.onRequest(current, ctx);
79
+ if (result.conversation !== current) {
80
+ current = result.conversation;
81
+ }
82
+ for (const e of result.effects) effects.push(e);
83
+ this.breaker.recordSuccess(p.id);
84
+ } catch (err) {
85
+ this.breaker.recordFailure(p.id);
86
+ errors.push({
87
+ processor: p.id,
88
+ message: err instanceof Error ? err.message : String(err),
89
+ });
90
+ // current is unchanged — fail-open
91
+ }
92
+ }
93
+
94
+ return {
95
+ conversation: current,
96
+ effects,
97
+ bytesIn,
98
+ bytesOut: sizeOf(current),
99
+ errors,
100
+ };
101
+ }
102
+ }
103
+
104
+ export type { Processor, ProcessorContext, ProcessorEffect } from "./types.js";
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalize } from "../providers/anthropic.js";
3
+
4
+ interface CacheEntry {
5
+ status: number;
6
+ headers: Record<string, string>;
7
+ body: Buffer;
8
+ storedAt: number;
9
+ expiresAt: number;
10
+ /** Approx response body size in bytes — used for LRU eviction. */
11
+ bytes: number;
12
+ /** Usage as reported by upstream when the response was first cached. */
13
+ usage: { inputTokens: number; outputTokens: number };
14
+ /** Model from the cached response. */
15
+ model: string;
16
+ }
17
+
18
+ export interface CacheHit {
19
+ status: number;
20
+ headers: Record<string, string>;
21
+ body: Buffer;
22
+ usage: { inputTokens: number; outputTokens: number };
23
+ model: string;
24
+ cachedAgoMs: number;
25
+ cachedBytes: number;
26
+ }
27
+
28
+ /**
29
+ * Tiny, conservative response cache for the Anthropic JSON endpoint.
30
+ *
31
+ * Caches IFF: temperature === 0 AND stream === false. Anthropic only guarantees
32
+ * deterministic outputs under these conditions, so caching anything else risks
33
+ * serving a stale response a user wouldn't expect.
34
+ *
35
+ * Bounded by total byte budget (default 64 MB) with LRU eviction.
36
+ * Default TTL: 10 minutes.
37
+ */
38
+ export class ResponseCache {
39
+ private readonly map = new Map<string, CacheEntry>();
40
+ private currentBytes = 0;
41
+ private hits = 0;
42
+ private misses = 0;
43
+
44
+ constructor(
45
+ private readonly maxBytes = 64 * 1024 * 1024,
46
+ private readonly defaultTtlMs = 10 * 60 * 1000,
47
+ ) {}
48
+
49
+ private static keyFor(body: unknown): string | null {
50
+ if (!body || typeof body !== "object") return null;
51
+ const obj = body as Record<string, unknown>;
52
+ if (obj["stream"] === true) return null;
53
+ if (obj["temperature"] !== 0) return null;
54
+ return createHash("sha256")
55
+ .update(
56
+ canonicalize({
57
+ model: obj["model"] ?? null,
58
+ system: obj["system"] ?? null,
59
+ tools: obj["tools"] ?? null,
60
+ tool_choice: obj["tool_choice"] ?? null,
61
+ messages: obj["messages"] ?? [],
62
+ max_tokens: obj["max_tokens"] ?? null,
63
+ temperature: obj["temperature"] ?? null,
64
+ top_p: obj["top_p"] ?? null,
65
+ top_k: obj["top_k"] ?? null,
66
+ stop_sequences: obj["stop_sequences"] ?? null,
67
+ }),
68
+ )
69
+ .digest("hex");
70
+ }
71
+
72
+ /** Returns a hit if the body is cacheable AND fresh; otherwise null. */
73
+ lookup(body: unknown): CacheHit | null {
74
+ const key = ResponseCache.keyFor(body);
75
+ if (key === null) return null;
76
+ const entry = this.map.get(key);
77
+ if (entry === undefined) {
78
+ this.misses++;
79
+ return null;
80
+ }
81
+ const now = Date.now();
82
+ if (entry.expiresAt < now) {
83
+ this.evict(key, entry);
84
+ this.misses++;
85
+ return null;
86
+ }
87
+ // LRU: refresh insertion order
88
+ this.map.delete(key);
89
+ this.map.set(key, entry);
90
+ this.hits++;
91
+ return {
92
+ status: entry.status,
93
+ headers: entry.headers,
94
+ body: entry.body,
95
+ usage: entry.usage,
96
+ model: entry.model,
97
+ cachedAgoMs: now - entry.storedAt,
98
+ cachedBytes: entry.bytes,
99
+ };
100
+ }
101
+
102
+ /** Store a response for a cacheable request. No-op if request isn't cacheable. */
103
+ store(
104
+ body: unknown,
105
+ response: {
106
+ status: number;
107
+ headers: Record<string, string>;
108
+ body: Buffer;
109
+ usage: { inputTokens: number; outputTokens: number };
110
+ model: string;
111
+ },
112
+ ttlMs?: number,
113
+ ): void {
114
+ const key = ResponseCache.keyFor(body);
115
+ if (key === null) return;
116
+ // Only cache 2xx; 4xx/5xx are likely transient or user errors
117
+ if (response.status < 200 || response.status >= 300) return;
118
+ const bytes = response.body.length;
119
+ if (bytes > this.maxBytes / 4) return; // skip absurdly large bodies
120
+ this.evictIfNeeded(bytes);
121
+ const now = Date.now();
122
+ const entry: CacheEntry = {
123
+ status: response.status,
124
+ headers: { ...response.headers },
125
+ body: response.body,
126
+ storedAt: now,
127
+ expiresAt: now + (ttlMs ?? this.defaultTtlMs),
128
+ bytes,
129
+ usage: response.usage,
130
+ model: response.model,
131
+ };
132
+ // Drop pre-existing entry under the same key (refresh)
133
+ const prior = this.map.get(key);
134
+ if (prior !== undefined) {
135
+ this.currentBytes -= prior.bytes;
136
+ this.map.delete(key);
137
+ }
138
+ this.map.set(key, entry);
139
+ this.currentBytes += bytes;
140
+ }
141
+
142
+ private evictIfNeeded(incomingBytes: number): void {
143
+ while (this.currentBytes + incomingBytes > this.maxBytes && this.map.size > 0) {
144
+ const oldest = this.map.keys().next();
145
+ if (oldest.done === true) break;
146
+ const key = oldest.value;
147
+ const e = this.map.get(key);
148
+ if (e === undefined) break;
149
+ this.evict(key, e);
150
+ }
151
+ }
152
+
153
+ private evict(key: string, entry: CacheEntry): void {
154
+ this.currentBytes -= entry.bytes;
155
+ this.map.delete(key);
156
+ }
157
+
158
+ stats(): { hits: number; misses: number; entries: number; bytes: number } {
159
+ return { hits: this.hits, misses: this.misses, entries: this.map.size, bytes: this.currentBytes };
160
+ }
161
+ }
@@ -0,0 +1,58 @@
1
+ import type { Conversation } from "../providers/types.js";
2
+ import type { ProviderId } from "../providers/types.js";
3
+
4
+ export interface ProcessorContext {
5
+ providerId: ProviderId;
6
+ /** Stable fingerprint of the conversation (system + first user message hash). */
7
+ conversationFingerprint: string;
8
+ /** Inbound request raw bytes (read-only). */
9
+ inboundBytes: number;
10
+ }
11
+
12
+ export interface ProcessorEffect {
13
+ /** Stable identifier for accounting (e.g. "conversation-dedup"). */
14
+ name: string;
15
+ /** Bytes saved by this processor's modifications. */
16
+ bytesSaved: number;
17
+ /** Optional structured detail for the dashboard / diff-mode. */
18
+ detail?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface ProcessorResult {
22
+ /** Updated conversation (may be a new object; processors must return one). */
23
+ conversation: Conversation;
24
+ /** Effects applied (zero or one entry per call). */
25
+ effects: ProcessorEffect[];
26
+ }
27
+
28
+ /**
29
+ * A request-side processor inspects + rewrites the conversation BEFORE it goes
30
+ * upstream. It must be:
31
+ * - deterministic: same input → same output, so prompt caching still hits
32
+ * - fail-open: any throw is caught by the pipeline; original conversation is preserved
33
+ * - bounded: never adds unbounded latency (no network calls in v0.2)
34
+ */
35
+ export interface Processor {
36
+ readonly id: string;
37
+ readonly enabledByDefault: boolean;
38
+
39
+ /**
40
+ * Mutate the conversation if appropriate. MUST return a new conversation
41
+ * (or the same reference if no change). Any throw is treated as a soft
42
+ * failure: the pipeline reverts to the input and trips the breaker.
43
+ */
44
+ onRequest(conversation: Conversation, ctx: ProcessorContext): ProcessorResult;
45
+ }
46
+
47
+ export interface PipelineOptions {
48
+ processors: Processor[];
49
+ enabled: Set<string>;
50
+ }
51
+
52
+ export interface PipelineRunResult {
53
+ conversation: Conversation;
54
+ effects: ProcessorEffect[];
55
+ bytesIn: number;
56
+ bytesOut: number;
57
+ errors: Array<{ processor: string; message: string }>;
58
+ }
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { SSEEvent, UsageCounts } from "../types.js";
3
+ import { emptyUsage } from "../pricing.js";
4
+ import type {
5
+ Provider,
6
+ StreamAccumulator,
7
+ Conversation,
8
+ ConvMessage,
9
+ ConvBlock,
10
+ } from "./types.js";
11
+
12
+ interface AnthropicUsage {
13
+ input_tokens?: number;
14
+ output_tokens?: number;
15
+ cache_creation_input_tokens?: number;
16
+ cache_read_input_tokens?: number;
17
+ }
18
+
19
+ function fromAnthropic(u: AnthropicUsage | undefined): UsageCounts {
20
+ if (!u) return emptyUsage();
21
+ return {
22
+ inputTokens: u.input_tokens ?? 0,
23
+ outputTokens: u.output_tokens ?? 0,
24
+ cacheCreationInputTokens: u.cache_creation_input_tokens ?? 0,
25
+ cacheReadInputTokens: u.cache_read_input_tokens ?? 0,
26
+ };
27
+ }
28
+
29
+ function canonicalize(value: unknown): string {
30
+ if (value === null || value === undefined) return "null";
31
+ if (typeof value === "string") return JSON.stringify(value);
32
+ if (typeof value !== "object") return JSON.stringify(value);
33
+ if (Array.isArray(value)) {
34
+ return "[" + value.map(canonicalize).join(",") + "]";
35
+ }
36
+ const obj = value as Record<string, unknown>;
37
+ const keys = Object.keys(obj).sort();
38
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
39
+ }
40
+
41
+ function sha256(s: string): string {
42
+ return createHash("sha256").update(s).digest("hex");
43
+ }
44
+
45
+ function byteLength(value: unknown): number {
46
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
47
+ return Buffer.byteLength(JSON.stringify(value ?? ""), "utf8");
48
+ }
49
+
50
+ class AnthropicStreamAccumulator implements StreamAccumulator {
51
+ private current: UsageCounts = emptyUsage();
52
+ private modelFromEvent: string | null = null;
53
+
54
+ observe(event: SSEEvent): void {
55
+ if (event.event !== "message_start" && event.event !== "message_delta") return;
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(event.data);
59
+ } catch {
60
+ return;
61
+ }
62
+ if (!parsed || typeof parsed !== "object") return;
63
+ const obj = parsed as Record<string, unknown>;
64
+
65
+ if (event.event === "message_start") {
66
+ const message = obj["message"] as Record<string, unknown> | undefined;
67
+ if (message) {
68
+ if (typeof message["model"] === "string") {
69
+ this.modelFromEvent = message["model"] as string;
70
+ }
71
+ this.current = fromAnthropic(message["usage"] as AnthropicUsage | undefined);
72
+ }
73
+ } else {
74
+ const usage = obj["usage"] as AnthropicUsage | undefined;
75
+ if (usage) {
76
+ const u = fromAnthropic(usage);
77
+ this.current = {
78
+ inputTokens: this.current.inputTokens || u.inputTokens,
79
+ outputTokens: u.outputTokens,
80
+ cacheCreationInputTokens:
81
+ this.current.cacheCreationInputTokens || u.cacheCreationInputTokens,
82
+ cacheReadInputTokens:
83
+ this.current.cacheReadInputTokens || u.cacheReadInputTokens,
84
+ };
85
+ }
86
+ }
87
+ }
88
+
89
+ total(): UsageCounts {
90
+ return { ...this.current };
91
+ }
92
+
93
+ model(): string | null {
94
+ return this.modelFromEvent;
95
+ }
96
+ }
97
+
98
+ function blocksFromAnthropic(content: unknown): ConvBlock[] {
99
+ // Anthropic accepts string OR array of blocks for message.content
100
+ if (typeof content === "string") {
101
+ return [{ kind: "text", text: content }];
102
+ }
103
+ if (!Array.isArray(content)) return [{ kind: "other", raw: content }];
104
+ return content.map((block): ConvBlock => {
105
+ if (!block || typeof block !== "object") return { kind: "other", raw: block };
106
+ const b = block as Record<string, unknown>;
107
+ const type = b["type"];
108
+ if (type === "text" && typeof b["text"] === "string") {
109
+ return { kind: "text", text: b["text"] };
110
+ }
111
+ if (type === "tool_use" && typeof b["id"] === "string" && typeof b["name"] === "string") {
112
+ return { kind: "tool_use", id: b["id"], name: b["name"], input: b["input"] };
113
+ }
114
+ if (type === "tool_result" && typeof b["tool_use_id"] === "string") {
115
+ const content = b["content"];
116
+ const hash = sha256(canonicalize(content));
117
+ return {
118
+ kind: "tool_result",
119
+ tool_use_id: b["tool_use_id"],
120
+ content,
121
+ contentHash: hash,
122
+ contentBytes: byteLength(content),
123
+ };
124
+ }
125
+ return { kind: "other", raw: block };
126
+ });
127
+ }
128
+
129
+ function blocksToAnthropic(blocks: ConvBlock[]): unknown {
130
+ // Preserve array-vs-string shape: if it was originally a single text block from a
131
+ // string, we still emit an array. Anthropic accepts both.
132
+ return blocks.map((block) => {
133
+ switch (block.kind) {
134
+ case "text":
135
+ return { type: "text", text: block.text };
136
+ case "tool_use":
137
+ return { type: "tool_use", id: block.id, name: block.name, input: block.input };
138
+ case "tool_result": {
139
+ if (block.pointer) {
140
+ const stub =
141
+ `[tokenshield: identical to tool_result ${block.pointer.priorToolUseId} ` +
142
+ `at message ${block.pointer.priorMessageIndex}, ` +
143
+ `sha:${block.contentHash.slice(0, 8)} — ${block.pointer.elidedBytes} bytes elided]`;
144
+ return { type: "tool_result", tool_use_id: block.tool_use_id, content: stub };
145
+ }
146
+ return { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content };
147
+ }
148
+ case "other":
149
+ return block.raw;
150
+ }
151
+ });
152
+ }
153
+
154
+ function extractSystem(body: Record<string, unknown>): string | null {
155
+ const sys = body["system"];
156
+ if (typeof sys === "string") return sys;
157
+ if (Array.isArray(sys)) {
158
+ return sys
159
+ .map((b) => (typeof b === "object" && b !== null && typeof (b as Record<string, unknown>)["text"] === "string" ? (b as Record<string, unknown>)["text"] : ""))
160
+ .join("\n\n");
161
+ }
162
+ return null;
163
+ }
164
+
165
+ export const anthropic: Provider = {
166
+ id: "anthropic",
167
+
168
+ matches(pathname: string): boolean {
169
+ return pathname.startsWith("/v1/messages") || pathname.startsWith("/v1/complete");
170
+ },
171
+
172
+ extractModel(body: unknown): string {
173
+ if (!body || typeof body !== "object") return "unknown";
174
+ const m = (body as Record<string, unknown>)["model"];
175
+ return typeof m === "string" ? m : "unknown";
176
+ },
177
+
178
+ isStreaming(body: unknown): boolean {
179
+ if (!body || typeof body !== "object") return false;
180
+ return (body as Record<string, unknown>)["stream"] === true;
181
+ },
182
+
183
+ usageFromResponseJson(body: unknown): { usage: UsageCounts; model: string | null } {
184
+ if (!body || typeof body !== "object") return { usage: emptyUsage(), model: null };
185
+ const obj = body as Record<string, unknown>;
186
+ return {
187
+ usage: fromAnthropic(obj["usage"] as AnthropicUsage | undefined),
188
+ model: typeof obj["model"] === "string" ? (obj["model"] as string) : null,
189
+ };
190
+ },
191
+
192
+ createStreamAccumulator(): StreamAccumulator {
193
+ return new AnthropicStreamAccumulator();
194
+ },
195
+
196
+ toConversation(body: unknown): Conversation | null {
197
+ if (!body || typeof body !== "object") return null;
198
+ const obj = body as Record<string, unknown>;
199
+ const rawMessages = obj["messages"];
200
+ if (!Array.isArray(rawMessages)) return null;
201
+ const messages: ConvMessage[] = rawMessages
202
+ .map((m): ConvMessage | null => {
203
+ if (!m || typeof m !== "object") return null;
204
+ const msg = m as Record<string, unknown>;
205
+ const role = msg["role"];
206
+ if (role !== "user" && role !== "assistant") return null;
207
+ return { role, blocks: blocksFromAnthropic(msg["content"]) };
208
+ })
209
+ .filter((m): m is ConvMessage => m !== null);
210
+
211
+ const model = typeof obj["model"] === "string" ? (obj["model"] as string) : "unknown";
212
+ const tempRaw = obj["temperature"];
213
+ const temperature = typeof tempRaw === "number" ? tempRaw : null;
214
+
215
+ return {
216
+ model,
217
+ system: extractSystem(obj),
218
+ messages,
219
+ temperature,
220
+ raw: obj,
221
+ };
222
+ },
223
+
224
+ applyConversation(body: unknown, conversation: Conversation): unknown {
225
+ if (!body || typeof body !== "object") return body;
226
+ const obj = { ...(body as Record<string, unknown>) };
227
+ obj["messages"] = conversation.messages.map((m) => ({
228
+ role: m.role,
229
+ content: blocksToAnthropic(m.blocks),
230
+ }));
231
+ return obj;
232
+ },
233
+ };
234
+
235
+ // helpers exported for tests
236
+ export { canonicalize, sha256, byteLength };
@@ -0,0 +1,10 @@
1
+ import type { Provider } from "./types.js";
2
+ import { anthropic } from "./anthropic.js";
3
+
4
+ const PROVIDERS: Provider[] = [anthropic];
5
+
6
+ export function providerForPath(pathname: string): Provider | null {
7
+ return PROVIDERS.find((p) => p.matches(pathname)) ?? null;
8
+ }
9
+
10
+ export { anthropic };
@@ -0,0 +1,87 @@
1
+ import type { SSEEvent, UsageCounts } from "../types.js";
2
+
3
+ export type ProviderId = "anthropic" | "openai" | "gemini";
4
+
5
+ /**
6
+ * A Provider knows how to:
7
+ * - claim a URL path
8
+ * - parse usage from streamed SSE events
9
+ * - parse usage from non-streaming JSON responses
10
+ * - extract the model + stream flag from a request body
11
+ *
12
+ * Processors operate on the abstract Conversation/Message model the
13
+ * provider produces, so the dedup/cache logic doesn't need to know
14
+ * Anthropic vs OpenAI shapes.
15
+ */
16
+ export interface Provider {
17
+ readonly id: ProviderId;
18
+
19
+ /** Does this provider handle the given inbound URL path? */
20
+ matches(pathname: string): boolean;
21
+
22
+ /** Extract model name from a parsed request body. Returns "unknown" if absent. */
23
+ extractModel(body: unknown): string;
24
+
25
+ /** Did the request ask for SSE streaming? */
26
+ isStreaming(body: unknown): boolean;
27
+
28
+ /** Parse non-streaming response body (already JSON.parsed). */
29
+ usageFromResponseJson(body: unknown): { usage: UsageCounts; model: string | null };
30
+
31
+ /** Streaming usage accumulator factory — one per request. */
32
+ createStreamAccumulator(): StreamAccumulator;
33
+
34
+ /** Adapt the inbound body into a normalised Conversation for processors. */
35
+ toConversation(body: unknown): Conversation | null;
36
+
37
+ /** Apply processor-modified conversation back into a body shape ready to forward. */
38
+ applyConversation(body: unknown, conversation: Conversation): unknown;
39
+ }
40
+
41
+ export interface StreamAccumulator {
42
+ observe(event: SSEEvent): void;
43
+ total(): UsageCounts;
44
+ model(): string | null;
45
+ }
46
+
47
+ // ─── Normalised model (provider-agnostic) ───────────────────────────────────
48
+ //
49
+ // Processors operate on this shape. Each Provider knows how to translate to/from
50
+ // its native message format. This keeps dedup/cache implementations small.
51
+
52
+ export interface Conversation {
53
+ model: string;
54
+ /** System prompt text (concatenated if provider supports an array). */
55
+ system: string | null;
56
+ messages: ConvMessage[];
57
+ /** Approximate temperature; null when not specified. Used by cache safety check. */
58
+ temperature: number | null;
59
+ /** Provider-specific extras forwarded untouched. */
60
+ raw: Record<string, unknown>;
61
+ }
62
+
63
+ export interface ConvMessage {
64
+ role: "user" | "assistant";
65
+ blocks: ConvBlock[];
66
+ }
67
+
68
+ export type ConvBlock =
69
+ | { kind: "text"; text: string }
70
+ | { kind: "tool_use"; id: string; name: string; input: unknown }
71
+ | {
72
+ kind: "tool_result";
73
+ tool_use_id: string;
74
+ /** Original content body (string or structured) — kept intact for first-occurrence. */
75
+ content: unknown;
76
+ /** Stable hash of `content` after canonicalization. */
77
+ contentHash: string;
78
+ /** Approx byte size of original content (for accounting). */
79
+ contentBytes: number;
80
+ /** If set, dedup has replaced the content with a pointer; original is "elided". */
81
+ pointer?: {
82
+ priorMessageIndex: number;
83
+ priorToolUseId: string;
84
+ elidedBytes: number;
85
+ };
86
+ }
87
+ | { kind: "other"; raw: unknown };