Skip to content

Commit 1c14172

Browse files
iceteaSAualtinok
authored andcommitted
feat: harvest quota headers on relay transports
Relay responses now feed the passive quota-header harvester: HTTP relay responses expose upstream headers directly, and the WebSocket relay delivers genuine upstream headers from response_start control messages via an onResponseHeaders callback in sendViaRelay. Synthetic optimistic response headers are never harvested; relay-to-direct fallback keeps header harvesting on the direct path. The WebSocket callback is idempotent per logical request with last-accepted-response wins: headers are staged at response_start and fire exactly once when the response commits (first downstream bytes or completion). A pre-byte recovery that resends full_sync replaces the staged headers with the retry's frame — the response that actually streams is also the fresher quota evidence — and duplicate response_start frames within one attempt are ignored.
1 parent 262592c commit 1c14172

9 files changed

Lines changed: 532 additions & 43 deletions

File tree

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
5. **URL rewrite**`rewriteUrl()` adds `?beta=true` to `/v1/messages` and overrides base URL when `ANTHROPIC_BASE_URL` is set — `packages/opencode/src/transform.ts`
5151
6. **Request body rewrite**`rewriteRequestBody()` strips trailing assistant messages, normalizes Fable/Mythos thinking and Sonnet 5 adaptive thinking, injects billing header, sanitizes system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, prefixes tool names with `mcp_`, creates `cch` over serialized body — `packages/opencode/src/transform.ts`
5252
7. **Routing**`shouldFallbackStatus()` checks if response should trigger fallback; `FallbackAccountManager` iterates accounts in ordered modes, while `StickySessionRouter` assigns cold sessions by reset-normalized spendable OAuth quota and weighted initial-prompt deficit, then persists hashed session affinity across processes/restarts. Sticky routes retain transient failures, hold confirmed 5h exhaustion when reset is within 15 minutes, and migrate for longer confirmed exhaustion/permanent account failure. All modes respect model-scoped quotas and killswitch thresholds (including per-model scoped thresholds). If all accounts fail the killswitch policy, a 429 block response is returned immediately; this block is classified as scoped-driven (matching a specific model's weekly limit) or account-level (5h/7d limits) with a model-specific or generic retry hint — `packages/core/src/routing.ts`, `packages/core/src/accounts.ts`, `packages/opencode/src/index.ts`
53-
8. **Relay**`sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back `packages/core/src/relay.ts`
53+
8. **Relay**`sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back. HTTP relay responses and WebSocket `response_start` control messages deliver genuine upstream headers to the OpenCode account-bound quota harvester; synthetic optimistic response headers are never harvest input, and relay-to-direct fallback stays owned by the direct path `packages/core/src/relay.ts`, `packages/opencode/src/index.ts`
5454
9. **SSE stream and Fable recovery** — Response body is wrapped in `createStrippedStream()`, which reverses the tool name prefix and detects Anthropic `refusal` finishes. For selected Fable sessions, a refusal activates a session-local 10-response Opus 4.8 downgrade; every successful downgraded response triggers a zero-output Fable prewarm through `CacheKeepManager` using the OAuth account that served the filtered Fable request. The last successful Opus tail anchor is retained per session and OAuth account; if Fable later refuses after that anchor has moved outside Anthropic's 20-block lookback, the retry spends the system cache slot on an explicit old-Opus-to-current-tail bridge rather than rewriting the intervening Opus cache. Recovery transitions are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives immediate ignored/no-reply `promptAsync` notices for the switch to Opus and return to Fable. Each notice is assigned a message ID immediately before the active assistant message: Desktop displays it immediately by creation time, while OpenCode's run loop sees it as older than the active assistant and cannot mistake it for pending user work or create an extra provider response — `packages/opencode/src/transform.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts`
5555
10. **Sidebar update**`writeSidebarState()` writes quota/routing/cache state plus bounded per-session Fable recovery status to a JSON file read by the TUI sidebar widget (separate process via RPC). Routing-authoritative writes (e.g. active routing decisions) are distinguished from display-only/metadata writes (e.g. quota refreshes or command paths). Display-only writes re-read the file and merge state to preserve any live routing session's `activeId` and route. Cross-process writes are synchronized using an atomic `mkdir` directory lock with jittered retries, rename-claim eviction, and lock-budget exhaustion skips. The write is fenced: ownership is verified before and after the rename, triggering one bounded locked repair of routing-authoritative fields on post-rename loss. — `packages/opencode/src/sidebar-state.ts`, `packages/opencode/src/index.ts`
5656

docs/parity-backlog.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -337,23 +337,20 @@ production-only regression (env-override bypass) — first-hand DIFF review caug
337337
338338
---
339339
340-
## 8. Relay response eligibility for quota harvest — FOLLOW-UP
340+
## 8. Relay response eligibility for quota harvest — DONE
341341
342-
The corrected Miniflare gate confirms both relay transports preserve
343-
`anthropic-ratelimit-unified-*`: the HTTP Worker copies `upstream.headers` into its response and
344-
WebSocket sends them in `response_start`. The earlier HTTP-negative test omitted
345-
`x-session-affinity`, so `sendViaRelay` returned its direct fallback without reaching the Worker.
346-
Quota harvest remains direct-only at the client `usedRelay` guard because WebSocket response
347-
headers are transport-reconstructed and are not yet canonical harvest evidence. Resolve that
348-
eligibility/synthetic-header question at the client guard before enabling relay harvest. Do not add
349-
a quota side channel.
342+
OpenCode harvests genuine upstream headers from both relay transports without a quota side channel.
343+
HTTP delivery is gated on `usedRelay`; WebSocket delivery comes from `response_start` before the
344+
optimistic-response early return. Relay-to-direct fallback invokes only the direct-path harvester,
345+
and synthetic optimistic headers remain ineligible.
350346
351347
## 9. Pi quota-header harvest parity — FOLLOW-UP
352348
353349
Pi uses the distinct `packages/pi/src/stream.ts` response path. Header harvest, served-account
354350
attribution, sidecar persistence, and quota display parity remain out of scope for v1. Port the
355351
OpenCode direct-path behavior without sharing request-path state implicitly, then gate Pi's own
356352
streaming response headers and malformed-header handling.
353+
Relay-header callback wiring is also OpenCode-only; Pi parity remains outstanding.
357354
358355
## Implementation phase (operator directive)
359356

docs/quota-surfaces.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Three independent surfaces expose Claude plan quota and account identity to OAut
77
| Usage API (`GET /api/oauth/usage`) | active poll, per token | on demand | **yes** (`limits[]`) | **yes** — pollable without traffic |
88
| `anthropic-ratelimit-unified-*` headers | passive, on every `/v1/messages` response | every request | no | no — only accounts you send through |
99

10-
The plugin combines surface 1 background polling (`fetchOAuthQuotaSnapshot` + `QuotaManager`) with passive direct-path harvest from surface 2. Relay responses remain gated from harvest.
10+
The plugin combines surface 1 background polling (`fetchOAuthQuotaSnapshot` + `QuotaManager`) with passive response-header harvest from surface 2 across direct and relay transports.
1111

1212
---
1313

@@ -143,7 +143,7 @@ Conditional headers (absent on main, present on work-alt): `fallback` appears on
143143
| Scoped per-model windows | `limits[]` `weekly_scoped` | n/a |
144144
| Representative/binding marker | `representative-claim` header + `is_active` (inferred) | `x-codex-…-over-…` style flags |
145145

146-
openai-auth is push-based by necessity (QuotaManager fed via `setMain`/`setFallback`); anthropic-auth now combines the usage poll with passive header pushes on direct requests.
146+
openai-auth is push-based by necessity (QuotaManager fed via `setMain`/`setFallback`); anthropic-auth combines the usage poll with passive header pushes on direct and relayed requests.
147147

148148
---
149149

@@ -171,17 +171,16 @@ Also returned: account/org uuids, email, subscription status/created, `enabled_p
171171

172172
## Implemented behavior
173173

174-
- Direct `/v1/messages` responses harvest the unified 5h and 7d windows. Utilization fractions are multiplied by 100, then rounded; reset values are epoch seconds converted to ISO timestamps.
174+
- Direct and relayed `/v1/messages` responses harvest the unified 5h and 7d windows. Utilization fractions are multiplied by 100, then rounded; reset values are epoch seconds converted to ISO timestamps.
175175
- Header pushes merge into the last poll snapshot. They preserve poll-owned `scoped`, including meaningful empty `[]`, and `extraUsage` credit data.
176176
- Poll `limits[].is_active` owns `bindingWindow` when present. The header `representative-claim` fills the marker only when the poll did not supply one.
177177
- Money stays in integer minor units with an explicit currency exponent. Formatting happens at the display boundary.
178178
- `fallback: available` becomes `fallbackAdvised`; it appears only in expanded quota views and does not change routing.
179179
- Profile metadata is sidecar-persisted, uses a seven-day TTL, and is absent from the request path.
180-
- Relay transport is direct-only for harvest in v1. Both the HTTP Worker (`upstream.headers` copied into its response) and WebSocket `response_start` preserve unified headers, but relay responses remain gated because transport-reconstructed WebSocket headers are not yet treated as canonical harvest evidence. See the relay parity item in `docs/parity-backlog.md`.
180+
- Relay HTTP harvest reads the Worker's genuine upstream response headers only when the relay handled the request. WebSocket harvest reads the genuine `response_start` headers through a callback even when the caller already received an optimistic response with synthetic headers. Relay-to-direct fallback remains owned by the direct-path harvest, so one upstream response produces one push.
181181

182182
## Gaps / opportunities
183183

184-
- Relay-side harvest requires a client eligibility decision and synthetic-header safety gate at the `usedRelay` guard, not an HTTP Worker passthrough fix.
185184
- Pi has a separate streaming response path and does not harvest quota headers in v1.
186185

187186
## Probe recipes

packages/core/src/relay.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@ type PendingWebSocketRequest = {
497497
streamByteCount: number
498498
retryAttempts: number
499499
retryingBeforeResponse: boolean
500+
onResponseHeaders?: (headers: Headers) => void
501+
stagedResponseHeaders?: Headers
502+
responseHeadersDelivered: boolean
500503
}
501504

502505
class PersistentRelaySession {
@@ -535,13 +538,20 @@ class PersistentRelaySession {
535538
payload: RelayPayload,
536539
bodyText: string,
537540
optimisticResponse = false,
541+
onResponseHeaders?: (headers: Headers) => void,
538542
): Promise<RelaySendResult> {
539543
this.touch()
540544
const enqueuedAt = perfNowMs()
541545
const start = this.queue
542546
.catch(() => {})
543547
.then(() =>
544-
this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse),
548+
this.startQueued(
549+
payload,
550+
bodyText,
551+
enqueuedAt,
552+
optimisticResponse,
553+
onResponseHeaders,
554+
),
545555
)
546556
const result = start.then(async ({ response, getPayload }) => ({
547557
response: await response,
@@ -562,6 +572,7 @@ class PersistentRelaySession {
562572
bodyText: string,
563573
enqueuedAt: number,
564574
optimisticResponse: boolean,
575+
onResponseHeaders?: (headers: Headers) => void,
565576
) {
566577
const connectStart = perfNowMs()
567578
await this.ensureConnected()
@@ -593,7 +604,12 @@ class PersistentRelaySession {
593604
)
594605

595606
let activePayload = requestPayload
596-
const first = this.sendPayload(requestPayload, bodyText, optimisticResponse)
607+
const first = this.sendPayload(
608+
requestPayload,
609+
bodyText,
610+
optimisticResponse,
611+
onResponseHeaders,
612+
)
597613
void first.done.catch(() => {})
598614
let activeDone = first.done
599615
const response = first.response.catch((error) => {
@@ -608,7 +624,12 @@ class PersistentRelaySession {
608624
fullSync.id = createRequestId()
609625
fullSync.revision = (this.serverState?.revision ?? 0) + 1
610626
activePayload = fullSync
611-
const retry = this.sendPayload(fullSync, bodyText, optimisticResponse)
627+
const retry = this.sendPayload(
628+
fullSync,
629+
bodyText,
630+
optimisticResponse,
631+
onResponseHeaders,
632+
)
612633
void retry.done.catch(() => {})
613634
activeDone = retry.done
614635
return retry.response
@@ -711,6 +732,7 @@ class PersistentRelaySession {
711732
payload: RelayPayload,
712733
bodyText: string,
713734
optimisticResponse: boolean,
735+
onResponseHeaders?: (headers: Headers) => void,
714736
) {
715737
const socket = this.socket
716738
if (!socket || socket.readyState !== WebSocket.OPEN) {
@@ -741,6 +763,9 @@ class PersistentRelaySession {
741763
streamByteCount: 0,
742764
retryAttempts: 0,
743765
retryingBeforeResponse: false,
766+
onResponseHeaders,
767+
stagedResponseHeaders: undefined,
768+
responseHeadersDelivered: false,
744769
}
745770
this.pending = pending
746771
this.resetPendingTimeout(pending)
@@ -805,6 +830,8 @@ class PersistentRelaySession {
805830
pending.accepted = false
806831
pending.acceptedAt = undefined
807832
pending.responseStartedAt = undefined
833+
pending.stagedResponseHeaders = undefined
834+
pending.responseHeadersDelivered = false
808835
pending.sentAt = perfNowMs()
809836
pending.retryingBeforeResponse = false
810837

@@ -830,11 +857,24 @@ class PersistentRelaySession {
830857
pending: PendingWebSocketRequest,
831858
chunk: Uint8Array,
832859
) {
860+
this.deliverResponseHeaders(pending)
833861
pending.streamChunkCount += 1
834862
pending.streamByteCount += chunk.byteLength
835863
pending.streamController?.enqueue(chunk)
836864
}
837865

866+
private deliverResponseHeaders(pending: PendingWebSocketRequest) {
867+
if (
868+
pending.responseHeadersDelivered ||
869+
pending.stagedResponseHeaders == null
870+
)
871+
return
872+
pending.responseHeadersDelivered = true
873+
try {
874+
pending.onResponseHeaders?.(pending.stagedResponseHeaders)
875+
} catch {}
876+
}
877+
838878
private handleBinaryChunk(data: unknown) {
839879
const pending = this.pending
840880
if (!pending?.responseStarted) return
@@ -894,19 +934,23 @@ class PersistentRelaySession {
894934
return
895935
}
896936
if (message.type === 'response_start') {
937+
if (pending.responseStartedAt != null) return
897938
const responseStartedAt = perfNowMs()
898939
pending.responseStartedAt = responseStartedAt
899940
relayLog(
900941
`perf websocket response_start session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(responseStartedAt - pending.sentAt)} upstreamMs=${pending.acceptedAt == null ? 'unknown' : formatMs(responseStartedAt - pending.acceptedAt)} status=${message.status}`,
901942
)
902943
clearTimeout(pending.timeout)
944+
if (message.headers)
945+
pending.stagedResponseHeaders = new Headers(message.headers)
903946
this.resolvePendingResponse(
904947
pending,
905948
message.status,
906949
message.statusText,
907950
message.headers,
908951
)
909952
if (pending.optimisticResponse && message.status >= 400) {
953+
this.deliverResponseHeaders(pending)
910954
pending.streamController?.enqueue(
911955
new TextEncoder().encode(
912956
`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_upstream_error', message: `Relay upstream returned HTTP ${message.status}` } })}\n\n`,
@@ -933,6 +977,7 @@ class PersistentRelaySession {
933977
relayLog(
934978
`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`,
935979
)
980+
this.deliverResponseHeaders(pending)
936981
pending.streamController?.enqueue(
937982
new TextEncoder().encode(
938983
`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`,
@@ -972,6 +1017,7 @@ class PersistentRelaySession {
9721017
)
9731018
clearTimeout(pending.timeout)
9741019
if (!pending.streamDone) {
1020+
this.deliverResponseHeaders(pending)
9751021
pending.streamDone = true
9761022
pending.streamController?.close()
9771023
}
@@ -1046,6 +1092,13 @@ export async function sendViaRelay(options: {
10461092
fallback: () => Promise<Response>
10471093
affinity?: string | null
10481094
optimisticResponse?: boolean
1095+
/**
1096+
* Observes genuine upstream headers once per delivered response. WebSocket
1097+
* retries before downstream bytes replace staged headers, so the callback
1098+
* receives only the final accepted response; duplicate response_start frames
1099+
* within an attempt are ignored.
1100+
*/
1101+
onResponseHeaders?: (headers: Headers) => void
10491102
}): Promise<Response> {
10501103
const {
10511104
config,
@@ -1056,6 +1109,7 @@ export async function sendViaRelay(options: {
10561109
fallback,
10571110
affinity: explicitAffinity,
10581111
optimisticResponse,
1112+
onResponseHeaders,
10591113
} = options
10601114
if (!config || !isRelayableAnthropicRequest(input, body)) return fallback()
10611115

@@ -1104,6 +1158,7 @@ export async function sendViaRelay(options: {
11041158
payload,
11051159
bodyText,
11061160
optimisticResponse === true,
1161+
onResponseHeaders,
11071162
)
11081163
} catch (error) {
11091164
relayLog(
@@ -1139,6 +1194,12 @@ export async function sendViaRelay(options: {
11391194

11401195
if (!result.usedRelay) return result.response
11411196

1197+
if (result.transport === 'http') {
1198+
try {
1199+
onResponseHeaders?.(result.response.headers)
1200+
} catch {}
1201+
}
1202+
11421203
if (result.transport === 'http') {
11431204
updateLocalRelayState(
11441205
affinity,

0 commit comments

Comments
 (0)