Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ src/
init.ts # Main entry point — wires interceptor, registry, aggregator, transport
core/
types.ts # All interfaces: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
provider-registry.ts # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, specificity-sorted rule list (custom wins on tie)
interceptor.ts # Patches globalThis.fetch, http.request, https.request, http.get, https.get; double-count guard; query stripping
aggregator.ts # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
transport.ts # Cloud mode (HTTPS POST with exponential backoff, max 3 retries) + local mode (WebSocket with auto-reconnect)
Expand DownExpand Up@@ -75,7 +75,7 @@ LICENSE
- **Infrastructure**: Pinecone, AWS (wildcard), Google Cloud (wildcard)
- **Other**: GitHub, CoinGecko, Hacker News, wttr.in, ZenQuotes, ip-api

Custom providers are prepended before built-ins (higher priority). Unrecognized hosts are grouped under `"unknown"`.
Custom and built-in rules are merged and sorted by specificity at construction time: rules with a `pathPrefix` come before those without, longer `pathPrefix` wins, exact host beats `*.` wildcard, and on equal specificity custom rules win. So a custom catch-all does not shadow built-in path-specific rules, but a custom rule with an equal-or-more-specific `pathPrefix` overrides the built-in. Unrecognized hosts are grouped under `"unknown"`, and host-only catch-all matches return `"other"` as the endpoint category.

