Skip to content

feat(proxy): per-client keys with per-client usage accounting - #186

Open
Frozen666 wants to merge 1 commit into
KarpelesLab:masterfrom
Frozen666:feat/per-client-usage
Open

feat(proxy): per-client keys with per-client usage accounting#186
Frozen666 wants to merge 1 commit into
KarpelesLab:masterfrom
Frozen666:feat/per-client-usage

Conversation

@Frozen666

@Frozen666 Frozen666 commented Aug 24, 2026

Copy link
Copy Markdown

Motivation

With a single shared proxy.apiKey, every consumer of the proxy looks the same: the per-account usage answers what was spent, but not by whom. When several people (or tools) share one proxy, there is no way to see which key holder consumed which share of the pooled quota.

What this adds

proxy.clientKeys — optional per-client keys alongside the shared proxy.apiKey:

"proxy": {
  "apiKey": "tc-shared",
  "clientKeys": [
    { "name": "alice", "key": "tc-alice-own-secret" },
    { "name": "bob",   "key": "tc-bob-own-secret" }
  ]
}
  • Both auth gates accept any configured key: the HTTP x-api-key gate and the CONNECT Proxy-Authorization gate (Bearer, or Basic with the key in either slot, so --proxy http://<key>@host:port works per client).
  • The tokens each response reports (SSE message_start/message_delta and non-streaming usage) are booked against the matching entry's name — same parsing, second dimension, alongside the untouched per-account accounting.
  • Counters surface under clients in /teamclaude/status and as a Clients section in teamclaude status; --activity-log lines get a [name] prefix.
  • Counters persist in the state file next to quota (additive restore, safe to delete as documented).
  • Key edits apply live via POST /teamclaude/reload — both gates read config.proxy per request, matching how eventLogging/blockedModels are read live.

Design notes

  • Attribution is best-effort by design: the shared apiKey and the loopback exemption stay unattributed (client: null) rather than inventing an identity. A loopback caller that does present a valid client key is attributed like any other. Deployments wanting complete stats give every consumer an entry.
  • In MITM mode the client identity rides the same mechanism as the TC_ACCT pin: the terminating-server map is keyed by (pin, client) and the listener carries forcedClient, for the same reason the pin does — no socket tagging. The map stays bounded by accounts × client keys, both operator-controlled.
  • resolveConnectPin now recognizes any configured key in the Basic username as auth-not-pin (previously only the shared key); it accepts both the legacy bare-string argument and the proxy config object, so existing callers are unaffected. connectAuthorized is kept as a boolean back-compat wrapper.
  • Every key candidate goes through the existing constant-time compare; the scan is over an operator-controlled handful of keys.

Tests

test/client-usage.test.js — 9 tests: tracker aggregation + additive restore with malformed entries; resolveClientAuth identity mapping (incl. clientKeys-only configs and a clientKeys entry duplicating the shared key); resolveConnectAuth for both Basic slots + Bearer + loopback semantics; resolveConnectPin back-compat; and end-to-end proxying against a mock upstream asserting tokens land on the right client for both SSE and JSON bodies while per-account totals are unchanged. Full suite passes, lint clean.

🤖 Generated with Claude Code

One shared proxy.apiKey means every consumer of a team proxy looks the same:
the per-account usage says WHAT was spent, never by WHOM. proxy.clientKeys
([{name, key}]) gives each consumer their own key; both auth gates (HTTP
x-api-key and CONNECT Proxy-Authorization) resolve which entry matched, and
the tokens each response reports are booked against that name — alongside the
existing per-account accounting, fed by the same response parsing.

Per-client counters show up under `clients` in /teamclaude/status and
`teamclaude status`, persist in the state file next to quota, and prefix
--activity-log lines with [name]. Key edits apply live via /teamclaude/reload.
The shared apiKey and the loopback exemption stay unattributed; a loopback
caller that DOES present a valid client key is attributed like any other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7DVJAvCRWgtw1TvNgsq4b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant