@relayplane/proxy 1.9.25 → 1.9.27

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Kill-Switch API
3
+ *
4
+ * Instantly halts ALL traffic for a specific tenant. Designed for:
5
+ * - Runaway agent loops detected by anomaly monitoring
6
+ * - Billing emergencies (unexpected spend spike)
7
+ * - Security incidents requiring immediate isolation
8
+ * - Client-requested suspension
9
+ *
10
+ * The kill-switch is checked in-memory first (O(1), no disk I/O) on every
11
+ * request so it activates within a single request cycle — effectively
12
+ * instantaneous. The flag is also persisted to disk so it survives proxy
13
+ * restarts.
14
+ *
15
+ * HTTP API (served by the proxy server):
16
+ * POST /v1/tenants/:tenantId/kill { reason?: string } → activates
17
+ * DELETE /v1/tenants/:tenantId/kill → lifts
18
+ * GET /v1/tenants/:tenantId/kill → status
19
+ */
20
+ export interface KillSwitchEntry {
21
+ tenant_id: string;
22
+ active: boolean;
23
+ reason?: string;
24
+ activated_at?: string;
25
+ activated_by?: string;
26
+ lifted_at?: string;
27
+ lifted_by?: string;
28
+ }
29
+ export interface KillSwitchStore {
30
+ entries: Record<string, KillSwitchEntry>;
31
+ updated_at: string;
32
+ }
33
+ export interface ActivateOptions {
34
+ reason?: string;
35
+ activated_by?: string;
36
+ }
37
+ export interface LiftOptions {
38
+ lifted_by?: string;
39
+ }
40
+ /**
41
+ * KillSwitchManager provides instant tenant traffic halting with persistence.
42
+ *
43
+ * Thread-safety note: this is single-process Node.js, so the in-memory Set
44
+ * is the authoritative source of truth for the hot path. Disk writes are
45
+ * fire-and-forget for persistence across restarts.
46
+ */
47
+ export declare class KillSwitchManager {
48
+ private storePath;
49
+ private activeKillSwitches;
50
+ private entries;
51
+ constructor(storePath?: string);
52
+ private load;
53
+ private persist;
54
+ /**
55
+ * Activate kill-switch for a tenant. Subsequent isActive() checks return
56
+ * true immediately (in-memory). Fire-and-forget persist to disk.
57
+ */
58
+ activate(tenantId: string, options?: ActivateOptions): KillSwitchEntry;
59
+ /**
60
+ * Lift kill-switch for a tenant. Requests resume immediately.
61
+ * Returns false if no active kill-switch was found.
62
+ */
63
+ lift(tenantId: string, options?: LiftOptions): boolean;
64
+ /**
65
+ * Check if a kill-switch is active for a tenant.
66
+ * O(1) — reads only from the in-memory Set.
67
+ */
68
+ isActive(tenantId: string): boolean;
69
+ /** Get the full kill-switch entry for a tenant. */
70
+ getEntry(tenantId: string): KillSwitchEntry | undefined;
71
+ /** List all kill-switch entries (active and historical). */
72
+ listAll(): KillSwitchEntry[];
73
+ /** List only currently active kill-switches. */
74
+ listActive(): KillSwitchEntry[];
75
+ /**
76
+ * Build the response payload for a blocked request.
77
+ * Use with HTTP 429 status and x-relayplane-kill-switch: true header.
78
+ */
79
+ buildBlockedResponse(tenantId: string): object;
80
+ }
81
+ export declare function getKillSwitchManager(storePath?: string): KillSwitchManager;
82
+ export declare function resetKillSwitchManager(): void;
83
+ //# sourceMappingURL=kill-switch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kill-switch.d.ts","sourceRoot":"","sources":["../src/kill-switch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAMH,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAQD;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,kBAAkB,CAA0B;IACpD,OAAO,CAAC,OAAO,CAA2C;gBAE9C,SAAS,CAAC,EAAE,MAAM;IAM9B,OAAO,CAAC,IAAI;IAcZ,OAAO,CAAC,OAAO;IAef;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,eAAe;IAwB1E;;;OAGG;IACH,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO;IAwB1D;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAInC,mDAAmD;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;IAIvD,4DAA4D;IAC5D,OAAO,IAAI,eAAe,EAAE;IAI5B,gDAAgD;IAChD,UAAU,IAAI,eAAe,EAAE;IAI/B;;;OAGG;IACH,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;CAa/C;AAKD,wBAAgB,oBAAoB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAG1E;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C"}
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ /**
3
+ * Kill-Switch API
4
+ *
5
+ * Instantly halts ALL traffic for a specific tenant. Designed for:
6
+ * - Runaway agent loops detected by anomaly monitoring
7
+ * - Billing emergencies (unexpected spend spike)
8
+ * - Security incidents requiring immediate isolation
9
+ * - Client-requested suspension
10
+ *
11
+ * The kill-switch is checked in-memory first (O(1), no disk I/O) on every
12
+ * request so it activates within a single request cycle — effectively
13
+ * instantaneous. The flag is also persisted to disk so it survives proxy
14
+ * restarts.
15
+ *
16
+ * HTTP API (served by the proxy server):
17
+ * POST /v1/tenants/:tenantId/kill { reason?: string } → activates
18
+ * DELETE /v1/tenants/:tenantId/kill → lifts
19
+ * GET /v1/tenants/:tenantId/kill → status
20
+ */
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.KillSwitchManager = void 0;
56
+ exports.getKillSwitchManager = getKillSwitchManager;
57
+ exports.resetKillSwitchManager = resetKillSwitchManager;
58
+ const fs = __importStar(require("fs"));
59
+ const path = __importStar(require("path"));
60
+ const os = __importStar(require("os"));
61
+ function resolveRelayplaneDir() {
62
+ const homeOverride = process.env['RELAYPLANE_HOME_OVERRIDE'];
63
+ const base = homeOverride ?? os.homedir();
64
+ return path.join(base, '.relayplane');
65
+ }
66
+ /**
67
+ * KillSwitchManager provides instant tenant traffic halting with persistence.
68
+ *
69
+ * Thread-safety note: this is single-process Node.js, so the in-memory Set
70
+ * is the authoritative source of truth for the hot path. Disk writes are
71
+ * fire-and-forget for persistence across restarts.
72
+ */
73
+ class KillSwitchManager {
74
+ storePath;
75
+ activeKillSwitches = new Set();
76
+ entries = new Map();
77
+ constructor(storePath) {
78
+ const dir = resolveRelayplaneDir();
79
+ this.storePath = storePath ?? path.join(dir, 'kill-switches.json');
80
+ this.load();
81
+ }
82
+ load() {
83
+ if (fs.existsSync(this.storePath)) {
84
+ try {
85
+ const store = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
86
+ for (const [id, entry] of Object.entries(store.entries)) {
87
+ this.entries.set(id, entry);
88
+ if (entry.active)
89
+ this.activeKillSwitches.add(id);
90
+ }
91
+ }
92
+ catch {
93
+ // Corrupt store — start fresh
94
+ }
95
+ }
96
+ }
97
+ persist() {
98
+ const dir = path.dirname(this.storePath);
99
+ if (!fs.existsSync(dir))
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const store = {
102
+ entries: Object.fromEntries(this.entries),
103
+ updated_at: new Date().toISOString(),
104
+ };
105
+ // Atomic write
106
+ const tmp = this.storePath + '.tmp';
107
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
108
+ fs.renameSync(tmp, this.storePath);
109
+ }
110
+ /**
111
+ * Activate kill-switch for a tenant. Subsequent isActive() checks return
112
+ * true immediately (in-memory). Fire-and-forget persist to disk.
113
+ */
114
+ activate(tenantId, options = {}) {
115
+ const now = new Date().toISOString();
116
+ const entry = {
117
+ tenant_id: tenantId,
118
+ active: true,
119
+ reason: options.reason,
120
+ activated_at: now,
121
+ activated_by: options.activated_by,
122
+ };
123
+ // In-memory first (hot path)
124
+ this.activeKillSwitches.add(tenantId);
125
+ this.entries.set(tenantId, entry);
126
+ // Persist (async-ish — but Node.js writeFileSync is sync, this is fast enough)
127
+ try {
128
+ this.persist();
129
+ }
130
+ catch {
131
+ // Don't throw — in-memory state is authoritative
132
+ }
133
+ return entry;
134
+ }
135
+ /**
136
+ * Lift kill-switch for a tenant. Requests resume immediately.
137
+ * Returns false if no active kill-switch was found.
138
+ */
139
+ lift(tenantId, options = {}) {
140
+ if (!this.activeKillSwitches.has(tenantId))
141
+ return false;
142
+ const now = new Date().toISOString();
143
+ const existing = this.entries.get(tenantId);
144
+ const entry = {
145
+ ...(existing ?? { tenant_id: tenantId }),
146
+ active: false,
147
+ lifted_at: now,
148
+ lifted_by: options.lifted_by,
149
+ };
150
+ this.activeKillSwitches.delete(tenantId);
151
+ this.entries.set(tenantId, entry);
152
+ try {
153
+ this.persist();
154
+ }
155
+ catch {
156
+ // In-memory state is authoritative
157
+ }
158
+ return true;
159
+ }
160
+ /**
161
+ * Check if a kill-switch is active for a tenant.
162
+ * O(1) — reads only from the in-memory Set.
163
+ */
164
+ isActive(tenantId) {
165
+ return this.activeKillSwitches.has(tenantId);
166
+ }
167
+ /** Get the full kill-switch entry for a tenant. */
168
+ getEntry(tenantId) {
169
+ return this.entries.get(tenantId);
170
+ }
171
+ /** List all kill-switch entries (active and historical). */
172
+ listAll() {
173
+ return Array.from(this.entries.values());
174
+ }
175
+ /** List only currently active kill-switches. */
176
+ listActive() {
177
+ return Array.from(this.entries.values()).filter(e => e.active);
178
+ }
179
+ /**
180
+ * Build the response payload for a blocked request.
181
+ * Use with HTTP 429 status and x-relayplane-kill-switch: true header.
182
+ */
183
+ buildBlockedResponse(tenantId) {
184
+ const entry = this.entries.get(tenantId);
185
+ return {
186
+ error: {
187
+ type: 'kill_switch_active',
188
+ message: `All traffic for tenant '${tenantId}' has been suspended.`,
189
+ tenant_id: tenantId,
190
+ activated_at: entry?.activated_at,
191
+ reason: entry?.reason ?? 'No reason provided.',
192
+ contact: 'Contact your RelayPlane administrator to lift the kill-switch.',
193
+ },
194
+ };
195
+ }
196
+ }
197
+ exports.KillSwitchManager = KillSwitchManager;
198
+ /** Singleton instance. */
199
+ let _instance;
200
+ function getKillSwitchManager(storePath) {
201
+ if (!_instance)
202
+ _instance = new KillSwitchManager(storePath);
203
+ return _instance;
204
+ }
205
+ function resetKillSwitchManager() {
206
+ _instance = undefined;
207
+ }
208
+ //# sourceMappingURL=kill-switch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kill-switch.js","sourceRoot":"","sources":["../src/kill-switch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Spec-Match Verification Plugin
3
+ *
4
+ * Before an agent marks a task complete, the orchestrator POSTs the task's
5
+ * acceptance criteria + the agent's output (diff, text, screenshots) to
6
+ * RelayPlane's /v1/spec-match endpoint. This plugin uses a cheap LLM
7
+ * (Haiku by default) to evaluate whether the output satisfies each criterion.
8
+ *
9
+ * Returns a structured pass/fail result with per-criterion evidence and
10
+ * confidence scores. Only pass:true results should proceed to production.
11
+ *
12
+ * Use in Matt's swarm: every coder task dispatch includes acceptance_criteria.
13
+ * On completion, orchestrator calls spec-match. Failing results trigger retry
14
+ * with escalation before the verifier agent sees the output.
15
+ */
16
+ export interface AcceptanceCriterion {
17
+ /** Short identifier, e.g. "hero-headline-changed" */
18
+ id: string;
19
+ /** Human-readable description of what must be true */
20
+ description: string;
21
+ /** How critical this criterion is */
22
+ severity: 'blocker' | 'major' | 'minor';
23
+ }
24
+ export interface SpecMatchRequest {
25
+ /** Task title or identifier for trace logging */
26
+ task_id: string;
27
+ /** Tenant this evaluation belongs to */
28
+ tenant_id?: string;
29
+ /** List of acceptance criteria to evaluate */
30
+ acceptance_criteria: AcceptanceCriterion[];
31
+ /**
32
+ * The agent's output to evaluate against criteria.
33
+ * At least one of diff, output_text, or screenshots must be provided.
34
+ */
35
+ diff?: string;
36
+ output_text?: string;
37
+ /** Base64-encoded PNG screenshots, or URLs */
38
+ screenshots?: string[];
39
+ /** Override the default evaluation model */
40
+ model_override?: string;
41
+ /** If true, include the full LLM prompt/response in the result */
42
+ debug?: boolean;
43
+ }
44
+ export interface CriterionResult {
45
+ criterion_id: string;
46
+ criterion_description: string;
47
+ met: boolean;
48
+ evidence: string;
49
+ confidence: 'high' | 'medium' | 'low';
50
+ severity: 'blocker' | 'major' | 'minor';
51
+ }
52
+ export interface SpecMatchResult {
53
+ /** True if all blocker criteria are met */
54
+ pass: boolean;
55
+ /** 0–100 score (percentage of weighted criteria met) */
56
+ score: number;
57
+ /** Per-criterion results */
58
+ criteria_results: CriterionResult[];
59
+ /** Criteria with severity=blocker that were not met */
60
+ blockers: string[];
61
+ /** Criteria with severity=major that were not met */
62
+ warnings: string[];
63
+ /** Model used for evaluation */
64
+ model_used: string;
65
+ /** Estimated cost of the spec-match call in USD */
66
+ cost_usd: number;
67
+ /** Trace ID for audit linking */
68
+ trace_id: string;
69
+ /** ISO timestamp */
70
+ evaluated_at: string;
71
+ /** Only present when request.debug=true */
72
+ debug_prompt?: string;
73
+ debug_response?: string;
74
+ }
75
+ export interface SpecMatchPluginOptions {
76
+ /** Anthropic-compatible base URL (defaults to https://api.anthropic.com) */
77
+ apiBaseUrl?: string;
78
+ /** API key for the evaluation model */
79
+ apiKey?: string;
80
+ /** Model to use for evaluation (default: claude-haiku-4-5-20251001) */
81
+ defaultModel?: string;
82
+ /** Max tokens for the evaluation response (default: 2048) */
83
+ maxTokens?: number;
84
+ }
85
+ /**
86
+ * SpecMatchPlugin evaluates agent output against acceptance criteria using
87
+ * an LLM judge. Use this at task completion before marking work as done.
88
+ */
89
+ export declare class SpecMatchPlugin {
90
+ private apiBaseUrl;
91
+ private apiKey;
92
+ private defaultModel;
93
+ private maxTokens;
94
+ constructor(options?: SpecMatchPluginOptions);
95
+ evaluate(request: SpecMatchRequest): Promise<SpecMatchResult>;
96
+ }
97
+ export declare function getSpecMatchPlugin(options?: SpecMatchPluginOptions): SpecMatchPlugin;
98
+ export declare function resetSpecMatchPlugin(): void;
99
+ //# sourceMappingURL=spec-match-plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spec-match-plugin.d.ts","sourceRoot":"","sources":["../src/spec-match-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,mBAAmB;IAClC,qDAAqD;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,QAAQ,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;CACzC;AAED,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,mBAAmB,EAAE,mBAAmB,EAAE,CAAC;IAC3C;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,GAAG,EAAE,OAAO,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;IACtC,QAAQ,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,IAAI,EAAE,OAAO,CAAC;IACd,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,uDAAuD;IACvD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gCAAgC;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAsDD;;;GAGG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAAS;gBAEd,OAAO,GAAE,sBAA2B;IAO1C,QAAQ,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC;CAiIpE;AAKD,wBAAgB,kBAAkB,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,eAAe,CAGpF;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ /**
3
+ * Spec-Match Verification Plugin
4
+ *
5
+ * Before an agent marks a task complete, the orchestrator POSTs the task's
6
+ * acceptance criteria + the agent's output (diff, text, screenshots) to
7
+ * RelayPlane's /v1/spec-match endpoint. This plugin uses a cheap LLM
8
+ * (Haiku by default) to evaluate whether the output satisfies each criterion.
9
+ *
10
+ * Returns a structured pass/fail result with per-criterion evidence and
11
+ * confidence scores. Only pass:true results should proceed to production.
12
+ *
13
+ * Use in Matt's swarm: every coder task dispatch includes acceptance_criteria.
14
+ * On completion, orchestrator calls spec-match. Failing results trigger retry
15
+ * with escalation before the verifier agent sees the output.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.SpecMatchPlugin = void 0;
19
+ exports.getSpecMatchPlugin = getSpecMatchPlugin;
20
+ exports.resetSpecMatchPlugin = resetSpecMatchPlugin;
21
+ /** Haiku price per 1M tokens (input/output) for cost estimation */
22
+ const HAIKU_INPUT_COST_PER_1M = 0.80;
23
+ const HAIKU_OUTPUT_COST_PER_1M = 4.00;
24
+ function buildEvaluationPrompt(request) {
25
+ const criteriaList = request.acceptance_criteria
26
+ .map((c, i) => `${i + 1}. [${c.id}] (${c.severity}) ${c.description}`)
27
+ .join('\n');
28
+ const outputSections = [];
29
+ if (request.diff)
30
+ outputSections.push(`<diff>\n${request.diff}\n</diff>`);
31
+ if (request.output_text)
32
+ outputSections.push(`<output>\n${request.output_text}\n</output>`);
33
+ if (request.screenshots?.length) {
34
+ outputSections.push(`<screenshots>${request.screenshots.length} screenshot(s) provided.</screenshots>`);
35
+ }
36
+ return `You are a strict QA evaluator. Evaluate whether the agent's output satisfies each acceptance criterion below.
37
+
38
+ <acceptance_criteria>
39
+ ${criteriaList}
40
+ </acceptance_criteria>
41
+
42
+ <agent_output>
43
+ ${outputSections.join('\n\n')}
44
+ </agent_output>
45
+
46
+ For each criterion, respond with a JSON array. Each element must be:
47
+ {
48
+ "criterion_id": "<id from above>",
49
+ "met": true|false,
50
+ "evidence": "<one sentence citing specific evidence from the output>",
51
+ "confidence": "high"|"medium"|"low"
52
+ }
53
+
54
+ Rules:
55
+ - met:true only when you see clear, direct evidence in the output
56
+ - confidence:high = definitive evidence; medium = likely but not certain; low = cannot tell
57
+ - For missing/unclear evidence, met:false with confidence:low
58
+ - Do not infer; evaluate only what is present
59
+
60
+ Respond with ONLY the JSON array. No preamble.`;
61
+ }
62
+ function estimateCost(inputTokens, outputTokens) {
63
+ return (inputTokens / 1_000_000) * HAIKU_INPUT_COST_PER_1M +
64
+ (outputTokens / 1_000_000) * HAIKU_OUTPUT_COST_PER_1M;
65
+ }
66
+ function buildTraceId() {
67
+ return `sm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
68
+ }
69
+ /**
70
+ * SpecMatchPlugin evaluates agent output against acceptance criteria using
71
+ * an LLM judge. Use this at task completion before marking work as done.
72
+ */
73
+ class SpecMatchPlugin {
74
+ apiBaseUrl;
75
+ apiKey;
76
+ defaultModel;
77
+ maxTokens;
78
+ constructor(options = {}) {
79
+ this.apiBaseUrl = options.apiBaseUrl ?? (process.env['ANTHROPIC_BASE_URL'] ?? 'https://api.anthropic.com');
80
+ this.apiKey = options.apiKey ?? (process.env['ANTHROPIC_API_KEY'] ?? '');
81
+ this.defaultModel = options.defaultModel ?? 'claude-haiku-4-5-20251001';
82
+ this.maxTokens = options.maxTokens ?? 2048;
83
+ }
84
+ async evaluate(request) {
85
+ const traceId = buildTraceId();
86
+ const model = request.model_override ?? this.defaultModel;
87
+ const prompt = buildEvaluationPrompt(request);
88
+ let rawResponse = '';
89
+ let inputTokens = 0;
90
+ let outputTokens = 0;
91
+ try {
92
+ const response = await fetch(`${this.apiBaseUrl}/v1/messages`, {
93
+ method: 'POST',
94
+ headers: {
95
+ 'content-type': 'application/json',
96
+ 'x-api-key': this.apiKey,
97
+ 'anthropic-version': '2023-06-01',
98
+ },
99
+ body: JSON.stringify({
100
+ model,
101
+ max_tokens: this.maxTokens,
102
+ messages: [{ role: 'user', content: prompt }],
103
+ }),
104
+ });
105
+ if (!response.ok) {
106
+ const errText = await response.text();
107
+ throw new Error(`Spec-match LLM call failed: ${response.status} ${errText}`);
108
+ }
109
+ const body = await response.json();
110
+ rawResponse = body.content.find(c => c.type === 'text')?.text ?? '[]';
111
+ inputTokens = body.usage?.input_tokens ?? Math.ceil(prompt.length / 4);
112
+ outputTokens = body.usage?.output_tokens ?? Math.ceil(rawResponse.length / 4);
113
+ }
114
+ catch (err) {
115
+ // Return a graceful failure result rather than throwing
116
+ return {
117
+ pass: false,
118
+ score: 0,
119
+ criteria_results: request.acceptance_criteria.map(c => ({
120
+ criterion_id: c.id,
121
+ criterion_description: c.description,
122
+ met: false,
123
+ evidence: `Spec-match evaluation failed: ${err instanceof Error ? err.message : String(err)}`,
124
+ confidence: 'low',
125
+ severity: c.severity,
126
+ })),
127
+ blockers: request.acceptance_criteria.filter(c => c.severity === 'blocker').map(c => c.id),
128
+ warnings: request.acceptance_criteria.filter(c => c.severity === 'major').map(c => c.id),
129
+ model_used: model,
130
+ cost_usd: 0,
131
+ trace_id: traceId,
132
+ evaluated_at: new Date().toISOString(),
133
+ };
134
+ }
135
+ // Parse LLM response
136
+ let llmResults = [];
137
+ try {
138
+ // Strip markdown fences if present
139
+ const cleaned = rawResponse.replace(/^```json\n?/i, '').replace(/\n?```$/i, '').trim();
140
+ llmResults = JSON.parse(cleaned);
141
+ }
142
+ catch {
143
+ // Fallback: all criteria unmet if we can't parse
144
+ llmResults = request.acceptance_criteria.map(c => ({
145
+ criterion_id: c.id,
146
+ met: false,
147
+ evidence: 'Could not parse evaluator response.',
148
+ confidence: 'low',
149
+ }));
150
+ }
151
+ // Build per-criterion results
152
+ const criteriaMap = new Map(request.acceptance_criteria.map(c => [c.id, c]));
153
+ const criteriaResults = llmResults.map(r => {
154
+ const criterion = criteriaMap.get(r.criterion_id);
155
+ return {
156
+ criterion_id: r.criterion_id,
157
+ criterion_description: criterion?.description ?? r.criterion_id,
158
+ met: r.met,
159
+ evidence: r.evidence,
160
+ confidence: r.confidence,
161
+ severity: criterion?.severity ?? 'minor',
162
+ };
163
+ });
164
+ // Score: weighted by severity (blocker=3, major=2, minor=1)
165
+ const weights = { blocker: 3, major: 2, minor: 1 };
166
+ const totalWeight = criteriaResults.reduce((sum, c) => sum + weights[c.severity], 0);
167
+ const metWeight = criteriaResults
168
+ .filter(c => c.met)
169
+ .reduce((sum, c) => sum + weights[c.severity], 0);
170
+ const score = totalWeight > 0 ? Math.round((metWeight / totalWeight) * 100) : 100;
171
+ const blockers = criteriaResults
172
+ .filter(c => c.severity === 'blocker' && !c.met)
173
+ .map(c => c.criterion_id);
174
+ const warnings = criteriaResults
175
+ .filter(c => c.severity === 'major' && !c.met)
176
+ .map(c => c.criterion_id);
177
+ const result = {
178
+ pass: blockers.length === 0,
179
+ score,
180
+ criteria_results: criteriaResults,
181
+ blockers,
182
+ warnings,
183
+ model_used: model,
184
+ cost_usd: estimateCost(inputTokens, outputTokens),
185
+ trace_id: traceId,
186
+ evaluated_at: new Date().toISOString(),
187
+ };
188
+ if (request.debug) {
189
+ result.debug_prompt = prompt;
190
+ result.debug_response = rawResponse;
191
+ }
192
+ return result;
193
+ }
194
+ }
195
+ exports.SpecMatchPlugin = SpecMatchPlugin;
196
+ /** Singleton instance. */
197
+ let _instance;
198
+ function getSpecMatchPlugin(options) {
199
+ if (!_instance)
200
+ _instance = new SpecMatchPlugin(options);
201
+ return _instance;
202
+ }
203
+ function resetSpecMatchPlugin() {
204
+ _instance = undefined;
205
+ }
206
+ //# sourceMappingURL=spec-match-plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spec-match-plugin.js","sourceRoot":"","sources":["../src/spec-match-plugin.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tenant Isolation — per-tenant request scoping for agent swarms.
3
+ *
4
+ * Each tenant gets an isolated lane: separate budget tracking, rate limits,
5
+ * audit namespace, and kill-switch flag. Tenant A's runaway agent can never
6
+ * affect Tenant B's traffic or billing.
7
+ *
8
+ * Tenants are identified by the `x-tenant-id` request header or by an API
9
+ * key prefix registered in config. The proxy strips tenant headers before
10
+ * forwarding to providers.
11
+ */
12
+ export type TenantTier = 'free' | 'starter' | 'pro' | 'max' | 'enterprise';
13
+ export interface TenantConfig {
14
+ label: string;
15
+ tier: TenantTier;
16
+ /** Hard daily spend cap in USD. Requests exceeding this are blocked. */
17
+ budget_usd_per_day?: number;
18
+ /** Hard monthly spend cap in USD. */
19
+ budget_usd_per_month?: number;
20
+ /** Allowlist of model IDs this tenant may use. Empty = all models allowed. */
21
+ allowed_models?: string[];
22
+ /** Denylist of model IDs. Checked after allowed_models. */
23
+ denied_models?: string[];
24
+ /** Maximum requests per minute for this tenant. */
25
+ rpm_limit?: number;
26
+ /** When true, ALL requests from this tenant are immediately rejected (kill-switch). */
27
+ kill_switch?: boolean;
28
+ /** Timestamp when the kill-switch was activated. */
29
+ kill_switch_activated_at?: string;
30
+ /** Human-readable reason for the kill-switch. */
31
+ kill_switch_reason?: string;
32
+ /** Metadata tags for dashboards and audit logs. */
33
+ tags?: Record<string, string>;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+ export interface TenantSpend {
38
+ tenant_id: string;
39
+ date: string;
40
+ spend_usd: number;
41
+ request_count: number;
42
+ last_request_at: string;
43
+ }
44
+ export interface TenantRequestContext {
45
+ tenant_id: string;
46
+ trace_id: string;
47
+ request_id: string;
48
+ timestamp: string;
49
+ }
50
+ export interface TenantCheckResult {
51
+ allowed: boolean;
52
+ tenant_id: string;
53
+ reason?: string;
54
+ /** Set when kill-switch is active */
55
+ kill_switch_active?: boolean;
56
+ /** Set when a budget limit would be exceeded */
57
+ budget_exceeded?: boolean;
58
+ /** Set when the requested model is not allowed */
59
+ model_denied?: boolean;
60
+ /** Current daily spend for this tenant */
61
+ daily_spend_usd?: number;
62
+ /** Remaining daily budget */
63
+ daily_budget_remaining_usd?: number;
64
+ }
65
+ export interface TenantIsolatorOptions {
66
+ /** Path to the tenants config file. Defaults to ~/.relayplane/tenants.json */
67
+ configPath?: string;
68
+ /** Path to the spend tracking SQLite DB or JSON store. Defaults to ~/.relayplane/tenant-spend.json */
69
+ spendStorePath?: string;
70
+ }
71
+ /**
72
+ * Manages per-tenant isolation: configuration, budget enforcement, and
73
+ * kill-switch state. Designed to be instantiated once and reused per request.
74
+ */
75
+ export declare class TenantIsolator {
76
+ private configPath;
77
+ private spendStorePath;
78
+ private tenants;
79
+ private spendCache;
80
+ private killSwitchCache;
81
+ constructor(options?: TenantIsolatorOptions);
82
+ private load;
83
+ private save;
84
+ private saveSpend;
85
+ /** Register or update a tenant configuration. */
86
+ upsertTenant(tenantId: string, config: Omit<TenantConfig, 'created_at' | 'updated_at'>): TenantConfig;
87
+ /** Remove a tenant and all their spend records. */
88
+ deleteTenant(tenantId: string): boolean;
89
+ /** Get a tenant config by ID. Returns undefined for unknown tenants. */
90
+ getTenant(tenantId: string): TenantConfig | undefined;
91
+ /** List all tenants. */
92
+ listTenants(): Array<{
93
+ id: string;
94
+ config: TenantConfig;
95
+ }>;
96
+ /**
97
+ * Extract tenant ID from an incoming request.
98
+ * Priority: x-tenant-id header > API key prefix registered in config.
99
+ * Returns 'default' if no tenant can be identified.
100
+ */
101
+ extractTenantId(headers: Record<string, string | string[] | undefined>, apiKey?: string): string;
102
+ /**
103
+ * Check whether a request from a tenant should be allowed.
104
+ * Checks kill-switch, model allowlist/denylist, and budget caps.
105
+ */
106
+ checkRequest(tenantId: string, model?: string, estimatedCostUsd?: number): TenantCheckResult;
107
+ /** Record spend for a tenant after a successful request. */
108
+ recordSpend(tenantId: string, costUsd: number): void;
109
+ /** Get today's spend for a tenant. */
110
+ getDailySpend(tenantId: string): number;
111
+ /**
112
+ * Generate the request context headers to inject into downstream traces.
113
+ * The proxy adds these before forwarding and strips them from the outbound
114
+ * request to the provider.
115
+ */
116
+ buildRequestContext(tenantId: string): TenantRequestContext;
117
+ }
118
+ export declare function getTenantIsolator(options?: TenantIsolatorOptions): TenantIsolator;
119
+ /** Reset the singleton (for tests). */
120
+ export declare function resetTenantIsolator(): void;
121
+ //# sourceMappingURL=tenant-isolation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-isolation.d.ts","sourceRoot":"","sources":["../src/tenant-isolation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ /**
3
+ * Tenant Isolation — per-tenant request scoping for agent swarms.
4
+ *
5
+ * Each tenant gets an isolated lane: separate budget tracking, rate limits,
6
+ * audit namespace, and kill-switch flag. Tenant A's runaway agent can never
7
+ * affect Tenant B's traffic or billing.
8
+ *
9
+ * Tenants are identified by the `x-tenant-id` request header or by an API
10
+ * key prefix registered in config. The proxy strips tenant headers before
11
+ * forwarding to providers.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.TenantIsolator = void 0;
48
+ exports.getTenantIsolator = getTenantIsolator;
49
+ exports.resetTenantIsolator = resetTenantIsolator;
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ const os = __importStar(require("os"));
53
+ const crypto = __importStar(require("crypto"));
54
+ function resolveRelayplaneDir() {
55
+ const homeOverride = process.env['RELAYPLANE_HOME_OVERRIDE'];
56
+ const base = homeOverride ?? os.homedir();
57
+ return path.join(base, '.relayplane');
58
+ }
59
+ /**
60
+ * Manages per-tenant isolation: configuration, budget enforcement, and
61
+ * kill-switch state. Designed to be instantiated once and reused per request.
62
+ */
63
+ class TenantIsolator {
64
+ configPath;
65
+ spendStorePath;
66
+ tenants = new Map();
67
+ spendCache = new Map();
68
+ killSwitchCache = new Set();
69
+ constructor(options = {}) {
70
+ const dir = resolveRelayplaneDir();
71
+ this.configPath = options.configPath ?? path.join(dir, 'tenants.json');
72
+ this.spendStorePath = options.spendStorePath ?? path.join(dir, 'tenant-spend.json');
73
+ this.load();
74
+ }
75
+ load() {
76
+ if (fs.existsSync(this.configPath)) {
77
+ try {
78
+ const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
79
+ this.tenants = new Map(Object.entries(raw));
80
+ // Seed in-memory kill-switch cache from persisted state
81
+ for (const [id, cfg] of this.tenants) {
82
+ if (cfg.kill_switch)
83
+ this.killSwitchCache.add(id);
84
+ }
85
+ }
86
+ catch {
87
+ // Corrupt config — start with empty map
88
+ }
89
+ }
90
+ if (fs.existsSync(this.spendStorePath)) {
91
+ try {
92
+ const raw = JSON.parse(fs.readFileSync(this.spendStorePath, 'utf-8'));
93
+ this.spendCache = new Map(Object.entries(raw));
94
+ }
95
+ catch {
96
+ // Corrupt spend store — start fresh
97
+ }
98
+ }
99
+ }
100
+ save() {
101
+ const dir = path.dirname(this.configPath);
102
+ if (!fs.existsSync(dir))
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ const obj = {};
105
+ for (const [id, cfg] of this.tenants)
106
+ obj[id] = cfg;
107
+ fs.writeFileSync(this.configPath, JSON.stringify(obj, null, 2));
108
+ }
109
+ saveSpend() {
110
+ const dir = path.dirname(this.spendStorePath);
111
+ if (!fs.existsSync(dir))
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ const obj = {};
114
+ for (const [id, spend] of this.spendCache)
115
+ obj[id] = spend;
116
+ fs.writeFileSync(this.spendStorePath, JSON.stringify(obj, null, 2));
117
+ }
118
+ /** Register or update a tenant configuration. */
119
+ upsertTenant(tenantId, config) {
120
+ const now = new Date().toISOString();
121
+ const existing = this.tenants.get(tenantId);
122
+ const full = {
123
+ ...config,
124
+ created_at: existing?.created_at ?? now,
125
+ updated_at: now,
126
+ };
127
+ this.tenants.set(tenantId, full);
128
+ if (full.kill_switch) {
129
+ this.killSwitchCache.add(tenantId);
130
+ }
131
+ else {
132
+ this.killSwitchCache.delete(tenantId);
133
+ }
134
+ this.save();
135
+ return full;
136
+ }
137
+ /** Remove a tenant and all their spend records. */
138
+ deleteTenant(tenantId) {
139
+ const existed = this.tenants.delete(tenantId);
140
+ this.spendCache.delete(tenantId);
141
+ this.killSwitchCache.delete(tenantId);
142
+ if (existed) {
143
+ this.save();
144
+ this.saveSpend();
145
+ }
146
+ return existed;
147
+ }
148
+ /** Get a tenant config by ID. Returns undefined for unknown tenants. */
149
+ getTenant(tenantId) {
150
+ return this.tenants.get(tenantId);
151
+ }
152
+ /** List all tenants. */
153
+ listTenants() {
154
+ return Array.from(this.tenants.entries()).map(([id, config]) => ({ id, config }));
155
+ }
156
+ /**
157
+ * Extract tenant ID from an incoming request.
158
+ * Priority: x-tenant-id header > API key prefix registered in config.
159
+ * Returns 'default' if no tenant can be identified.
160
+ */
161
+ extractTenantId(headers, apiKey) {
162
+ // 1. Explicit header
163
+ const header = headers['x-tenant-id'];
164
+ if (header)
165
+ return Array.isArray(header) ? header[0] : header;
166
+ // 2. API key prefix match
167
+ if (apiKey) {
168
+ for (const [id, cfg] of this.tenants) {
169
+ if (cfg.tags?.['api_key_prefix'] && apiKey.startsWith(cfg.tags['api_key_prefix'])) {
170
+ return id;
171
+ }
172
+ }
173
+ }
174
+ return 'default';
175
+ }
176
+ /**
177
+ * Check whether a request from a tenant should be allowed.
178
+ * Checks kill-switch, model allowlist/denylist, and budget caps.
179
+ */
180
+ checkRequest(tenantId, model, estimatedCostUsd = 0) {
181
+ // Fast path: in-memory kill-switch check (no disk I/O)
182
+ if (this.killSwitchCache.has(tenantId)) {
183
+ return { allowed: false, tenant_id: tenantId, kill_switch_active: true, reason: 'Kill-switch is active for this tenant.' };
184
+ }
185
+ const config = this.tenants.get(tenantId);
186
+ if (!config) {
187
+ // Unknown tenant — allow by default (open proxy mode)
188
+ return { allowed: true, tenant_id: tenantId };
189
+ }
190
+ // Model allowlist check
191
+ if (model && config.allowed_models && config.allowed_models.length > 0) {
192
+ if (!config.allowed_models.includes(model)) {
193
+ return { allowed: false, tenant_id: tenantId, model_denied: true, reason: `Model '${model}' is not in the allowlist for tenant '${tenantId}'.` };
194
+ }
195
+ }
196
+ // Model denylist check
197
+ if (model && config.denied_models && config.denied_models.includes(model)) {
198
+ return { allowed: false, tenant_id: tenantId, model_denied: true, reason: `Model '${model}' is denied for tenant '${tenantId}'.` };
199
+ }
200
+ // Budget check
201
+ if (config.budget_usd_per_day !== undefined) {
202
+ const today = new Date().toISOString().slice(0, 10);
203
+ const spendKey = `${tenantId}:${today}`;
204
+ const spend = this.spendCache.get(spendKey);
205
+ const currentSpend = spend?.spend_usd ?? 0;
206
+ const remaining = config.budget_usd_per_day - currentSpend;
207
+ if (estimatedCostUsd > 0 && currentSpend + estimatedCostUsd > config.budget_usd_per_day) {
208
+ return {
209
+ allowed: false,
210
+ tenant_id: tenantId,
211
+ budget_exceeded: true,
212
+ daily_spend_usd: currentSpend,
213
+ daily_budget_remaining_usd: Math.max(0, remaining),
214
+ reason: `Daily budget of $${config.budget_usd_per_day} exceeded for tenant '${tenantId}'.`,
215
+ };
216
+ }
217
+ return {
218
+ allowed: true,
219
+ tenant_id: tenantId,
220
+ daily_spend_usd: currentSpend,
221
+ daily_budget_remaining_usd: Math.max(0, remaining),
222
+ };
223
+ }
224
+ return { allowed: true, tenant_id: tenantId };
225
+ }
226
+ /** Record spend for a tenant after a successful request. */
227
+ recordSpend(tenantId, costUsd) {
228
+ const today = new Date().toISOString().slice(0, 10);
229
+ const spendKey = `${tenantId}:${today}`;
230
+ const existing = this.spendCache.get(spendKey);
231
+ const now = new Date().toISOString();
232
+ this.spendCache.set(spendKey, {
233
+ tenant_id: tenantId,
234
+ date: today,
235
+ spend_usd: (existing?.spend_usd ?? 0) + costUsd,
236
+ request_count: (existing?.request_count ?? 0) + 1,
237
+ last_request_at: now,
238
+ });
239
+ this.saveSpend();
240
+ }
241
+ /** Get today's spend for a tenant. */
242
+ getDailySpend(tenantId) {
243
+ const today = new Date().toISOString().slice(0, 10);
244
+ return this.spendCache.get(`${tenantId}:${today}`)?.spend_usd ?? 0;
245
+ }
246
+ /**
247
+ * Generate the request context headers to inject into downstream traces.
248
+ * The proxy adds these before forwarding and strips them from the outbound
249
+ * request to the provider.
250
+ */
251
+ buildRequestContext(tenantId) {
252
+ return {
253
+ tenant_id: tenantId,
254
+ trace_id: `trace_${crypto.randomBytes(8).toString('hex')}`,
255
+ request_id: `req_${crypto.randomBytes(6).toString('hex')}`,
256
+ timestamp: new Date().toISOString(),
257
+ };
258
+ }
259
+ }
260
+ exports.TenantIsolator = TenantIsolator;
261
+ /** Singleton instance for use across the proxy server. */
262
+ let _instance;
263
+ function getTenantIsolator(options) {
264
+ if (!_instance)
265
+ _instance = new TenantIsolator(options);
266
+ return _instance;
267
+ }
268
+ /** Reset the singleton (for tests). */
269
+ function resetTenantIsolator() {
270
+ _instance = undefined;
271
+ }
272
+ //# sourceMappingURL=tenant-isolation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-isolation.js","sourceRoot":"","sources":["../src/tenant-isolation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export interface SearchableTool {
2
+ name: string;
3
+ description: string;
4
+ }
5
+ export declare class SemanticToolSearch {
6
+ private tools;
7
+ private vectors;
8
+ get size(): number;
9
+ indexTools(tools: SearchableTool[]): void;
10
+ search(query: string, topK: number): SearchableTool[];
11
+ }
12
+ //# sourceMappingURL=tool-search-semantic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-search-semantic.d.ts","sourceRoot":"","sources":["../src/tool-search-semantic.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AA6BD,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,KAAK,CAAwB;IACrC,OAAO,CAAC,OAAO,CAA6B;IAE5C,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,UAAU,CAAC,KAAK,EAAE,cAAc,EAAE,GAAG,IAAI;IAOzC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE;CAUtD"}
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SemanticToolSearch = void 0;
4
+ function tokenize(text) {
5
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(t => t.length > 1);
6
+ }
7
+ function buildVector(tokens) {
8
+ const freq = new Map();
9
+ for (const t of tokens) {
10
+ freq.set(t, (freq.get(t) ?? 0) + 1);
11
+ }
12
+ return freq;
13
+ }
14
+ function cosineSimilarity(a, b) {
15
+ let dot = 0;
16
+ let normA = 0;
17
+ let normB = 0;
18
+ for (const [term, countA] of a) {
19
+ dot += countA * (b.get(term) ?? 0);
20
+ normA += countA * countA;
21
+ }
22
+ for (const [, countB] of b) {
23
+ normB += countB * countB;
24
+ }
25
+ if (normA === 0 || normB === 0)
26
+ return 0;
27
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
28
+ }
29
+ class SemanticToolSearch {
30
+ tools = [];
31
+ vectors = [];
32
+ get size() {
33
+ return this.tools.length;
34
+ }
35
+ indexTools(tools) {
36
+ this.tools = [...tools];
37
+ this.vectors = tools.map(t => buildVector(tokenize(`${t.name} ${t.description}`)));
38
+ }
39
+ search(query, topK) {
40
+ if (this.tools.length === 0)
41
+ return [];
42
+ const queryVec = buildVector(tokenize(query));
43
+ const scored = this.tools.map((tool, i) => ({
44
+ tool,
45
+ score: cosineSimilarity(queryVec, this.vectors[i]),
46
+ }));
47
+ scored.sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name));
48
+ return scored.slice(0, topK).map(s => s.tool);
49
+ }
50
+ }
51
+ exports.SemanticToolSearch = SemanticToolSearch;
52
+ //# sourceMappingURL=tool-search-semantic.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-search-semantic.js","sourceRoot":"","sources":["../src/tool-search-semantic.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayplane/proxy",
3
- "version": "1.9.25",
3
+ "version": "1.9.27",
4
4
  "description": "Open source cost intelligence proxy for AI agents. Cut LLM costs ~80% with smart model routing. Dashboard, policy engine, 11 providers. MIT licensed.",
5
5
  "homepage": "https://relayplane.com",
6
6
  "repository": {