Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 71 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,33 @@ The router searches these files in order:
- `oauth.json`
- `config.json`

It reads the `accessToken` (or `access_token`, `oauthToken`, `oauth_token`) field from the first file found.
Two on-disk layouts are supported automatically:

- **Nested** (the format real Claude Code writes to `~/.claude/.credentials.json`):

```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1781050618000,
"scopes": ["user:inference", "user:profile"],
"subscriptionType": "max"
}
}
```

- **Flat** (convenient for tests and minimal setups):

```json
{ "accessToken": "sk-ant-oat01-..." }
```

For the nested layout the router reads `accessToken` and `expiresAt` from inside
`claudeAiOauth`. For the flat layout it reads `accessToken` (or `access_token`,
`oauthToken`, `oauth_token`) from the top level of the first file found. The
file is only ever read — the router never writes back to or deletes your
credential files.

### 3. Start the router

Expand Down Expand Up @@ -162,8 +188,16 @@ curl -s http://localhost:8080/api/latest/anthropic/v1/messages \
The router will:
1. Validate the `la_sk_...` token
2. Replace it with the real OAuth token from the Claude Code session
3. Forward the request to `https://api.anthropic.com/v1/messages`
4. Stream the response back to the client
3. Inject the upstream headers Claude MAX OAuth requires — `anthropic-version`
(default `2023-06-01` when the client omits it) and the
`anthropic-beta: oauth-2025-04-20` flag (merged with any betas the client
already sent)
4. Forward the request to `https://api.anthropic.com/v1/messages`
5. Stream the response back to the client

Because the router injects these headers itself, a client only needs to send the
`la_sk_...` token — it never needs the real OAuth token, the OAuth beta flag, or
even an `anthropic-version` header.

## Using with Claude Code

Expand Down Expand Up @@ -518,6 +552,8 @@ link-assistant-router

# Issue / list / revoke / show tokens locally (no HTTP needed):
link-assistant-router tokens issue --ttl-hours 168 --label alice
# ...optionally cap how many upstream requests the token may make:
link-assistant-router tokens issue --ttl-hours 168 --label alice --max-requests 500
link-assistant-router tokens list
link-assistant-router tokens revoke <id>
link-assistant-router tokens show <id>
Expand Down Expand Up @@ -656,9 +692,10 @@ The router uses JWT-based custom tokens with the `la_sk_` prefix.

### Token lifecycle

1. **Issue**: `POST /api/tokens` creates a signed JWT with a UUID subject, expiration, and optional label
1. **Issue**: `POST /api/tokens` creates a signed JWT with a UUID subject, expiration, optional label, and an optional per-token request budget
2. **Validate**: Each proxy request extracts the `Authorization: Bearer la_sk_...` header, strips the prefix, and verifies the JWT signature and expiration
3. **Revoke**: Tokens can be revoked by their subject ID (stored in-memory; revocations are lost on restart)
3. **Meter**: When the token carries a request budget, each forwarded request increments a persisted `used_requests` counter; once it reaches `max_requests` the router returns `429 Too Many Requests` instead of forwarding upstream
4. **Revoke**: Tokens can be revoked by their subject ID. Records (including the revoked flag and usage counter) are written to the persistent token store, so revocations and usage survive restarts

### Token format

Expand All @@ -673,12 +710,41 @@ Tokens are standard HS256 JWTs with the `la_sk_` prefix. The JWT payload contain
}
```

### Per-token request budget

Each token can carry an optional cap on the number of upstream requests it may
make. This lets you hand a scoped token to a separate task or agent and bound
how much of your Claude MAX subscription that task can consume, without ever
exposing the real OAuth credential.

```bash
# CLI: issue a token limited to 100 upstream requests
link-assistant-router tokens issue --ttl-hours 24 --label scoped-agent --max-requests 100

