fix(gateway): close billing, limit, routing and translation gaps

Billing:
- Passthrough SSE streams now bill the usage the upstream reports.
  The gateway falls back to a request-size estimate only on a 2xx
  stream or a truncated JSON body.
- A unified stream that fails after it starts is estimated, not
  billed at zero.
- Billing uses the built-in LiteLLM price table and honours
  modelMeta free: true.
- Pricing entries are validated. Cost arithmetic rounds the total and
  saturates on overflow.
- Gemini thinking tokens count as completion tokens.

Limits and storage:
- A rejected request rolls back its counts on the other scopes, so one
  user cannot lock out a group.
- Redis EXPIRE replies are checked. Keys recreated without a TTL are
  repaired at once.
- A mutating pipeline is not retried after reply bytes arrive.
- History, model totals and target usage report a store outage.

Routing and translation:
- Discovered ids with a provider-like prefix are listed only in
  owner/id form, so listing and routing agree.
- Anthropic and Gemini discovery follows pagination.
- The developer role maps to system.
- stream_options.include_usage is honoured on translated streams.
- tool_choice none keeps tools when history holds tool blocks.
- refusal maps to content_filter. Data URI parameters are stripped.

Streaming, retry and failover:
- SSE accepts CR, LF and CRLF line ends. This closes a CR injection
  path.
- Retry-After is never jittered. A cancelled wait returns the
  context error.
- The watchdog timer stops at end of body.
- Oversized bodies return 413.
- A failed stream ends with an error event.
- Failover restores pre-existing headers. Client cancels do not count
  as provider successes.

Also: "admin" is a reserved provider name. metrics.path must start
with "/". Pricing warnings go to the right gateway instance. The admin
targets view sends access as null or [] so the two states differ.
Upstream organisation headers are stripped. The users-file reload
error is logged once per error. Dead code is removed, and test-only
helpers move to test files.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 09:44:31 +01:00
parent b3b8ae6ad5
commit 4c01c75276
62 changed files with 6064 additions and 1182 deletions
+22 -7
View File
@@ -1272,12 +1272,27 @@ type adminTargetCountersView struct {
// group.allowsMCP/allowsAgent themselves delegate to (auth.go) against
// each group's own cloned mcpServers/agents pattern list (groupSummary,
// authStore.snapshot), so this view can never disagree with what
// handleTargetProxy actually enforces. Access is omitted (nil) when every
// configured group can reach this target — "empty meaning all", mirroring
// groupSummary's own providers/models/mcpServers/agents omitempty
// convention (adminUsageEntryView) — rather than always listing every
// group name, which would grow with the group catalog for no reason once
// nothing is actually restricted.
// handleTargetProxy actually enforces.
//
// Access's JSON contract (review-auth finding F2, 2026-09 review — fixed
// from a prior "omitempty" tag): "access":null means every configured
// group can reach this target ("empty meaning all" — adminTargetAccess
// returns a nil slice for that case); "access":[] (present, zero-length)
// means NO configured group can reach it; "access":["g1",...] lists the
// groups that can. The prior omitempty tag made the first two cases
// serialize identically (both simply absent from the JSON body), so a
// target restricted to zero groups rendered in the webui exactly like an
// unrestricted one — the opposite of what its own access rule enforces.
// No omitempty here specifically because that distinction depends on nil
// vs. non-nil-but-empty, which omitempty collapses. adminTargetAccess's
// own return value is unchanged by this fix (it already returned nil for
// "all" and a non-nil, possibly zero-length, slice otherwise) — only this
// struct's serialization of that value changes. The webui (webui/src/
// types/api.ts's AdminTargetView.access, webui/src/lib/target-columns.ts)
// must be updated to treat null/undefined and a zero-length array as
// DIFFERENT states, distinct from today's "!access?.length means All
// groups" check that treats both alike; report this contract to whoever
// owns that file.
//
// Health is feat/target-health's own readout (targetHealthView, below).
// Field order (Health first, the struct-typed field, then the strings/
@@ -1288,7 +1303,7 @@ type adminTargetView struct {
Health adminTargetHealthView `json:"health"`
Name string `json:"name"`
URL string `json:"url"`
Access []string `json:"access,omitempty"`
Access []string `json:"access"`
Counters adminTargetCountersView `json:"counters"`
}