## Transport Modes

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,7 +90,7 @@ All fields are optional.
| `localPort` | `number` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `boolean` | `false` | Log telemetry activity to stdout. |
| `enabled` | `boolean` | `true` | Master kill switch. Set `false` to disable in tests. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with higher priority than built-ins. |
| `customProviders` | `ProviderDef[]` | `[]` | Extra provider rules merged with built-ins; sorted by specificity (longer `pathPrefix` wins; on tie, custom beats built-in). |
| `excludePatterns` | `string[]` | `[]` | URL substrings that cause a request to be silently dropped. |
| `baseUrl` | `string` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `maxRetries` | `number` | `3` | Retry attempts for failed cloud flushes. |
Expand DownExpand Up@@ -178,6 +178,17 @@ init({
});
```

### Custom provider priority

Custom and built-in rules are merged and sorted by specificity at `ProviderRegistry` construction time. The sort is:

1. Rules with a `pathPrefix` come before rules without.
2. Longer `pathPrefix` wins (more specific).
3. Exact host beats `*.` wildcard host.
4. On equal specificity, custom rules win.

So a custom catch-all (`{ hostPattern: "api.openai.com", provider: "openai-mock" }` with no `pathPrefix`) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the same host DOES override the built-in (equal specificity → custom wins).

### Cleanup / teardown

`init()` returns a handle with a `dispose()` method that stops the interceptor, cancels the flush timer, and closes the transport connection. Useful in tests or when you want to reinitialize with different config.
Expand DownExpand Up@@ -222,7 +233,7 @@ const registry = new ProviderRegistry();
const result = registry.match("https://api.openai.com/v1/chat/completions");
// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }

// Registry with custom rules taking priority
// Registry with custom rules priority by specificity, custom wins on tie
const custom = new ProviderRegistry([
{ hostPattern: "api.acme.com", provider: "acme", endpointCategory: "api", costPerRequestCents: 0.1 },
]);
Expand Down
1,446 changes: 1,446 additions & 0 deletions docs/superpowers/plans/2026-05-15-provider-registry-overhaul.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/roadmap-2026-05-13-issue-waves.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 3 — Interceptor surgical fixes

**Status:** in-progress
**Status:** done

**Merged PR:** https://github.com/recost-dev/middleware-node/pull/35

**Plan:** `plans/2026-05-15-interceptor-surgical-fixes.md`

Expand All@@ -78,7 +80,9 @@ Both touch `WindowSummary` serialization. Coordinate Python + Node together so n

## Wave 4 — Provider registry overhaul

**Status:** pending
**Status:** in-progress

**Plan:** `plans/2026-05-15-provider-registry-overhaul.md`

**Theme:** Registry correctness — matching priority, cardinality, bucket cap.

Expand Down
36 changes: 32 additions & 4 deletions src/core/aggregator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ export class Aggregator {
private _buckets = new Map<string, Bucket>();
private _windowStart: string | null = null;
private _size = 0;
private _overflowCount = 0;

constructor(config: AggregatorConfig = {}) {
this._environment = config.environment ?? "development";
Expand All@@ -87,8 +88,14 @@ export class Aggregator {
}

/**
* True if ingesting this event would allocate a new bucket AND the current
* window is already at maxBuckets capacity. Callers should flush first.
* Early-flush hint: true if ingesting this event would allocate a new bucket
* AND the current window is already at `maxBuckets` capacity. Callers may
* trigger an early flush to preserve the window before adding more events.
*
* Note: this is a hint, not a guarantee. Even without a flush, `ingest()`
* itself synchronously enforces the cap by redirecting new keys into a
* per-provider `_overflow` bucket — so cardinality stays bounded even when
* the caller misses the hint or hits an async gap before flushing.
*/
wouldOverflow(event: RawEvent): boolean {
if (this._buckets.size < this._maxBuckets) return false;
Expand All@@ -112,8 +119,20 @@ export class Aggregator {
}

const provider = event.provider ?? "unknown";
const endpoint = event.endpointCategory ?? event.path;
const key = this._keyFor(event);
let endpoint = event.endpointCategory ?? event.path;
let key = this._keyFor(event);

// Soft cap enforced synchronously: if we're at the bucket limit AND this
// event would create a new bucket, redirect into a per-provider _overflow
// bucket. Counts / latencies / bytes / cost are still accumulated — only
// endpoint cardinality is bounded. `wouldOverflow()` remains the early-
// flush hint, but the async gap between hint and flush in init.ts is now
// closed here.
if (this._buckets.size >= this._maxBuckets && !this._buckets.has(key)) {
endpoint = "_overflow";
key = `${provider}::_overflow::${event.method}`;
this._overflowCount += 1;
}

let bucket = this._buckets.get(key);
if (bucket === undefined) {
Expand DownExpand Up@@ -176,6 +195,7 @@ export class Aggregator {
this._buckets = new Map();
this._windowStart = null;
this._size = 0;
this._overflowCount = 0;

return {
environment: this._environment,
Expand All@@ -201,4 +221,12 @@ export class Aggregator {
get maxBuckets(): number {
return this._maxBuckets;
}

/**
* Number of events redirected into a `_overflow` bucket since the last flush
* because the bucket cap was reached. Resets to 0 on every `flush()`.
*/
get overflowCount(): number {
return this._overflowCount;
}
}
88 changes: 75 additions & 13 deletions src/core/provider-registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
/**
* ProviderRegistry — matches intercepted request URLs to known API providers.
*
* Rules are checked in order; the first match wins. Custom providers are
* prepended at construction time so they always take priority over built-ins.
* Rules are checked in order; the first match wins. At construction time,
* custom and built-in rules are merged and sorted by specificity (descending):
* 1. Rules with `pathPrefix` come before rules without.
* 2. Within those, longer `pathPrefix` beats shorter (more specific).
* 3. Within those, exact host beats `*.` wildcard host.
* 4. On equal specificity, custom rules beat built-in rules.
*
* This means a custom catch-all (no `pathPrefix`) for `api.openai.com` does NOT
* shadow the built-in `/v1/chat/completions` rule — the built-in is more
* specific. A custom rule with `pathPrefix: "/v1/chat/completions"` on the
* same host DOES override the built-in (equal specificity → custom wins).
*/

import { URL } from "node:url";
Expand All@@ -16,7 +25,7 @@ import type { ProviderDef } from "./types.js";
export interface MatchResult {
/** Matched provider name (e.g. "openai"). */
provider: string;
/** Matched endpoint category (e.g. "chat_completions"), or the raw pathname. */
/** Matched endpoint category (e.g. "chat_completions"), or "other" for catch-all matches. */
endpointCategory: string;
/** Estimated cost per request in cents. 0 when no cost data is available. */
costPerRequestCents: number;
Expand DownExpand Up@@ -54,7 +63,11 @@ export const BUILTIN_PROVIDERS: ProviderDef[] = [
{ hostPattern: "api.stripe.com", provider: "stripe", costPerRequestCents: 0 },

// ── Twilio ────────────────────────────────────────────────────────────────
// Path structure varies by account SID; categorization happens post-match in match().
// Path structure varies by account SID; categorization happens post-match
// in match() via refineTwilio().
// Default (unrefined) cost: 0.5¢ placeholder for endpoints we don't
// explicitly recognize. Source: rough median across Twilio's per-product
// pricing pages, reviewed 2026-05-15.
{ hostPattern: "api.twilio.com", provider: "twilio", costPerRequestCents: 0.5 },

// ── SendGrid ──────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -115,31 +128,77 @@ function hostMatches(pattern: string, hostname: string): boolean {
// Twilio path refinement
// ---------------------------------------------------------------------------

/** Refines category and cost for Twilio after a host-level match. */
/**
* Refines category and cost for Twilio after a host-level match.
*
* Pricing constants below are per-request US-outbound averages. They are
* rough estimates for relative cost comparison only — actual Twilio pricing
* varies by destination country, sender type, and volume discounts.
*/
function refineTwilio(pathname: string): Pick<MatchResult, "endpointCategory" | "costPerRequestCents"> {
if (pathname.includes("/Messages")) {
// Twilio SMS: $0.0079/msg US outbound.
// Source: https://www.twilio.com/sms/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "sms", costPerRequestCents: 0.79 };
}
if (pathname.includes("/Calls")) {
// Twilio Voice: $0.013/min US outbound (per-minute, treated as per-request
// for a typical short call).
// Source: https://www.twilio.com/voice/pricing/us — reviewed 2026-05-15.
return { endpointCategory: "voice_calls", costPerRequestCents: 1.3 };
}
return { endpointCategory: pathname, costPerRequestCents: 0.5 };
// Unrecognized Twilio path: fall back to "other" rather than the raw
// pathname (which would include account SIDs and explode cardinality
// downstream in the aggregator).
return { endpointCategory: "other", costPerRequestCents: 0.5 };
}

// ---------------------------------------------------------------------------
// ProviderRegistry
// ---------------------------------------------------------------------------

/** Maps intercepted request URLs to provider metadata using an ordered rule list. */
/** Compares two tagged rules by specificity descending (more specific first). */
function compareRules(
a: { rule: ProviderDef; custom: boolean },
b: { rule: ProviderDef; custom: boolean },
): number {
// Tier 1: rules with pathPrefix come before rules without
const aHasPath = a.rule.pathPrefix !== undefined ? 1 : 0;
const bHasPath = b.rule.pathPrefix !== undefined ? 1 : 0;
if (aHasPath !== bHasPath) return bHasPath - aHasPath;

// Tier 2: longer pathPrefix wins (more specific)
const aLen = a.rule.pathPrefix?.length ?? 0;
const bLen = b.rule.pathPrefix?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;

// Tier 3: exact host beats *. wildcard host
const aExact = a.rule.hostPattern.startsWith("*.") ? 0 : 1;
const bExact = b.rule.hostPattern.startsWith("*.") ? 0 : 1;
if (aExact !== bExact) return bExact - aExact;

// Tier 4: custom rules win on tie
if (a.custom !== b.custom) return a.custom ? -1 : 1;

return 0;
}

/** Maps intercepted request URLs to provider metadata using a priority-sorted rule list. */
export class ProviderRegistry {
private readonly _rules: ProviderDef[];

/**
* @param customProviders - Optional extra rules prepended before built-ins,
* giving them higher matching priority.
* @param customProviders - Optional extra rules. Merged with built-ins and
* sorted by specificity (longer `pathPrefix` first, exact host before
* wildcard, custom-wins-on-tie). See the class JSDoc for the full rule.
*/
constructor(customProviders: ProviderDef[] = []) {
this._rules = [...customProviders, ...BUILTIN_PROVIDERS];
const tagged: { rule: ProviderDef; custom: boolean }[] = [
...customProviders.map((rule) => ({ rule, custom: true })),
...BUILTIN_PROVIDERS.map((rule) => ({ rule, custom: false })),
];
tagged.sort(compareRules);
this._rules = tagged.map((t) => t.rule);
}

/**
Expand All@@ -161,8 +220,11 @@ export class ProviderRegistry {
if (!hostMatches(rule.hostPattern, hostname)) continue;
if (rule.pathPrefix !== undefined && !pathname.startsWith(rule.pathPrefix)) continue;

// Host (and optional path) matched — build the result
let endpointCategory = rule.endpointCategory ?? pathname;
// Host (and optional path) matched — build the result.
// When the rule has no explicit endpointCategory and no provider-specific
// refiner applies, fall back to the literal "other". Returning the raw
// pathname here leaks account-SID-style segments into downstream buckets.
let endpointCategory = rule.endpointCategory ?? "other";
let costPerRequestCents = rule.costPerRequestCents ?? 0;

// Post-match refinement for providers with dynamic path structures
Expand All@@ -178,7 +240,7 @@ export class ProviderRegistry {
return null;
}

/** Returns all rules in priority order (custom first, built-ins after). */
/** Returns all rules sorted by specificity (more-specific first; custom wins on tie). See the class JSDoc for the full ordering rule. */
list(): ProviderDef[] {
return this._rules;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/aggregator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -358,3 +358,80 @@ describe("Aggregator — bucket overflow protection", () => {
expect(agg.wouldOverflow(overflowEvent)).toBe(true);
});
});

describe("Aggregator — soft cap (ingest-time)", () => {
it("at cap, an event with a new key is redirected to a per-provider _overflow bucket", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// This is event #4 with a new (provider, endpoint, method) triplet — at cap.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "d", latencyMs: 999, requestBytes: 7, responseBytes: 11 }), 1.5);

// A new _overflow bucket is created — bucketCount goes to 4. The cap is
// soft: the redirect bucket is allowed to exceed the limit by exactly 1
// per (provider, method) — counts stay bounded, attribution preserved.
expect(agg.bucketCount).toBe(4);
expect(agg.overflowCount).toBe(1);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow" && m.provider === "p");
expect(overflow).toBeDefined();
expect(overflow!.requestCount).toBe(1);
expect(overflow!.totalLatencyMs).toBe(999);
expect(overflow!.totalRequestBytes).toBe(7);
expect(overflow!.totalResponseBytes).toBe(11);
expect(overflow!.estimatedCostCents).toBeCloseTo(1.5);
});

it("at cap, an event matching an EXISTING bucket key still ingests normally", () => {
const agg = new Aggregator({ maxBuckets: 3 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" }));
expect(agg.bucketCount).toBe(3);

// Same triplet as the first event — no new bucket needed, no overflow.
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
expect(agg.bucketCount).toBe(3);
expect(agg.overflowCount).toBe(0);

const summary = agg.flush()!;
const bucketA = summary.metrics.find((m) => m.endpoint === "a")!;
expect(bucketA.requestCount).toBe(2);
});

it("multiple over-cap events accumulate into one _overflow bucket per (provider, method)", () => {
const agg = new Aggregator({ maxBuckets: 2 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a", method: "GET" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b", method: "GET" }));

// 5 over-cap events, all with the same (provider, method) but different
// endpoints — they all collapse into a single (p, _overflow, GET) bucket.
for (let i = 0; i < 5; i++) {
agg.ingest(makeEvent({ provider: "p", endpointCategory: `new-${i}`, method: "GET", latencyMs: 100 }), 0.5);
}

expect(agg.bucketCount).toBe(3); // 2 original + 1 overflow
expect(agg.overflowCount).toBe(5);

const summary = agg.flush()!;
const overflow = summary.metrics.find((m) => m.endpoint === "_overflow")!;
expect(overflow.requestCount).toBe(5);
expect(overflow.totalLatencyMs).toBe(500);
expect(overflow.estimatedCostCents).toBeCloseTo(2.5);
});

it("overflowCount is exposed via getter and resets to 0 on flush", () => {
const agg = new Aggregator({ maxBuckets: 1 });
agg.ingest(makeEvent({ provider: "p", endpointCategory: "a" }));
agg.ingest(makeEvent({ provider: "p", endpointCategory: "b" })); // overflow #1
agg.ingest(makeEvent({ provider: "p", endpointCategory: "c" })); // overflow #2 (same _overflow bucket, but still counted)
expect(agg.overflowCount).toBe(2);

agg.flush();
expect(agg.overflowCount).toBe(0);
});
});
Loading