# HTTP: same, via the admin endpoint
curl -s -X POST http://localhost:8080/api/tokens \
-H "Content-Type: application/json" \
-d '{"ttl_hours": 24, "label": "scoped-agent", "max_requests": 100}' | jq .
```

- Omitting `--max-requests` / `max_requests` leaves the token **unlimited**.
- Usage is counted per forwarded request and persisted in the token store, so
the budget is enforced across restarts.
- When the budget is exhausted the router responds with
`429 Too Many Requests` and a `rate_limit_error` body
(`{"error":{"message":"Token has reached its request limit",...}}`) instead of
forwarding upstream.
- `tokens list` shows a `requests` column as `used/max` (e.g. `42/100`), or
`used/-` for unlimited tokens.

### Security notes

- The `TOKEN_SECRET` must be kept secure — anyone with the secret can forge tokens
- OAuth tokens from the Claude Code session are never exposed to clients
- Tokens are validated on every request
- Use a strong, random secret (e.g., `openssl rand -hex 32`)
- Pair short TTLs with `max_requests` to give each task a tightly scoped,
self-expiring credential

## Testing

Expand Down
32 changes: 32 additions & 0 deletions changelog.d/20260609_233000_issue_35_local_testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
bump: minor
---

### Added

- Added a per-token request budget: tokens can carry an optional `max_requests`
cap with a persisted `used_requests` counter, enforced on every upstream
forwarding path (Anthropic, OpenAI-compatible, Gonka) with an HTTP 429
`rate_limit_error` once exhausted. Exposed via the CLI `tokens issue
--max-requests`, the `POST /api/tokens` `max_requests` field, and a `used/max`
column in `tokens list`.
- Added the issue #35 case-study package under `docs/case-studies/issue-35`,
including a full requirement trace, online research with primary sources, an
existing-components survey (LiteLLM virtual keys/budgets, Portkey, Kong AI
Gateway, community Claude proxies), and redacted live end-to-end evidence.

### Fixed

- Fixed Claude MAX credential reading: the router now parses the real Claude Code
`~/.claude/.credentials.json` layout, where the OAuth token is nested under a
`claudeAiOauth` object (`accessToken`, `refreshToken`, `expiresAt`, `scopes`,
`subscriptionType`), in addition to the previously supported flat layout.
`doctor` now probes the credential file and reports whether a usable token was
found.

### Changed

- Documented the nested credential layout, transparent header injection
(`anthropic-version` default plus the `anthropic-beta: oauth-2025-04-20` flag),
and the per-token request budget in `README.md`, and corrected the stale note
claiming token revocations are lost on restart (records are persisted).
158 changes: 158 additions & 0 deletions docs/case-studies/issue-35/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Issue 35 Case Study: Test it locally

## Summary

Issue 35 asked us to use the newly available local Docker and Claude access to
**actually run the router end-to-end**, confirm that everything the
documentation claims really works, and fix the root cause of anything that does
not — in both code and docs. The central functional requirement was to prove
that the proxy can **issue a token that hides the real Claude MAX OAuth access
token**, so that separate tasks can be given a scoped credential that grants
access to the subscription while limiting how much each task can consume.

The work in PR #36 did three things:

1. **Verified the documented credential flow against a real Claude MAX session**
and found that the router only read a *flat* credential layout, while real
Claude Code writes a *nested* `claudeAiOauth` object to
`~/.claude/.credentials.json`. This was the one genuine code bug — fixed in
`src/oauth.rs`.
2. **Confirmed transparent passthrough and token hiding end-to-end** against
`api.anthropic.com` using the live subscription, with the real OAuth token
never appearing in logs or client-visible output.
3. **Added the "limit how much each task can use" capability** the issue asks
for: a per-token request budget (`max_requests`) enforced with HTTP 429,
persisted across restarts, and surfaced in the CLI, the admin API, and
`tokens list`.

## Requirements

The full requirement-by-requirement trace is in
[`requirements.md`](./requirements.md). At a glance, the issue contains five
explicit requirements:

1. Test everything documented locally (Claude + Docker) and fix root causes in
code **and** docs.
2. Make it possible to issue a token that hides the real access token, copying
local configuration as needed **without deleting it**.
3. Produce an API token that grants subscription access to separate tasks and
**limits how much each task can use**.
4. Collect issue data into `docs/case-studies/issue-35/`, do a deep case-study
analysis, and search online for additional facts.
5. List every requirement, propose solutions/plans per requirement, and survey
existing components/libraries that solve a similar problem.

## Root Cause: nested credential layout

Real Claude Code stores its OAuth session like this (token bytes redacted):

```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-…",
"refreshToken": "sk-ant-ort01-…",
"expiresAt": 1781050618000,
"scopes": ["user:inference", "user:profile"],
"subscriptionType": "max"
}
}
```

The pre-fix reader looked only for a top-level `accessToken`/`access_token`
field, so against a real session it found **no token** and could not substitute
the upstream credential. `src/oauth.rs` now accepts both the nested
`claudeAiOauth` object and the flat layout via `extract_token()` /
`expires_at_ms()`, covered by unit tests. `run_doctor` additionally probes the
credential file and reports `found, token OK` / `found, NO TOKEN` / `MISSING`.

## Feature: per-token request budget

To satisfy "limit how much each task can use tokens", a token now carries an
optional `max_requests` cap and a persisted `used_requests` counter:

- `src/storage.rs`: `TokenRecord` gained `max_requests: Option<u64>` and
`used_requests: u64` (both `#[serde(default)]` for backward compatibility),
the Lino text codec round-trips `(max_requests N)` / `(used_requests N)`, and
`TokenStore::try_consume_request` atomically-enough checks-and-increments.
- `src/token.rs`: `issue_token_full(ttl, label, account, max_requests)` writes
the cap; `enforce_request_budget(token_id)` returns
`TokenError::LimitExceeded` once the cap is hit.
- `src/proxy.rs`: every forwarding path (`/v1/messages`, OpenAI, Gonka) calls
`enforce_request_budget` after token validation and returns
`429 rate_limit_error` when exhausted.
- `src/token_admin.rs`, `src/cli.rs`, `src/main.rs`: the admin endpoint accepts
`max_requests`, the CLI accepts `--max-requests`, and `tokens list` shows a
`used/max` column.

