@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.
- package/dist/index.d.ts +15 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +33 -0
- package/dist/ledger.js +141 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pricing.d.ts +5 -0
- package/dist/pricing.js +83 -0
- package/dist/pricing.js.map +1 -0
- package/dist/processors/conversation-dedup.d.ts +23 -0
- package/dist/processors/conversation-dedup.js +71 -0
- package/dist/processors/conversation-dedup.js.map +1 -0
- package/dist/processors/pipeline.d.ts +10 -0
- package/dist/processors/pipeline.js +89 -0
- package/dist/processors/pipeline.js.map +1 -0
- package/dist/processors/response-cache.d.ts +53 -0
- package/dist/processors/response-cache.js +129 -0
- package/dist/processors/response-cache.js.map +1 -0
- package/dist/processors/types.d.ts +54 -0
- package/dist/processors/types.js +2 -0
- package/dist/processors/types.js.map +1 -0
- package/dist/providers/anthropic.d.ts +6 -0
- package/dist/providers/anthropic.js +216 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/registry.d.ts +4 -0
- package/dist/providers/registry.js +7 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/types.d.ts +79 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/proxy/anthropic-passthrough.d.ts +13 -0
- package/dist/proxy/anthropic-passthrough.js +363 -0
- package/dist/proxy/anthropic-passthrough.js.map +1 -0
- package/dist/proxy/sse.d.ts +20 -0
- package/dist/proxy/sse.js +59 -0
- package/dist/proxy/sse.js.map +1 -0
- package/dist/proxy/usage.d.ts +25 -0
- package/dist/proxy/usage.js +82 -0
- package/dist/proxy/usage.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +130 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +31 -0
- package/src/ledger.ts +232 -0
- package/src/pricing.ts +93 -0
- package/src/processors/conversation-dedup.ts +77 -0
- package/src/processors/pipeline.ts +104 -0
- package/src/processors/response-cache.ts +161 -0
- package/src/processors/types.ts +58 -0
- package/src/providers/anthropic.ts +236 -0
- package/src/providers/registry.ts +10 -0
- package/src/providers/types.ts +87 -0
- package/src/proxy/anthropic-passthrough.ts +393 -0
- package/src/proxy/sse.ts +58 -0
- package/src/proxy/usage.ts +98 -0
- package/src/server.ts +154 -0
- package/src/types.ts +47 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { handleAnthropicRequest } from "./proxy/anthropic-passthrough.js";
|
|
3
|
+
import { Ledger } from "./ledger.js";
|
|
4
|
+
export function defaultConfig(overrides = {}) {
|
|
5
|
+
const home = process.env["HOME"] ?? process.env["USERPROFILE"] ?? ".";
|
|
6
|
+
return {
|
|
7
|
+
upstreamBaseUrl: overrides.upstreamBaseUrl ?? "https://api.anthropic.com",
|
|
8
|
+
port: overrides.port ?? 7777,
|
|
9
|
+
bind: overrides.bind ?? "127.0.0.1",
|
|
10
|
+
dashboardPort: overrides.dashboardPort ?? 7778,
|
|
11
|
+
ledgerPath: overrides.ledgerPath ?? `${home}/.tokenshield/ledger.db`,
|
|
12
|
+
enabledProcessors: overrides.enabledProcessors ?? ["token-accounting"],
|
|
13
|
+
retentionDays: overrides.retentionDays ?? 7,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function listenOn(server, port, bind) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
server.once("error", reject);
|
|
19
|
+
server.listen(port, bind, () => {
|
|
20
|
+
server.removeListener("error", reject);
|
|
21
|
+
resolve();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async function closeServer(server) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
server.close(() => resolve());
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function start(opts) {
|
|
31
|
+
const ledger = new Ledger(opts.config.ledgerPath);
|
|
32
|
+
const sink = (r) => {
|
|
33
|
+
try {
|
|
34
|
+
ledger.record(r);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ledger errors must never break the request path
|
|
38
|
+
}
|
|
39
|
+
opts.onRecord?.(r);
|
|
40
|
+
};
|
|
41
|
+
const proxy = createServer((req, res) => {
|
|
42
|
+
if (req.url === "/__tokenshield/health") {
|
|
43
|
+
res.statusCode = 200;
|
|
44
|
+
res.setHeader("content-type", "application/json");
|
|
45
|
+
res.end(JSON.stringify({ ok: true, version: "0.1.0" }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
handleAnthropicRequest(req, res, opts.config, sink).catch((err) => {
|
|
49
|
+
if (!res.headersSent) {
|
|
50
|
+
res.statusCode = 500;
|
|
51
|
+
res.setHeader("content-type", "application/json");
|
|
52
|
+
res.end(JSON.stringify({
|
|
53
|
+
type: "error",
|
|
54
|
+
error: {
|
|
55
|
+
type: "tokenshield_internal_error",
|
|
56
|
+
message: err?.message ?? "unknown",
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
try {
|
|
62
|
+
res.end();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
proxy.keepAliveTimeout = 65_000;
|
|
71
|
+
proxy.headersTimeout = 70_000;
|
|
72
|
+
proxy.requestTimeout = 0; // streaming responses can be long
|
|
73
|
+
const dashboard = createServer((req, res) => {
|
|
74
|
+
const url = req.url ?? "/";
|
|
75
|
+
if (url === "/api/summary") {
|
|
76
|
+
const since = Date.now() - 24 * 60 * 60 * 1000;
|
|
77
|
+
const summary = ledger.summary(since);
|
|
78
|
+
res.statusCode = 200;
|
|
79
|
+
res.setHeader("content-type", "application/json");
|
|
80
|
+
res.setHeader("cache-control", "no-store");
|
|
81
|
+
res.end(JSON.stringify(summary));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (url === "/api/recent") {
|
|
85
|
+
res.statusCode = 200;
|
|
86
|
+
res.setHeader("content-type", "application/json");
|
|
87
|
+
res.setHeader("cache-control", "no-store");
|
|
88
|
+
res.end(JSON.stringify(ledger.recent(50)));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (url === "/health") {
|
|
92
|
+
res.statusCode = 200;
|
|
93
|
+
res.setHeader("content-type", "application/json");
|
|
94
|
+
res.end(JSON.stringify({ ok: true }));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const html = opts.renderDashboard?.(ledger) ?? defaultDashboardHtml();
|
|
98
|
+
res.statusCode = 200;
|
|
99
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
100
|
+
res.setHeader("cache-control", "no-store");
|
|
101
|
+
res.end(html);
|
|
102
|
+
});
|
|
103
|
+
await listenOn(proxy, opts.config.port, opts.config.bind);
|
|
104
|
+
await listenOn(dashboard, opts.config.dashboardPort, opts.config.bind);
|
|
105
|
+
const retentionInterval = setInterval(() => {
|
|
106
|
+
const cutoff = Date.now() - opts.config.retentionDays * 24 * 60 * 60 * 1000;
|
|
107
|
+
try {
|
|
108
|
+
ledger.prune(cutoff);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
}, 60 * 60 * 1000);
|
|
114
|
+
retentionInterval.unref();
|
|
115
|
+
return {
|
|
116
|
+
proxy,
|
|
117
|
+
dashboard,
|
|
118
|
+
ledger,
|
|
119
|
+
close: async () => {
|
|
120
|
+
clearInterval(retentionInterval);
|
|
121
|
+
await Promise.all([closeServer(proxy), closeServer(dashboard)]);
|
|
122
|
+
ledger.close();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function defaultDashboardHtml() {
|
|
127
|
+
return `<!doctype html><meta charset="utf-8"><title>TokenShield</title>
|
|
128
|
+
<body><h1>TokenShield</h1><p>Dashboard renderer not provided.</p></body>`;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ModelId = "claude-opus-4-7" | "claude-opus-4-6" | "claude-sonnet-4-6" | "claude-sonnet-4-5" | "claude-haiku-4-5" | "claude-haiku-4-5-20251001" | string;
|
|
2
|
+
export interface UsageCounts {
|
|
3
|
+
inputTokens: number;
|
|
4
|
+
outputTokens: number;
|
|
5
|
+
cacheCreationInputTokens: number;
|
|
6
|
+
cacheReadInputTokens: number;
|
|
7
|
+
}
|
|
8
|
+
export interface RequestRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
model: ModelId;
|
|
12
|
+
endpoint: string;
|
|
13
|
+
streamed: boolean;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
upstreamStatus: number;
|
|
16
|
+
upstreamError: string | null;
|
|
17
|
+
usageRaw: UsageCounts;
|
|
18
|
+
usageSent: UsageCounts;
|
|
19
|
+
dollarsRaw: number;
|
|
20
|
+
dollarsSent: number;
|
|
21
|
+
dollarsSaved: number;
|
|
22
|
+
processorsApplied: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface ProxyConfig {
|
|
25
|
+
upstreamBaseUrl: string;
|
|
26
|
+
port: number;
|
|
27
|
+
bind: string;
|
|
28
|
+
dashboardPort: number;
|
|
29
|
+
ledgerPath: string;
|
|
30
|
+
enabledProcessors: string[];
|
|
31
|
+
retentionDays: number;
|
|
32
|
+
}
|
|
33
|
+
export type SSEEvent = {
|
|
34
|
+
event: string;
|
|
35
|
+
data: string;
|
|
36
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@curatedmcp/tokenshield-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "TokenShield proxy engine — Anthropic API-layer middleware with token accounting",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./server": "./dist/server.js",
|
|
11
|
+
"./pipeline": "./dist/proxy/pipeline.js",
|
|
12
|
+
"./ledger": "./dist/ledger.js",
|
|
13
|
+
"./pricing": "./dist/pricing.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"test": "tsc -p tsconfig.test.json && node --enable-source-maps --test $(find dist-test/test -name '*.test.js')",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/oneprofile-dev/tokenshield"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"typescript": "^5.5.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { start, defaultConfig } from "./server.js";
|
|
2
|
+
export type { ProxyServerHandle, StartOptions } from "./server.js";
|
|
3
|
+
export { Ledger } from "./ledger.js";
|
|
4
|
+
export type { SavingsSummary } from "./ledger.js";
|
|
5
|
+
export { dollarsFor, addUsage, emptyUsage, isKnownModel } from "./pricing.js";
|
|
6
|
+
export { SSEParser } from "./proxy/sse.js";
|
|
7
|
+
export { StreamUsageAccumulator, usageFromJson } from "./proxy/usage.js";
|
|
8
|
+
export {
|
|
9
|
+
handleAnthropicRequest,
|
|
10
|
+
setProcessorEnabled,
|
|
11
|
+
getProcessorEnabledIds,
|
|
12
|
+
getResponseCacheStats,
|
|
13
|
+
} from "./proxy/anthropic-passthrough.js";
|
|
14
|
+
export { providerForPath, anthropic } from "./providers/registry.js";
|
|
15
|
+
export type { Provider, Conversation, ConvMessage, ConvBlock, ProviderId } from "./providers/types.js";
|
|
16
|
+
export { Pipeline } from "./processors/pipeline.js";
|
|
17
|
+
export { conversationDedup } from "./processors/conversation-dedup.js";
|
|
18
|
+
export { ResponseCache } from "./processors/response-cache.js";
|
|
19
|
+
export type {
|
|
20
|
+
Processor,
|
|
21
|
+
ProcessorContext,
|
|
22
|
+
ProcessorEffect,
|
|
23
|
+
ProcessorResult,
|
|
24
|
+
} from "./processors/types.js";
|
|
25
|
+
export type {
|
|
26
|
+
RequestRecord,
|
|
27
|
+
ProxyConfig,
|
|
28
|
+
UsageCounts,
|
|
29
|
+
ModelId,
|
|
30
|
+
SSEEvent,
|
|
31
|
+
} from "./types.js";
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { RequestRecord } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface SavingsSummary {
|
|
7
|
+
windowStart: number;
|
|
8
|
+
windowEnd: number;
|
|
9
|
+
requestCount: number;
|
|
10
|
+
totalInputTokensRaw: number;
|
|
11
|
+
totalInputTokensSent: number;
|
|
12
|
+
totalOutputTokensRaw: number;
|
|
13
|
+
totalOutputTokensSent: number;
|
|
14
|
+
dollarsRaw: number;
|
|
15
|
+
dollarsSent: number;
|
|
16
|
+
dollarsSaved: number;
|
|
17
|
+
byModel: Array<{
|
|
18
|
+
model: string;
|
|
19
|
+
requests: number;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
dollars: number;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SummaryRow {
|
|
27
|
+
model: string;
|
|
28
|
+
requests: number;
|
|
29
|
+
input_tokens_raw: number | null;
|
|
30
|
+
input_tokens_sent: number | null;
|
|
31
|
+
output_tokens_raw: number | null;
|
|
32
|
+
output_tokens_sent: number | null;
|
|
33
|
+
dollars_raw: number | null;
|
|
34
|
+
dollars_sent: number | null;
|
|
35
|
+
dollars_saved: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RequestRow {
|
|
39
|
+
id: string;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
model: string;
|
|
42
|
+
endpoint: string;
|
|
43
|
+
streamed: number;
|
|
44
|
+
duration_ms: number;
|
|
45
|
+
upstream_status: number;
|
|
46
|
+
upstream_error: string | null;
|
|
47
|
+
input_tokens_raw: number;
|
|
48
|
+
input_tokens_sent: number;
|
|
49
|
+
output_tokens_raw: number;
|
|
50
|
+
output_tokens_sent: number;
|
|
51
|
+
cache_create_raw: number;
|
|
52
|
+
cache_read_raw: number;
|
|
53
|
+
dollars_raw: number;
|
|
54
|
+
dollars_sent: number;
|
|
55
|
+
dollars_saved: number;
|
|
56
|
+
processors: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class Ledger {
|
|
60
|
+
private db: DatabaseSync;
|
|
61
|
+
private insertStmt: ReturnType<DatabaseSync["prepare"]>;
|
|
62
|
+
private summaryStmt: ReturnType<DatabaseSync["prepare"]>;
|
|
63
|
+
private recentStmt: ReturnType<DatabaseSync["prepare"]>;
|
|
64
|
+
private pruneStmt: ReturnType<DatabaseSync["prepare"]>;
|
|
65
|
+
|
|
66
|
+
constructor(path: string) {
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
+
this.db = new DatabaseSync(path);
|
|
69
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
70
|
+
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
71
|
+
this.db.exec(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
timestamp INTEGER NOT NULL,
|
|
75
|
+
model TEXT NOT NULL,
|
|
76
|
+
endpoint TEXT NOT NULL,
|
|
77
|
+
streamed INTEGER NOT NULL,
|
|
78
|
+
duration_ms INTEGER NOT NULL,
|
|
79
|
+
upstream_status INTEGER NOT NULL,
|
|
80
|
+
upstream_error TEXT,
|
|
81
|
+
input_tokens_raw INTEGER NOT NULL,
|
|
82
|
+
input_tokens_sent INTEGER NOT NULL,
|
|
83
|
+
output_tokens_raw INTEGER NOT NULL,
|
|
84
|
+
output_tokens_sent INTEGER NOT NULL,
|
|
85
|
+
cache_create_raw INTEGER NOT NULL,
|
|
86
|
+
cache_read_raw INTEGER NOT NULL,
|
|
87
|
+
dollars_raw REAL NOT NULL,
|
|
88
|
+
dollars_sent REAL NOT NULL,
|
|
89
|
+
dollars_saved REAL NOT NULL,
|
|
90
|
+
processors TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
`);
|
|
93
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(timestamp)");
|
|
94
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model)");
|
|
95
|
+
|
|
96
|
+
this.insertStmt = this.db.prepare(`
|
|
97
|
+
INSERT INTO requests (
|
|
98
|
+
id, timestamp, model, endpoint, streamed, duration_ms,
|
|
99
|
+
upstream_status, upstream_error,
|
|
100
|
+
input_tokens_raw, input_tokens_sent,
|
|
101
|
+
output_tokens_raw, output_tokens_sent,
|
|
102
|
+
cache_create_raw, cache_read_raw,
|
|
103
|
+
dollars_raw, dollars_sent, dollars_saved, processors
|
|
104
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
`);
|
|
106
|
+
this.summaryStmt = this.db.prepare(`
|
|
107
|
+
SELECT model,
|
|
108
|
+
COUNT(*) as requests,
|
|
109
|
+
SUM(input_tokens_raw) as input_tokens_raw,
|
|
110
|
+
SUM(input_tokens_sent) as input_tokens_sent,
|
|
111
|
+
SUM(output_tokens_raw) as output_tokens_raw,
|
|
112
|
+
SUM(output_tokens_sent) as output_tokens_sent,
|
|
113
|
+
SUM(dollars_raw) as dollars_raw,
|
|
114
|
+
SUM(dollars_sent) as dollars_sent,
|
|
115
|
+
SUM(dollars_saved) as dollars_saved
|
|
116
|
+
FROM requests
|
|
117
|
+
WHERE timestamp >= ?
|
|
118
|
+
GROUP BY model
|
|
119
|
+
ORDER BY dollars_raw DESC
|
|
120
|
+
`);
|
|
121
|
+
this.recentStmt = this.db.prepare(
|
|
122
|
+
`SELECT * FROM requests ORDER BY timestamp DESC LIMIT ?`,
|
|
123
|
+
);
|
|
124
|
+
this.pruneStmt = this.db.prepare(`DELETE FROM requests WHERE timestamp < ?`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
record(r: RequestRecord): void {
|
|
128
|
+
this.insertStmt.run(
|
|
129
|
+
r.id,
|
|
130
|
+
r.timestamp,
|
|
131
|
+
r.model,
|
|
132
|
+
r.endpoint,
|
|
133
|
+
r.streamed ? 1 : 0,
|
|
134
|
+
r.durationMs,
|
|
135
|
+
r.upstreamStatus,
|
|
136
|
+
r.upstreamError,
|
|
137
|
+
r.usageRaw.inputTokens,
|
|
138
|
+
r.usageSent.inputTokens,
|
|
139
|
+
r.usageRaw.outputTokens,
|
|
140
|
+
r.usageSent.outputTokens,
|
|
141
|
+
r.usageRaw.cacheCreationInputTokens,
|
|
142
|
+
r.usageRaw.cacheReadInputTokens,
|
|
143
|
+
r.dollarsRaw,
|
|
144
|
+
r.dollarsSent,
|
|
145
|
+
r.dollarsSaved,
|
|
146
|
+
JSON.stringify(r.processorsApplied),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
summary(sinceMs: number): SavingsSummary {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const rows = this.summaryStmt.all(sinceMs) as unknown as SummaryRow[];
|
|
153
|
+
|
|
154
|
+
let totalIRaw = 0,
|
|
155
|
+
totalISent = 0,
|
|
156
|
+
totalORaw = 0,
|
|
157
|
+
totalOSent = 0,
|
|
158
|
+
dRaw = 0,
|
|
159
|
+
dSent = 0,
|
|
160
|
+
dSaved = 0,
|
|
161
|
+
reqCount = 0;
|
|
162
|
+
for (const r of rows) {
|
|
163
|
+
reqCount += r.requests;
|
|
164
|
+
totalIRaw += r.input_tokens_raw ?? 0;
|
|
165
|
+
totalISent += r.input_tokens_sent ?? 0;
|
|
166
|
+
totalORaw += r.output_tokens_raw ?? 0;
|
|
167
|
+
totalOSent += r.output_tokens_sent ?? 0;
|
|
168
|
+
dRaw += r.dollars_raw ?? 0;
|
|
169
|
+
dSent += r.dollars_sent ?? 0;
|
|
170
|
+
dSaved += r.dollars_saved ?? 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
windowStart: sinceMs,
|
|
175
|
+
windowEnd: now,
|
|
176
|
+
requestCount: reqCount,
|
|
177
|
+
totalInputTokensRaw: totalIRaw,
|
|
178
|
+
totalInputTokensSent: totalISent,
|
|
179
|
+
totalOutputTokensRaw: totalORaw,
|
|
180
|
+
totalOutputTokensSent: totalOSent,
|
|
181
|
+
dollarsRaw: dRaw,
|
|
182
|
+
dollarsSent: dSent,
|
|
183
|
+
dollarsSaved: dSaved,
|
|
184
|
+
byModel: rows.map((r) => ({
|
|
185
|
+
model: r.model,
|
|
186
|
+
requests: r.requests,
|
|
187
|
+
inputTokens: r.input_tokens_raw ?? 0,
|
|
188
|
+
outputTokens: r.output_tokens_raw ?? 0,
|
|
189
|
+
dollars: r.dollars_raw ?? 0,
|
|
190
|
+
})),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
recent(limit = 50): RequestRecord[] {
|
|
195
|
+
const rows = this.recentStmt.all(limit) as unknown as RequestRow[];
|
|
196
|
+
return rows.map((r) => ({
|
|
197
|
+
id: r.id,
|
|
198
|
+
timestamp: r.timestamp,
|
|
199
|
+
model: r.model,
|
|
200
|
+
endpoint: r.endpoint,
|
|
201
|
+
streamed: r.streamed === 1,
|
|
202
|
+
durationMs: r.duration_ms,
|
|
203
|
+
upstreamStatus: r.upstream_status,
|
|
204
|
+
upstreamError: r.upstream_error,
|
|
205
|
+
usageRaw: {
|
|
206
|
+
inputTokens: r.input_tokens_raw,
|
|
207
|
+
outputTokens: r.output_tokens_raw,
|
|
208
|
+
cacheCreationInputTokens: r.cache_create_raw,
|
|
209
|
+
cacheReadInputTokens: r.cache_read_raw,
|
|
210
|
+
},
|
|
211
|
+
usageSent: {
|
|
212
|
+
inputTokens: r.input_tokens_sent,
|
|
213
|
+
outputTokens: r.output_tokens_sent,
|
|
214
|
+
cacheCreationInputTokens: r.cache_create_raw,
|
|
215
|
+
cacheReadInputTokens: r.cache_read_raw,
|
|
216
|
+
},
|
|
217
|
+
dollarsRaw: r.dollars_raw,
|
|
218
|
+
dollarsSent: r.dollars_sent,
|
|
219
|
+
dollarsSaved: r.dollars_saved,
|
|
220
|
+
processorsApplied: JSON.parse(r.processors ?? "[]") as string[],
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
prune(olderThanMs: number): number {
|
|
225
|
+
const result = this.pruneStmt.run(olderThanMs);
|
|
226
|
+
return Number(result.changes);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
close(): void {
|
|
230
|
+
this.db.close();
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ModelId, UsageCounts } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface ModelPricing {
|
|
4
|
+
inputPerMTok: number;
|
|
5
|
+
outputPerMTok: number;
|
|
6
|
+
cacheWritePerMTok: number;
|
|
7
|
+
cacheReadPerMTok: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PRICING: Record<string, ModelPricing> = {
|
|
11
|
+
"claude-opus-4-7": {
|
|
12
|
+
inputPerMTok: 15,
|
|
13
|
+
outputPerMTok: 75,
|
|
14
|
+
cacheWritePerMTok: 18.75,
|
|
15
|
+
cacheReadPerMTok: 1.5,
|
|
16
|
+
},
|
|
17
|
+
"claude-opus-4-6": {
|
|
18
|
+
inputPerMTok: 15,
|
|
19
|
+
outputPerMTok: 75,
|
|
20
|
+
cacheWritePerMTok: 18.75,
|
|
21
|
+
cacheReadPerMTok: 1.5,
|
|
22
|
+
},
|
|
23
|
+
"claude-sonnet-4-6": {
|
|
24
|
+
inputPerMTok: 3,
|
|
25
|
+
outputPerMTok: 15,
|
|
26
|
+
cacheWritePerMTok: 3.75,
|
|
27
|
+
cacheReadPerMTok: 0.3,
|
|
28
|
+
},
|
|
29
|
+
"claude-sonnet-4-5": {
|
|
30
|
+
inputPerMTok: 3,
|
|
31
|
+
outputPerMTok: 15,
|
|
32
|
+
cacheWritePerMTok: 3.75,
|
|
33
|
+
cacheReadPerMTok: 0.3,
|
|
34
|
+
},
|
|
35
|
+
"claude-haiku-4-5": {
|
|
36
|
+
inputPerMTok: 1,
|
|
37
|
+
outputPerMTok: 5,
|
|
38
|
+
cacheWritePerMTok: 1.25,
|
|
39
|
+
cacheReadPerMTok: 0.1,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULT_PRICING: ModelPricing = PRICING["claude-sonnet-4-6"]!;
|
|
44
|
+
|
|
45
|
+
function lookup(model: ModelId): ModelPricing {
|
|
46
|
+
const exact = PRICING[model];
|
|
47
|
+
if (exact) return exact;
|
|
48
|
+
for (const [key, val] of Object.entries(PRICING)) {
|
|
49
|
+
if (model.startsWith(key)) return val;
|
|
50
|
+
}
|
|
51
|
+
const lower = model.toLowerCase();
|
|
52
|
+
if (lower.includes("opus")) return PRICING["claude-opus-4-7"]!;
|
|
53
|
+
if (lower.includes("haiku")) return PRICING["claude-haiku-4-5"]!;
|
|
54
|
+
if (lower.includes("sonnet")) return PRICING["claude-sonnet-4-6"]!;
|
|
55
|
+
return DEFAULT_PRICING;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function dollarsFor(model: ModelId, u: UsageCounts): number {
|
|
59
|
+
const p = lookup(model);
|
|
60
|
+
return (
|
|
61
|
+
(u.inputTokens * p.inputPerMTok) / 1_000_000 +
|
|
62
|
+
(u.outputTokens * p.outputPerMTok) / 1_000_000 +
|
|
63
|
+
(u.cacheCreationInputTokens * p.cacheWritePerMTok) / 1_000_000 +
|
|
64
|
+
(u.cacheReadInputTokens * p.cacheReadPerMTok) / 1_000_000
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function emptyUsage(): UsageCounts {
|
|
69
|
+
return {
|
|
70
|
+
inputTokens: 0,
|
|
71
|
+
outputTokens: 0,
|
|
72
|
+
cacheCreationInputTokens: 0,
|
|
73
|
+
cacheReadInputTokens: 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function addUsage(a: UsageCounts, b: UsageCounts): UsageCounts {
|
|
78
|
+
return {
|
|
79
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
80
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
81
|
+
cacheCreationInputTokens:
|
|
82
|
+
a.cacheCreationInputTokens + b.cacheCreationInputTokens,
|
|
83
|
+
cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isKnownModel(model: ModelId): boolean {
|
|
88
|
+
if (PRICING[model]) return true;
|
|
89
|
+
for (const key of Object.keys(PRICING)) {
|
|
90
|
+
if (model.startsWith(key)) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Conversation, ConvBlock } from "../providers/types.js";
|
|
2
|
+
import type { Processor, ProcessorContext, ProcessorResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Conversation deduplication.
|
|
6
|
+
*
|
|
7
|
+
* Walks the conversation in message order. The first occurrence of any
|
|
8
|
+
* tool_result content (keyed by its content hash) is kept verbatim. Subsequent
|
|
9
|
+
* occurrences are replaced with a compact pointer that references the prior one.
|
|
10
|
+
*
|
|
11
|
+
* Determinism: same input always produces the same output (hash-based ordering),
|
|
12
|
+
* so Anthropic prompt caching remains valid.
|
|
13
|
+
*
|
|
14
|
+
* Fail-open: if any block can't be handled cleanly, it's left untouched.
|
|
15
|
+
*/
|
|
16
|
+
class ConversationDedup implements Processor {
|
|
17
|
+
readonly id = "conversation-dedup";
|
|
18
|
+
readonly enabledByDefault = true;
|
|
19
|
+
|
|
20
|
+
/** Only elide payloads worth eliding — avoids stub-overhead on tiny results. */
|
|
21
|
+
static readonly MIN_ELIDE_BYTES = 256;
|
|
22
|
+
|
|
23
|
+
onRequest(conv: Conversation, _ctx: ProcessorContext): ProcessorResult {
|
|
24
|
+
const seen = new Map<string, { messageIndex: number; toolUseId: string }>();
|
|
25
|
+
let elidedBytesTotal = 0;
|
|
26
|
+
let elidedCount = 0;
|
|
27
|
+
let mutated = false;
|
|
28
|
+
|
|
29
|
+
const newMessages = conv.messages.map((msg, msgIdx) => {
|
|
30
|
+
const newBlocks = msg.blocks.map<ConvBlock>((block) => {
|
|
31
|
+
if (block.kind !== "tool_result") return block;
|
|
32
|
+
if (block.pointer) return block; // already deduped (idempotency)
|
|
33
|
+
const prior = seen.get(block.contentHash);
|
|
34
|
+
if (prior === undefined) {
|
|
35
|
+
seen.set(block.contentHash, {
|
|
36
|
+
messageIndex: msgIdx,
|
|
37
|
+
toolUseId: block.tool_use_id,
|
|
38
|
+
});
|
|
39
|
+
return block;
|
|
40
|
+
}
|
|
41
|
+
if (block.contentBytes < ConversationDedup.MIN_ELIDE_BYTES) {
|
|
42
|
+
// Not worth the stub overhead; leave verbatim.
|
|
43
|
+
return block;
|
|
44
|
+
}
|
|
45
|
+
mutated = true;
|
|
46
|
+
elidedBytesTotal += block.contentBytes;
|
|
47
|
+
elidedCount++;
|
|
48
|
+
return {
|
|
49
|
+
...block,
|
|
50
|
+
pointer: {
|
|
51
|
+
priorMessageIndex: prior.messageIndex,
|
|
52
|
+
priorToolUseId: prior.toolUseId,
|
|
53
|
+
elidedBytes: block.contentBytes,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
return mutated ? { ...msg, blocks: newBlocks } : msg;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!mutated) {
|
|
61
|
+
return { conversation: conv, effects: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
conversation: { ...conv, messages: newMessages },
|
|
66
|
+
effects: [
|
|
67
|
+
{
|
|
68
|
+
name: this.id,
|
|
69
|
+
bytesSaved: elidedBytesTotal,
|
|
70
|
+
detail: { elidedCount, distinctContent: seen.size },
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const conversationDedup = new ConversationDedup();
|