@twzrd1111/agent-killswitch 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # Agent Spend Kill-Switch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A drop-in local reverse proxy that sits in front of any OpenAI-compatible
4
+ `/v1/chat/completions` endpoint. It enforces hard token/cost budgets, gives
5
+ you an instant file-based kill switch, and writes every decision to an
6
+ append-only JSONL ledger.
7
+
8
+ Point any OpenAI client at it by changing one variable — `OPENAI_BASE_URL` —
9
+ and a runaway agent can never silently burn more than you allow.
10
+
11
+ ## 5-minute setup (Docker)
12
+
13
+ ```sh
14
+ # 1. Configure (or just use env vars)
15
+ cp config.example.yaml config.yaml
16
+
17
+ # 2. Run — the only secret is your upstream key
18
+ docker build -t agent-killswitch .
19
+ docker run -d --name killswitch \
20
+ -p 127.0.0.1:4020:4020 \
21
+ -e OPENAI_API_KEY="$OPENAI_API_KEY" \
22
+ -v "$PWD/config.yaml:/app/config.yaml:ro" \
23
+ -v "$PWD/data:/app/data" \
24
+ agent-killswitch
25
+
26
+ # 3. Point any OpenAI client at it
27
+ export OPENAI_BASE_URL="http://127.0.0.1:4020/v1"
28
+ ```
29
+
30
+ Works with LangChain, AutoGen, CrewAI, Vercel AI SDK, Cursor, and anything
31
+ else that speaks `/v1/chat/completions` — streaming included.
32
+
33
+ ## The kill switch
34
+
35
+ ```sh
36
+ # Stop all spend immediately (no upstream call is made after this)
37
+ touch ./.kill # or: curl -X POST http://127.0.0.1:4020/kill
38
+
39
+ # Resume
40
+ rm ./.kill # or: curl -X POST http://127.0.0.1:4020/reset
41
+ ```
42
+
43
+ A vetoed request returns an OpenAI-shaped error so frameworks handle it
44
+ gracefully (HTTP 403 for the kill switch, 429 for budgets):
45
+
46
+ ```json
47
+ {"error": {"type": "killswitch_veto", "code": "budget_exceeded",
48
+ "message": "Hourly limit $5.00 hit", "spent_usd": 5.12, "limit_usd": 5.0}}
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ YAML file plus env overrides (env wins). Copy `config.example.yaml` to
54
+ `config.yaml` and edit the limits:
55
+
56
+ | Env var | YAML key | Default |
57
+ |---|---|---|
58
+ | `KILLSWITCH_CONFIG` | — (config path) | `./config.yaml` |
59
+ | `KILLSWITCH_UPSTREAM_BASE_URL` | `upstream.base_url` | `https://api.openai.com/v1` |
60
+ | `KILLSWITCH_API_KEY_ENV` | `upstream.api_key_env` | `OPENAI_API_KEY` |
61
+ | `KILLSWITCH_MAX_TOKENS_MINUTE` | `limits.max_tokens_per_minute` | `100000` |
62
+ | `KILLSWITCH_MAX_USD_HOUR` | `limits.max_usd_per_hour` | `5.00` |
63
+ | `KILLSWITCH_MAX_USD_DAY` | `limits.max_usd_per_day` | `25.00` |
64
+ | `KILLSWITCH_MAX_COST_REQUEST` | `limits.max_cost_per_request_usd` | `0.50` |
65
+ | `KILLSWITCH_KILL_FILE` | `kill_switch.file_path` | `./.kill` |
66
+ | `KILLSWITCH_KILL_API` | `kill_switch.enable_api_endpoint` | `true` |
67
+ | `KILLSWITCH_LEDGER_PATH` | `logging.path` | `./data/spend-ledger.jsonl` |
68
+ | `KILLSWITCH_PORT` / `PORT` | `server.port` | `4020` |
69
+
70
+ Every decision — allowed or vetoed — appends one line to the JSONL ledger
71
+ with timestamp, model, token counts, `cost_usd`, `allowed`, `veto_reason`,
72
+ and `request_id`. Restart replays the last 48 h to rebuild the spend
73
+ windows; the ledger file is the source of truth.
74
+
75
+ ## Pricing notes
76
+
77
+ Costs use a static per-model table (`src/pricing.ts`, checked against
78
+ OpenAI's published prices). There are no live pricing fetches by design.
79
+ An **unknown model is billed at the most expensive known rate** rather than
80
+ rejected — failing closed on cost beats failing open, and the ledger shows
81
+ exactly what was assumed.
82
+
83
+ ## What it is not (v0.1 scope)
84
+
85
+ OpenAI chat completions only. No Anthropic, no embeddings/images, no
86
+ tokenizer, no multi-upstream or key rotation, no dashboard, no Redis/SQLite.
87
+
88
+ ## Paid support
89
+
90
+ This is a local binary with no SaaS backend. Annual support (priority fixes,
91
+ custom limits/policies, audit-export help) is available — open an issue on
92
+ the repo with your use case and team size.
@@ -0,0 +1,23 @@
1
+ upstream:
2
+ base_url: "https://api.openai.com/v1"
3
+ # Name of the env var holding the upstream API key. The key itself is
4
+ # never stored in this file.
5
+ api_key_env: "OPENAI_API_KEY"
6
+
7
+ limits:
8
+ max_tokens_per_minute: 100000
9
+ max_usd_per_hour: 5.00
10
+ max_usd_per_day: 25.00
11
+ max_cost_per_request_usd: 0.50
12
+
13
+ kill_switch:
14
+ # When this file exists, every chat-completion request is vetoed
15
+ # immediately without contacting upstream. `touch ./.kill` to stop.
16
+ file_path: "./.kill"
17
+ enable_api_endpoint: true
18
+
19
+ logging:
20
+ path: "./data/spend-ledger.jsonl"
21
+
22
+ server:
23
+ port: 4020
package/dist/config.js ADDED
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import yaml from "js-yaml";
4
+ function num(env, fallback) {
5
+ if (env === undefined || env === "")
6
+ return fallback;
7
+ const v = Number(env);
8
+ if (!Number.isFinite(v) || v < 0)
9
+ throw new Error(`Invalid numeric env value: ${env}`);
10
+ return v;
11
+ }
12
+ function str(env, fallback) {
13
+ return env === undefined || env === "" ? fallback : env;
14
+ }
15
+ /** Load YAML config, then apply env overrides. Throws on missing API key. */
16
+ export function loadConfig(configPath) {
17
+ let raw = {};
18
+ if (fs.existsSync(configPath)) {
19
+ raw = (yaml.load(fs.readFileSync(configPath, "utf8")) ?? {});
20
+ }
21
+ const apiKeyEnv = str(process.env.KILLSWITCH_API_KEY_ENV, raw.upstream?.api_key_env ?? "OPENAI_API_KEY");
22
+ const apiKey = process.env[apiKeyEnv] ?? "";
23
+ if (!apiKey) {
24
+ throw new Error(`Upstream API key missing: set ${apiKeyEnv}`);
25
+ }
26
+ return {
27
+ upstreamBaseUrl: str(process.env.KILLSWITCH_UPSTREAM_BASE_URL, raw.upstream?.base_url ?? "https://api.openai.com/v1").replace(/\/+$/, ""),
28
+ apiKey,
29
+ maxTokensPerMinute: num(process.env.KILLSWITCH_MAX_TOKENS_MINUTE, raw.limits?.max_tokens_per_minute ?? 100000),
30
+ maxUsdPerHour: num(process.env.KILLSWITCH_MAX_USD_HOUR, raw.limits?.max_usd_per_hour ?? 5.0),
31
+ maxUsdPerDay: num(process.env.KILLSWITCH_MAX_USD_DAY, raw.limits?.max_usd_per_day ?? 25.0),
32
+ maxCostPerRequestUsd: num(process.env.KILLSWITCH_MAX_COST_REQUEST, raw.limits?.max_cost_per_request_usd ?? 0.5),
33
+ killFilePath: path.resolve(str(process.env.KILLSWITCH_KILL_FILE, raw.kill_switch?.file_path ?? "./.kill")),
34
+ enableKillApi: process.env.KILLSWITCH_KILL_API !== undefined
35
+ ? process.env.KILLSWITCH_KILL_API === "1" || process.env.KILLSWITCH_KILL_API === "true"
36
+ : (raw.kill_switch?.enable_api_endpoint ?? true),
37
+ ledgerPath: path.resolve(str(process.env.KILLSWITCH_LEDGER_PATH, raw.logging?.path ?? "./data/spend-ledger.jsonl")),
38
+ port: Math.floor(num(process.env.KILLSWITCH_PORT ?? process.env.PORT, raw.server?.port ?? 4020)),
39
+ };
40
+ }
package/dist/gate.js ADDED
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import { pessimisticRequestCostUsd } from "./pricing.js";
3
+ /** Instant kill switch: the mere existence of the file vetoes everything. */
4
+ export function checkKillSwitch(filePath) {
5
+ try {
6
+ return fs.existsSync(filePath);
7
+ }
8
+ catch {
9
+ // If we cannot even stat the file, fail closed.
10
+ return true;
11
+ }
12
+ }
13
+ /** Pure pre-call budget evaluator. No I/O, no tokenizer. */
14
+ export function evaluateGate(input) {
15
+ if (input.killed) {
16
+ return {
17
+ allowed: false,
18
+ status: 403,
19
+ code: "kill_switch",
20
+ message: "Kill switch engaged",
21
+ spentUsd: input.window.hourUsd,
22
+ limitUsd: input.maxUsdPerHour,
23
+ };
24
+ }
25
+ // Token velocity gate: recorded tokens in the last 60 s plus this
26
+ // request's max_tokens as a stand-in for its eventual size.
27
+ const estTokens = input.maxTokens ?? 0;
28
+ if (input.window.minuteTokens + estTokens > input.maxTokensPerMinute) {
29
+ return {
30
+ allowed: false,
31
+ status: 429,
32
+ code: "budget_exceeded",
33
+ message: `Token-per-minute limit ${input.maxTokensPerMinute} would be exceeded`,
34
+ spentUsd: input.window.minuteTokens,
35
+ limitUsd: input.maxTokensPerMinute,
36
+ };
37
+ }
38
+ // Pessimistic cost gate: assume every requested max_token bills at the
39
+ // output rate. Requests without max_tokens skip this check and are metered
40
+ // on actual usage instead.
41
+ if (input.maxTokens !== null) {
42
+ const pessimistic = pessimisticRequestCostUsd(input.model, input.maxTokens);
43
+ if (pessimistic > input.maxCostPerRequestUsd) {
44
+ return {
45
+ allowed: false,
46
+ status: 429,
47
+ code: "per_request_cap",
48
+ message: `Request could cost $${pessimistic.toFixed(4)}, over the per-request cap $${input.maxCostPerRequestUsd.toFixed(2)}`,
49
+ spentUsd: pessimistic,
50
+ limitUsd: input.maxCostPerRequestUsd,
51
+ };
52
+ }
53
+ if (input.window.hourUsd + pessimistic > input.maxUsdPerHour) {
54
+ return {
55
+ allowed: false,
56
+ status: 429,
57
+ code: "budget_exceeded",
58
+ message: `Hourly limit $${input.maxUsdPerHour.toFixed(2)} would be exceeded`,
59
+ spentUsd: input.window.hourUsd,
60
+ limitUsd: input.maxUsdPerHour,
61
+ };
62
+ }
63
+ if (input.window.dayUsd + pessimistic > input.maxUsdPerDay) {
64
+ return {
65
+ allowed: false,
66
+ status: 429,
67
+ code: "budget_exceeded",
68
+ message: `Daily limit $${input.maxUsdPerDay.toFixed(2)} would be exceeded`,
69
+ spentUsd: input.window.dayUsd,
70
+ limitUsd: input.maxUsdPerDay,
71
+ };
72
+ }
73
+ }
74
+ // Hard caps apply even without max_tokens: an already-blown window vetoes.
75
+ if (input.window.hourUsd >= input.maxUsdPerHour) {
76
+ return {
77
+ allowed: false,
78
+ status: 429,
79
+ code: "budget_exceeded",
80
+ message: `Hourly limit $${input.maxUsdPerHour.toFixed(2)} hit`,
81
+ spentUsd: input.window.hourUsd,
82
+ limitUsd: input.maxUsdPerHour,
83
+ };
84
+ }
85
+ if (input.window.dayUsd >= input.maxUsdPerDay) {
86
+ return {
87
+ allowed: false,
88
+ status: 429,
89
+ code: "budget_exceeded",
90
+ message: `Daily limit $${input.maxUsdPerDay.toFixed(2)} hit`,
91
+ spentUsd: input.window.dayUsd,
92
+ limitUsd: input.maxUsdPerDay,
93
+ };
94
+ }
95
+ return { allowed: true, status: 200, code: null, message: "allowed", spentUsd: 0, limitUsd: 0 };
96
+ }
97
+ /** OpenAI-shaped veto body so agent frameworks handle it like a normal API error. */
98
+ export function vetoBody(decision) {
99
+ return {
100
+ error: {
101
+ type: "killswitch_veto",
102
+ code: decision.code,
103
+ message: decision.message,
104
+ spent_usd: round4(decision.spentUsd),
105
+ limit_usd: decision.limitUsd,
106
+ },
107
+ };
108
+ }
109
+ function round4(n) {
110
+ return Math.round(n * 10000) / 10000;
111
+ }
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ import * as fs from "node:fs";
2
+ import * as http from "node:http";
3
+ import * as path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { loadConfig } from "./config.js";
6
+ import { checkKillSwitch, evaluateGate, vetoBody } from "./gate.js";
7
+ import { Ledger } from "./ledger.js";
8
+ import { costUsd } from "./pricing.js";
9
+ import { forwardChatCompletion } from "./proxy.js";
10
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
11
+ function readBody(req) {
12
+ return new Promise((resolve, reject) => {
13
+ const chunks = [];
14
+ let size = 0;
15
+ req.on("data", (c) => {
16
+ size += c.length;
17
+ if (size > MAX_BODY_BYTES) {
18
+ reject(new Error("body too large"));
19
+ req.destroy();
20
+ return;
21
+ }
22
+ chunks.push(c);
23
+ });
24
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
25
+ req.on("error", reject);
26
+ });
27
+ }
28
+ function json(res, status, obj) {
29
+ res.writeHead(status, { "content-type": "application/json" });
30
+ res.end(JSON.stringify(obj));
31
+ }
32
+ export function createServer() {
33
+ const configPath = process.env.KILLSWITCH_CONFIG ?? "./config.yaml";
34
+ const cfg = loadConfig(configPath);
35
+ const ledger = new Ledger(cfg.ledgerPath);
36
+ const replayed = ledger.load();
37
+ // eslint-disable-next-line no-console
38
+ console.log(`agent-killswitch: replayed ${replayed} ledger lines from the last 48 h`);
39
+ return http.createServer((req, res) => {
40
+ void handle(req, res, cfg, ledger).catch((err) => {
41
+ // eslint-disable-next-line no-console
42
+ console.error(`request failed: ${err.message}`);
43
+ if (!res.headersSent)
44
+ json(res, 500, { error: { type: "killswitch_error", message: "internal error" } });
45
+ else {
46
+ try {
47
+ res.end();
48
+ }
49
+ catch {
50
+ /* already torn down */
51
+ }
52
+ }
53
+ });
54
+ });
55
+ }
56
+ async function handle(req, res, cfg, ledger) {
57
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
58
+ if (req.method === "GET" && url.pathname === "/healthz") {
59
+ const w = ledger.window();
60
+ json(res, 200, {
61
+ ok: true,
62
+ killed: checkKillSwitch(cfg.killFilePath),
63
+ hour_usd: w.hourUsd,
64
+ day_usd: w.dayUsd,
65
+ });
66
+ return;
67
+ }
68
+ if (cfg.enableKillApi && req.method === "POST" && url.pathname === "/kill") {
69
+ fs.mkdirSync(path.dirname(cfg.killFilePath), { recursive: true });
70
+ fs.writeFileSync(cfg.killFilePath, `${new Date().toISOString()}\n`);
71
+ json(res, 200, { ok: true, killed: true });
72
+ return;
73
+ }
74
+ if (cfg.enableKillApi && req.method === "POST" && url.pathname === "/reset") {
75
+ try {
76
+ fs.unlinkSync(cfg.killFilePath);
77
+ }
78
+ catch {
79
+ /* already absent */
80
+ }
81
+ json(res, 200, { ok: true, killed: false });
82
+ return;
83
+ }
84
+ if (req.method !== "POST" || url.pathname !== "/v1/chat/completions") {
85
+ json(res, 404, { error: { type: "not_found", message: "only POST /v1/chat/completions is proxied" } });
86
+ return;
87
+ }
88
+ const raw = await readBody(req);
89
+ let body;
90
+ try {
91
+ body = JSON.parse(raw);
92
+ }
93
+ catch {
94
+ json(res, 400, { error: { type: "invalid_request", message: "body must be JSON" } });
95
+ return;
96
+ }
97
+ const model = typeof body["model"] === "string" ? body["model"] : "unknown";
98
+ const maxTokens = typeof body["max_tokens"] === "number" && Number.isFinite(body["max_tokens"])
99
+ ? Math.floor(body["max_tokens"])
100
+ : null;
101
+ const requestId = req.headers["x-request-id"] ??
102
+ (typeof body["request_id"] === "string" ? body["request_id"] : randomUUID());
103
+ const now = new Date().toISOString();
104
+ const decision = evaluateGate({
105
+ killed: checkKillSwitch(cfg.killFilePath),
106
+ window: ledger.window(),
107
+ model,
108
+ maxTokens,
109
+ maxTokensPerMinute: cfg.maxTokensPerMinute,
110
+ maxUsdPerHour: cfg.maxUsdPerHour,
111
+ maxUsdPerDay: cfg.maxUsdPerDay,
112
+ maxCostPerRequestUsd: cfg.maxCostPerRequestUsd,
113
+ });
114
+ if (!decision.allowed) {
115
+ ledger.append({
116
+ timestamp: now,
117
+ model,
118
+ prompt_tokens: 0,
119
+ completion_tokens: 0,
120
+ total_tokens: 0,
121
+ cost_usd: 0,
122
+ allowed: false,
123
+ veto_reason: decision.code,
124
+ request_id: requestId,
125
+ });
126
+ json(res, decision.status, vetoBody(decision));
127
+ return;
128
+ }
129
+ // Allowed: forward, then meter on the actual usage reported upstream.
130
+ const result = await forwardChatCompletion(cfg.upstreamBaseUrl, cfg.apiKey, body, res, requestId);
131
+ if (result.usage) {
132
+ const u = result.usage;
133
+ ledger.append({
134
+ timestamp: now,
135
+ model,
136
+ prompt_tokens: u.prompt_tokens,
137
+ completion_tokens: u.completion_tokens,
138
+ total_tokens: u.total_tokens,
139
+ cost_usd: costUsd(model, u.prompt_tokens, u.completion_tokens),
140
+ allowed: true,
141
+ veto_reason: null,
142
+ request_id: requestId,
143
+ });
144
+ }
145
+ }
146
+ // CLI entry: `node dist/index.js [config.yaml]`.
147
+ const invokedAsMain = process.argv[1] !== undefined && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "");
148
+ if (invokedAsMain) {
149
+ if (process.argv[2])
150
+ process.env.KILLSWITCH_CONFIG = process.argv[2];
151
+ const port = Number(process.env.KILLSWITCH_PORT ?? process.env.PORT ?? 4020);
152
+ const server = createServer();
153
+ server.listen(port, "127.0.0.1", () => {
154
+ // eslint-disable-next-line no-console
155
+ console.log(`agent-killswitch listening on 127.0.0.1:${port}`);
156
+ });
157
+ }
package/dist/ledger.js ADDED
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ // Lines older than this are ignored on rebuild; the in-memory window only
4
+ // ever needs the last day, with headroom for a long-downtime restart.
5
+ const REPLAY_MS = 48 * 3600 * 1000;
6
+ const RETAIN_MS = 48 * 3600 * 1000;
7
+ /** Append-only JSONL ledger (source of truth) + in-memory sliding windows. */
8
+ export class Ledger {
9
+ filePath;
10
+ spends = [];
11
+ constructor(filePath) {
12
+ this.filePath = filePath;
13
+ }
14
+ /** Replay the last 48 h of the ledger to rebuild the windows. */
15
+ load() {
16
+ this.spends = [];
17
+ if (!fs.existsSync(this.filePath))
18
+ return 0;
19
+ const cutoff = Date.now() - REPLAY_MS;
20
+ let replayed = 0;
21
+ const content = fs.readFileSync(this.filePath, "utf8");
22
+ for (const raw of content.split("\n")) {
23
+ const line = raw.trim();
24
+ if (!line)
25
+ continue;
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(line);
29
+ }
30
+ catch {
31
+ continue; // A torn write must never break startup.
32
+ }
33
+ if (!parsed.allowed)
34
+ continue; // Vetoed calls cost nothing.
35
+ const atMs = Date.parse(parsed.timestamp);
36
+ if (!Number.isFinite(atMs) || atMs < cutoff)
37
+ continue;
38
+ this.spends.push({
39
+ atMs,
40
+ totalTokens: parsed.total_tokens ?? 0,
41
+ costUsd: parsed.cost_usd ?? 0,
42
+ });
43
+ replayed++;
44
+ }
45
+ return replayed;
46
+ }
47
+ window(nowMs = Date.now()) {
48
+ let minuteTokens = 0;
49
+ let hourUsd = 0;
50
+ let dayUsd = 0;
51
+ for (const s of this.spends) {
52
+ const age = nowMs - s.atMs;
53
+ if (age < 0)
54
+ continue;
55
+ if (age < 24 * 3600 * 1000) {
56
+ dayUsd += s.costUsd;
57
+ if (age < 3600 * 1000) {
58
+ hourUsd += s.costUsd;
59
+ if (age < 60 * 1000)
60
+ minuteTokens += s.totalTokens;
61
+ }
62
+ }
63
+ }
64
+ return { minuteTokens, hourUsd, dayUsd };
65
+ }
66
+ append(line) {
67
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
68
+ fs.appendFileSync(this.filePath, JSON.stringify(line) + "\n", "utf8");
69
+ if (line.allowed) {
70
+ this.spends.push({
71
+ atMs: Date.parse(line.timestamp),
72
+ totalTokens: line.total_tokens,
73
+ costUsd: line.cost_usd,
74
+ });
75
+ // Bound memory: drop anything older than the replay horizon.
76
+ const cutoff = Date.now() - RETAIN_MS;
77
+ if (this.spends.length > 10000) {
78
+ this.spends = this.spends.filter((s) => s.atMs >= cutoff);
79
+ }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,37 @@
1
+ // Static per-model prices in USD per 1M tokens. Checked against OpenAI's
2
+ // published pricing 2026-11-07; update this table when OpenAI changes prices.
3
+ // Unknown models are priced as the most expensive known model (fail closed),
4
+ // never as the cheapest. See README for the rationale.
5
+ const PRICES = {
6
+ "o1": { inputPerMTok: 15.0, outputPerMTok: 60.0 },
7
+ "gpt-4o": { inputPerMTok: 2.5, outputPerMTok: 10.0 },
8
+ "gpt-4.1": { inputPerMTok: 2.0, outputPerMTok: 8.0 },
9
+ "o3-mini": { inputPerMTok: 1.1, outputPerMTok: 4.4 },
10
+ "gpt-4.1-mini": { inputPerMTok: 0.4, outputPerMTok: 1.6 },
11
+ "gpt-4o-mini": { inputPerMTok: 0.15, outputPerMTok: 0.6 },
12
+ };
13
+ function mostExpensive() {
14
+ let top = { inputPerMTok: 0, outputPerMTok: 0 };
15
+ for (const p of Object.values(PRICES)) {
16
+ if (p.outputPerMTok > top.outputPerMTok)
17
+ top = p;
18
+ }
19
+ return top;
20
+ }
21
+ export function priceFor(model) {
22
+ const price = PRICES[model];
23
+ if (price)
24
+ return { price, known: true };
25
+ // Fail closed: an unlisted model could be a pricey reasoning model, so we
26
+ // assume the worst instead of silently undercounting spend.
27
+ return { price: mostExpensive(), known: false };
28
+ }
29
+ export function costUsd(model, promptTokens, completionTokens) {
30
+ const { price } = priceFor(model);
31
+ return (promptTokens * price.inputPerMTok + completionTokens * price.outputPerMTok) / 1_000_000;
32
+ }
33
+ /** Pessimistic pre-call estimate: the request's max_tokens all billed at the output rate. */
34
+ export function pessimisticRequestCostUsd(model, maxTokens) {
35
+ const { price } = priceFor(model);
36
+ return (maxTokens * price.outputPerMTok) / 1_000_000;
37
+ }
package/dist/proxy.js ADDED
@@ -0,0 +1,101 @@
1
+ function num(v) {
2
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
3
+ }
4
+ /**
5
+ * Forward a chat-completion body upstream and pipe the response through.
6
+ * For streams, `stream_options.include_usage` is injected so the terminal
7
+ * SSE event carries usage; chunks are forwarded byte-for-byte and usage is
8
+ * extracted only from the final data event. Returns the observed usage
9
+ * (null when upstream sent none).
10
+ */
11
+ export async function forwardChatCompletion(upstreamBaseUrl, apiKey, body, clientRes, requestId) {
12
+ const streaming = body["stream"] === true;
13
+ const outbound = { ...body };
14
+ if (streaming) {
15
+ const opts = { ...outbound["stream_options"] };
16
+ opts["include_usage"] = true;
17
+ outbound["stream_options"] = opts;
18
+ }
19
+ const upstreamRes = await fetch(`${upstreamBaseUrl}/chat/completions`, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/json",
23
+ authorization: `Bearer ${apiKey}`,
24
+ "x-killswitch-request-id": requestId,
25
+ },
26
+ body: JSON.stringify(outbound),
27
+ });
28
+ if (!streaming) {
29
+ const text = await upstreamRes.text();
30
+ clientRes.writeHead(upstreamRes.status, { "content-type": "application/json" });
31
+ clientRes.end(text);
32
+ if (!upstreamRes.ok)
33
+ return { status: upstreamRes.status, usage: null };
34
+ try {
35
+ const parsed = JSON.parse(text);
36
+ const u = parsed.usage;
37
+ if (!u)
38
+ return { status: upstreamRes.status, usage: null };
39
+ return {
40
+ status: upstreamRes.status,
41
+ usage: {
42
+ prompt_tokens: num(u.prompt_tokens),
43
+ completion_tokens: num(u.completion_tokens),
44
+ total_tokens: num(u.total_tokens),
45
+ },
46
+ };
47
+ }
48
+ catch {
49
+ return { status: upstreamRes.status, usage: null };
50
+ }
51
+ }
52
+ // Streaming path: pipe SSE events verbatim, watching for the terminal
53
+ // usage event. The buffer only accumulates the current line, so memory
54
+ // stays flat regardless of stream length.
55
+ clientRes.writeHead(upstreamRes.status, {
56
+ "content-type": "text/event-stream",
57
+ "cache-control": "no-cache",
58
+ connection: "keep-alive",
59
+ });
60
+ if (!upstreamRes.ok || !upstreamRes.body) {
61
+ const text = upstreamRes.body ? await upstreamRes.text() : "";
62
+ clientRes.end(text);
63
+ return { status: upstreamRes.status, usage: null };
64
+ }
65
+ let usage = null;
66
+ let lineBuf = "";
67
+ const reader = upstreamRes.body.getReader();
68
+ const decoder = new TextDecoder();
69
+ for (;;) {
70
+ const { done, value } = await reader.read();
71
+ if (done)
72
+ break;
73
+ clientRes.write(value); // Forward first; extraction never delays the client.
74
+ lineBuf += decoder.decode(value, { stream: true });
75
+ let nl;
76
+ while ((nl = lineBuf.indexOf("\n")) >= 0) {
77
+ const line = lineBuf.slice(0, nl).trim();
78
+ lineBuf = lineBuf.slice(nl + 1);
79
+ if (!line.startsWith("data:"))
80
+ continue;
81
+ const payload = line.slice(5).trim();
82
+ if (payload === "[DONE]")
83
+ continue;
84
+ try {
85
+ const evt = JSON.parse(payload);
86
+ if (evt.usage && (evt.usage.prompt_tokens !== undefined || evt.usage.total_tokens !== undefined)) {
87
+ usage = {
88
+ prompt_tokens: num(evt.usage.prompt_tokens),
89
+ completion_tokens: num(evt.usage.completion_tokens),
90
+ total_tokens: num(evt.usage.total_tokens),
91
+ };
92
+ }
93
+ }
94
+ catch {
95
+ // Non-JSON data lines (e.g. keepalives) are forwarded untouched.
96
+ }
97
+ }
98
+ }
99
+ clientRes.end();
100
+ return { status: upstreamRes.status, usage };
101
+ }
package/package.json CHANGED
@@ -1,6 +1 @@
1
- {
2
- "name": "@twzrd1111/agent-killswitch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {"access": "public", "author": "TWZRD", "description": "Drop-in local reverse proxy that enforces hard token/cost budgets on OpenAI-compatible chat completions, with an instant file-based kill switch and an append-only JSONL spend ledger.", "exports": {"./package.json": "./package.json"}, "files": ["dist", "config.example.yaml", "README.md"], "homepage": "https://github.com/twzrd-sol/twzrd-trust#agent-killswitch", "keywords": ["openai", "proxy", "budget", "kill-switch", "cost-control", "agents"], "license": "MIT", "name": "@twzrd1111/agent-killswitch", "repository": {"type": "git", "url": "git+https://github.com/twzrd-sol/twzrd-trust.git"}, "scripts": {"build": "tsc -p .", "ci": "npm run typecheck", "start": "node dist/index.js", "typecheck": "tsc -p . --noEmit"}, "type": "module", "version": "0.1.0", "dependencies": {"js-yaml": "^4.1.0"}, "devDependencies": {"@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "typescript": "^5.6.0"}}