## Evidence

Archived under [`raw/`](./raw/):

- `issue-35.json`, `issue-35-comments.json` — issue body and (zero) comments.
- `server.log.redacted` — router startup log from the live run (no OAuth token
present; verified with `grep`).
- `count_tokens-200.json` — `/v1/messages/count_tokens` returned
`{"input_tokens":14}` (HTTP 200) when the client sent **only** a `la_sk_`
token and no `anthropic-version`/beta headers, proving transparent header
injection and token substitution work.
- `budget-exhausted-429.json` — the router's own
`{"error":{"message":"Token has reached its request limit",...}}` body.
- `tokens-list.txt` — `tokens list` showing the `e2e-budget` token at `2/2`
(exhausted) alongside unlimited `…/-` tokens.

## End-to-end verification

Performed live against `https://api.anthropic.com` with a copy of the real
Claude MAX credentials (the original file at `~/.claude/.credentials.json` was
**only read/copied, never modified or deleted** — confirmed unchanged at 471
bytes afterward):

| Check | Result |
| --- | --- |
| Client sends only `la_sk_` token to `count_tokens` | HTTP 200, `{"input_tokens":14}` |
| Real OAuth token appears in server logs | 0 occurrences (`grep`) |
| Missing token | 401 |
| Invalid token | 401 |
| Revoked token | 403 |
| Capped token (`max_requests=2`) after 2 requests | 3rd request → our 429 `Token has reached its request limit` (no upstream `request_id`) |
| Unlimited token | unaffected |
| Usage persistence | text store shows `(max_requests 2) (used_requests 2)`; `tokens list` shows `2/2` |

Note: live `/v1/messages` inference returned an upstream `429` with a genuine
Anthropic `request_id` during testing. That is a real account-level inference
rate limit on the shared MAX account, **not** a router bug — proven by
`count_tokens` (which is not inference-metered) returning 200 through the same
path.

### Docker

The same flow was re-verified through the container image. `link-assistant/router`
was built from the repo `Dockerfile` and run with a **copy** of the real Claude
MAX credentials mounted read-only at `/data/claude` (the Dockerfile default
`CLAUDE_CODE_HOME`); the original `~/.claude/.credentials.json` was never touched.
The container read the nested credential, started cleanly, issued a token with
`max_requests`, returned HTTP 200 from `count_tokens` for a client sending only a
`la_sk_` token, enforced the budget with 429, returned 401 for missing/invalid
tokens, and never logged the real OAuth token. Evidence is in
[`raw/docker/`](./raw/docker/).

## Online research and component survey

See [`online-research.md`](./online-research.md) for primary-source facts on the
Claude Code credential format, the `anthropic-beta: oauth-2025-04-20` flag, and
the `anthropic-version` header, and [`components-survey.md`](./components-survey.md)
for how existing gateways (LiteLLM virtual keys/budgets, Portkey, Kong AI
Gateway, community Claude proxies) solve the same scoped-credential / budget
problem and why a small native counter was the right fit here.

## Local Verification

The standard local CI gate was run before finalizing (see PR #36 for the
authoritative status):

- `cargo fmt --all -- --check`
- `cargo clippy --all-targets --all-features`
- `rust-script scripts/check-file-size.rs`
- `cargo test --all-features`
- `cargo test --doc`
- `cargo build --release`
38 changes: 38 additions & 0 deletions docs/case-studies/issue-35/components-survey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Existing Components & Libraries Survey

The issue explicitly asks to "check known existing components/libraries that
solve a similar problem or can help in solutions." The two sub-problems are:

1. **Hiding an upstream credential behind a gateway-issued key** (token
substitution).
2. **Limiting how much each issued key can consume** (per-key budget).

| Component | Credential hiding | Per-key usage limit | Fit for this repo |
| --- | --- | --- | --- |
| [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/virtual_keys) | Virtual keys map to real provider keys held server-side. | `max_budget` (spend $), `budget_duration`, `rpm_limit`/`tpm_limit`, `max_parallel_requests`; rejects on exceed. | Closest prior art. Heavyweight (Python, DB, full provider matrix); pulling it in would replace, not complement, this Rust gateway. Used as the **design reference** for the budget feature. |
| [Portkey AI Gateway](https://portkey.ai/) | Virtual keys / configs hold provider secrets. | Budgets and rate limits per key/workspace. | SaaS-leaning; same conceptual model, not embeddable as a library here. |
| [Kong AI Gateway](https://konghq.com/products/kong-ai-gateway) | Plugin holds upstream auth. | Rate-limiting / cost plugins. | General API-gateway; far larger surface than a single-subscription Claude proxy needs. |
| Community Claude proxies (e.g. `claude-code-proxy`-style projects) | Substitute a static API key / OAuth token upstream. | Generally **none** — they proxy but do not meter per client key. | Confirms credential substitution is standard, but the per-task limit (the issue's core ask) is the gap this PR fills. |
| Rust crates: `jsonwebtoken`, `governor`, `tower_governor` | — | `governor`/`tower_governor` give time-windowed rate limiting (requests/second). | `governor` solves *rate over time*, not a *total lifetime budget per token* with persistence. Adopting it would not satisfy "limit how much each task can use" (a cumulative cap survived across restarts). |

## Decision

- **Credential hiding** was already implemented in the router (the `la_sk_` →
OAuth bearer substitution). The only change needed was correctly *reading* the
real nested credential — a bug fix, not a new component.
- **Per-task limit**: rather than add a dependency, we implemented a minimal,
persisted **per-token request counter** (`max_requests` / `used_requests`)
modeled on LiteLLM's per-key budget concept. Reasons:
- The existing `TokenStore` already persists `TokenRecord`s, so the counter
rides along for free and survives restarts (which `governor`'s in-memory
limiter and LiteLLM's external DB would not, respectively, satisfy without
extra moving parts).
- "Number of requests" is the unit the issue names ("limit how much each task
can use tokens"), and it is provider-agnostic across the Anthropic, OpenAI,
and Gonka forwarding paths.
- Zero new dependencies keeps the strict clippy/file-size CI gates and the
single-container deployment story intact.

A spend- or token-based budget and a time-windowed `rpm`/`tpm` limit remain
natural future extensions on the same `TokenRecord`, but are out of scope for
this issue's explicit request.
Loading
Loading