@gotcos/glasses-server 6.79.1 → 6.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +98 -0
  3. package/README.md +20 -1
  4. package/managed-runtime-contract.json +6 -1
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/dictation-clean.ts +13 -6
  23. package/server/lib/dictation-fillers.ts +17 -10
  24. package/server/lib/dictation-polish-need.ts +48 -0
  25. package/server/lib/dictation-prewarm.ts +209 -0
  26. package/server/lib/direct-bridge.ts +853 -0
  27. package/server/lib/direct-sse.ts +262 -0
  28. package/server/lib/fork-thread.ts +11 -0
  29. package/server/lib/key-answer-fields.ts +49 -0
  30. package/server/lib/key-catalog.ts +301 -0
  31. package/server/lib/key-lanes-pricing.ts +12 -0
  32. package/server/lib/key-lanes-wiring.ts +346 -0
  33. package/server/lib/key-lanes.ts +145 -0
  34. package/server/lib/key-ledger.ts +123 -0
  35. package/server/lib/key-notices.ts +486 -0
  36. package/server/lib/key-profile-rejects.ts +25 -0
  37. package/server/lib/key-profile-routing.ts +173 -0
  38. package/server/lib/key-profiles.ts +182 -0
  39. package/server/lib/key-relay-contract.ts +262 -0
  40. package/server/lib/key-relay-errors.ts +211 -0
  41. package/server/lib/key-relay-meter.ts +303 -0
  42. package/server/lib/key-relay.ts +1018 -0
  43. package/server/lib/key-secrets-guard.ts +52 -0
  44. package/server/lib/key-store.ts +228 -0
  45. package/server/lib/keyed-sessions.ts +154 -0
  46. package/server/lib/lane-b-env.ts +169 -0
  47. package/server/lib/live-cues-v2-settings.ts +51 -13
  48. package/server/lib/live-cues-v2.ts +147 -19
  49. package/server/lib/model-router.ts +96 -7
  50. package/server/lib/morning-brief-config.ts +3 -0
  51. package/server/lib/native-head.ts +13 -1
  52. package/server/lib/occupancy-probes.ts +4 -0
  53. package/server/lib/ollama-run-ledger.ts +96 -13
  54. package/server/lib/prompt-edit.ts +2 -0
  55. package/server/lib/query-job-coordinator.ts +2 -1
  56. package/server/lib/query-job-runtime.ts +67 -15
  57. package/server/lib/query-job-store.ts +7 -1
  58. package/server/lib/query-job-types.ts +15 -1
  59. package/server/lib/session-hook-events.ts +28 -0
  60. package/server/lib/session-hook-ledger.ts +10 -0
  61. package/server/lib/thread-occupancy.ts +22 -1
  62. package/server/lib/token-audit.ts +18 -4
  63. package/server/lib/work-handoff-requests.ts +3 -0
  64. package/server/routes/agent-session-bindings.ts +15 -0
  65. package/server/routes/agent-sessions.ts +17 -5
  66. package/server/routes/handoffs.ts +22 -1
  67. package/server/routes/health.ts +5 -0
  68. package/server/routes/key-lanes.ts +442 -0
  69. package/server/routes/live-cues.ts +39 -3
  70. package/server/routes/openai-compat.ts +28 -3
  71. package/server/routes/prompt-drafts.ts +75 -8
  72. package/server/routes/query.ts +20 -6
  73. package/shared/key-preference.ts +57 -0
  74. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,211 @@
1
+ // Key lanes K1 P2 (plan v4 4.6): how the loopback relay maps upstream and relay events to what the
2
+ // child sees, which failures count toward the breaker, and the breaker itself.
3
+ //
4
+ // Pure functions and one small class. Nothing here reads a file, opens a socket or starts a timer,
5
+ // so importing it changes nothing for a server with key lanes off.
6
+ //
7
+ // The relay never retries itself (no double billing). Children retry 429, 5xx and network errors
8
+ // on their own budget (CLAUDE_CODE_MAX_RETRIES=2, Codex request_max_retries=2). Only a rejected
9
+ // key, an empty credit balance or a model that is gone counts toward the breaker, because those
10
+ // are the failures a retry cannot fix.
11
+ import type {
12
+ BreakerCountedFailure,
13
+ BreakerScope,
14
+ BreakerState,
15
+ RelayEngine,
16
+ RelayFailureKind,
17
+ } from './key-relay-contract.js'
18
+ import { BREAKER_LIMITS } from './key-relay-contract.js'
19
+
20
+ // ── Classification (plan 4.6 table) ──────────────────────────────────────────
21
+
22
+ const MODEL_GONE_RE = /model[^\n]{0,80}(not found|does not exist|not available|no longer|is not a valid|invalid model)|no endpoints found|unknown model|not a valid model/i
23
+
24
+ /**
25
+ * The 4.6 row an upstream status lands in, or null when it is passed through with no special
26
+ * action (a 2xx, or a 4xx such as 403 moderation that is neither the key nor the model).
27
+ */
28
+ export function classifyUpstreamStatus(status: number, bodyText = ''): RelayFailureKind | null {
29
+ if (status >= 200 && status < 300) return null
30
+ if (status === 401) return 'key_rejected'
31
+ if (status === 402) return 'out_of_credit'
32
+ if (status === 404) return 'model_gone'
33
+ if (status === 400 && MODEL_GONE_RE.test(bodyText)) return 'model_gone'
34
+ if (status === 429) return 'rate_limited'
35
+ if (status >= 500) return 'upstream_error'
36
+ return null
37
+ }
38
+
39
+ /** Plan 4.6: only 401, 402 and model-gone count. 429, 5xx, network and cap refusals never do. */
40
+ export function countsTowardBreaker(kind: RelayFailureKind | null): kind is BreakerCountedFailure {
41
+ return kind === 'key_rejected' || kind === 'out_of_credit' || kind === 'model_gone'
42
+ }
43
+
44
+ /** A rejected key or empty credit opens the KEY breaker; a gone model opens the PROFILE breaker. */
45
+ export function breakerScopeFor(kind: BreakerCountedFailure): BreakerScope {
46
+ return kind === 'model_gone' ? 'profile' : 'key'
47
+ }
48
+
49
+ /** The 4.6 rows whose server action is "ends the turn". */
50
+ export function endsTurn(kind: RelayFailureKind): boolean {
51
+ return kind === 'cap_refused' || kind === 'key_rejected' || kind === 'out_of_credit' || kind === 'model_gone'
52
+ }
53
+
54
+ // ── Error bodies in the caller's own shape ───────────────────────────────────
55
+
56
+ /** Relay-made failures (the ones not passed through from upstream). */
57
+ export type RelayLocalError =
58
+ | 'nonce_invalid'
59
+ | 'cap_refused'
60
+ | 'model_not_allowed'
61
+ | 'network_error'
62
+ | 'not_found'
63
+ | 'bad_request'
64
+ | 'too_large'
65
+ | 'too_many_in_flight'
66
+ | 'browser_refused'
67
+
68
+ interface LocalErrorSpec {
69
+ status: number
70
+ anthropicType: string
71
+ openaiType: string
72
+ message: string
73
+ }
74
+
75
+ /** User-facing copy. Plain sentences, no em dashes, no arrows, never a key or a nonce. */
76
+ export const RELAY_ERROR_SPECS: Record<RelayLocalError, LocalErrorSpec> = {
77
+ nonce_invalid: { status: 401, anthropicType: 'authentication_error', openaiType: 'invalid_api_key', message: 'This relay token is unknown or has expired.' },
78
+ cap_refused: { status: 402, anthropicType: 'billing_error', openaiType: 'insufficient_quota', message: 'Daily cap reached for this key.' },
79
+ model_not_allowed: { status: 400, anthropicType: 'invalid_request_error', openaiType: 'invalid_request_error', message: 'Model not allowed on this key profile' },
80
+ network_error: { status: 502, anthropicType: 'overloaded_error', openaiType: 'server_error', message: 'The provider could not be reached. Try again.' },
81
+ not_found: { status: 404, anthropicType: 'not_found_error', openaiType: 'invalid_request_error', message: 'Not found.' },
82
+ bad_request: { status: 400, anthropicType: 'invalid_request_error', openaiType: 'invalid_request_error', message: 'The request body is not valid JSON.' },
83
+ too_large: { status: 413, anthropicType: 'request_too_large', openaiType: 'invalid_request_error', message: 'The request is too large.' },
84
+ too_many_in_flight: { status: 429, anthropicType: 'rate_limit_error', openaiType: 'rate_limit_exceeded', message: 'Too many requests at once on this turn.' },
85
+ browser_refused: { status: 403, anthropicType: 'permission_error', openaiType: 'invalid_request_error', message: 'The relay does not answer browsers.' },
86
+ }
87
+
88
+ /** The JSON body for a relay-made failure: Anthropic shape for Claude, OpenAI shape for Codex. */
89
+ export function relayErrorBody(engine: RelayEngine | 'unknown', error: RelayLocalError): { status: number; body: string } {
90
+ const spec = RELAY_ERROR_SPECS[error]
91
+ const body = engine === 'codex'
92
+ ? { error: { message: spec.message, type: spec.openaiType, code: error } }
93
+ : { type: 'error', error: { type: spec.anthropicType, message: spec.message } }
94
+ return { status: spec.status, body: JSON.stringify(body) }
95
+ }
96
+
97
+ // ── Redaction ────────────────────────────────────────────────────────────────
98
+
99
+ export const REDACTED = '[redacted]'
100
+
101
+ /**
102
+ * Remove every occurrence of each secret from text bound for a child, a log or a ledger. Upstream
103
+ * error bodies are passed through, and a provider or proxy that echoes the Authorization header
104
+ * would otherwise hand the real key to the child.
105
+ */
106
+ export function redactSecrets(text: string, secrets: ReadonlyArray<string | null | undefined>): string {
107
+ let out = text
108
+ for (const s of secrets) {
109
+ if (!s || s.length < 8) continue
110
+ // QA N3: the plain form, the JSON-escaped form (a body quoting it), and the URL-encoded form.
111
+ const forms = new Set([s, JSON.stringify(s).slice(1, -1), encodeURIComponent(s)])
112
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) out = out.split(f).join(REDACTED)
113
+ }
114
+ return out
115
+ }
116
+
117
+ // ── Retry-After ──────────────────────────────────────────────────────────────
118
+
119
+ /** Seconds from a Retry-After header (delta seconds or an HTTP date), or null. Passed to the child as is. */
120
+ export function parseRetryAfterSeconds(value: string | null | undefined, now: () => number = Date.now): number | null {
121
+ if (!value) return null
122
+ const trimmed = value.trim()
123
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
124
+ const at = Date.parse(trimmed)
125
+ if (!Number.isFinite(at)) return null
126
+ return Math.max(0, Math.ceil((at - now()) / 1000))
127
+ }
128
+
129
+ // ── Breaker (plan 4.6) ───────────────────────────────────────────────────────
130
+
131
+ interface BreakerEntry {
132
+ failures: number
133
+ openedAtMs?: number
134
+ }
135
+
136
+ export type BreakerOpenListener = (scope: BreakerScope, id: string) => void
137
+
138
+ /**
139
+ * 3 consecutive counted failures open a breaker for 1 h. A success resets the count. A failure
140
+ * that does not count (429, 5xx, network, cap) neither counts nor resets. Reset on key change,
141
+ * successful validation or the Control "Reset" button (the relay calls reset()).
142
+ */
143
+ export class RelayBreakers {
144
+ private readonly entries = new Map<string, BreakerEntry>()
145
+
146
+ constructor(
147
+ private readonly now: () => number = Date.now,
148
+ private readonly onOpen: BreakerOpenListener = () => {},
149
+ ) {}
150
+
151
+ private key(scope: BreakerScope, id: string): string {
152
+ return `${scope}:${id}`
153
+ }
154
+
155
+ private live(scope: BreakerScope, id: string): BreakerEntry | undefined {
156
+ const k = this.key(scope, id)
157
+ const e = this.entries.get(k)
158
+ if (e?.openedAtMs !== undefined && this.now() - e.openedAtMs >= BREAKER_LIMITS.openMs) {
159
+ // Closed again after an hour, with a clean count.
160
+ this.entries.delete(k)
161
+ return undefined
162
+ }
163
+ return e
164
+ }
165
+
166
+ /** Record one upstream outcome. Returns true when this call opened the breaker. */
167
+ record(scope: BreakerScope, id: string, kind: RelayFailureKind | null, ok: boolean): boolean {
168
+ const k = this.key(scope, id)
169
+ if (ok) {
170
+ const e = this.live(scope, id)
171
+ if (e && e.openedAtMs === undefined) this.entries.delete(k)
172
+ return false
173
+ }
174
+ if (!countsTowardBreaker(kind)) return false
175
+ const e = this.live(scope, id) ?? { failures: 0 }
176
+ if (e.openedAtMs !== undefined) return false
177
+ e.failures += 1
178
+ if (e.failures >= BREAKER_LIMITS.failures) {
179
+ e.openedAtMs = this.now()
180
+ this.entries.set(k, e)
181
+ this.onOpen(scope, id)
182
+ return true
183
+ }
184
+ this.entries.set(k, e)
185
+ return false
186
+ }
187
+
188
+ isOpen(scope: BreakerScope, id: string): boolean {
189
+ return this.live(scope, id)?.openedAtMs !== undefined
190
+ }
191
+
192
+ state(scope: BreakerScope, id: string): BreakerState {
193
+ const e = this.live(scope, id)
194
+ if (!e) return { open: false, failures: 0 }
195
+ if (e.openedAtMs === undefined) return { open: false, failures: e.failures }
196
+ return {
197
+ open: true,
198
+ failures: e.failures,
199
+ openedAt: new Date(e.openedAtMs).toISOString(),
200
+ reopensAt: new Date(e.openedAtMs + BREAKER_LIMITS.openMs).toISOString(),
201
+ }
202
+ }
203
+
204
+ reset(scope: BreakerScope, id: string): void {
205
+ this.entries.delete(this.key(scope, id))
206
+ }
207
+
208
+ clear(): void {
209
+ this.entries.clear()
210
+ }
211
+ }
@@ -0,0 +1,303 @@
1
+ // Key lanes K1 P2 (plan v4 4.5): the per-key spend counter and its reservations.
2
+ //
3
+ // ONE counter per key ref, shared by every profile on that key, every parallel turn, hooks, the
4
+ // in-process direct lane (P5) and K3. reserve() is synchronous, so two parallel requests can never
5
+ // both pass a check that only one of them fits under: JavaScript runs one reserve at a time.
6
+ //
7
+ // spent today = max(local settled, OpenRouter usage_daily) + open reservations
8
+ // reserve = body bytes / 4 at the input price + min(max_tokens, 4096) at the output price
9
+ // refuse when spent today + reserve > cap
10
+ // settle replaces the reservation with the measured cost (stream usage times price, or the
11
+ // provider's own `usage.cost` when it sends one)
12
+ //
13
+ // The day is the UTC day, matching OpenRouter usage_daily. Overshoot is bounded by responses
14
+ // already streaming when the cap is reached; the OpenRouter key's own credit limit is the hard
15
+ // backstop. Nothing here starts a timer or opens a socket, and nothing is written until the first
16
+ // reservation.
17
+ import { randomBytes } from 'node:crypto'
18
+ import { getPublicKey } from './key-store.js'
19
+ import type { KeyLane, KeyMeter, KeyRef } from './key-relay-contract.js'
20
+ import { appendKeyLedgerRow, ledgerSpentUsd, utcDay } from './key-ledger.js'
21
+ import { KEY_CATALOG_CURATED } from './key-catalog.js'
22
+
23
+ export interface ModelPrice {
24
+ inUsdPerM: number
25
+ outUsdPerM: number
26
+ }
27
+
28
+ /**
29
+ * A model COS has no price for is metered as if it were the most expensive Claude tier, so an
30
+ * unknown price can only make the cap stricter, never looser.
31
+ */
32
+ export const FALLBACK_PRICE: ModelPrice = { inUsdPerM: 15, outUsdPerM: 75 }
33
+ export const RESERVE_MAX_OUTPUT_TOKENS = 4096
34
+ export const BYTES_PER_TOKEN_ESTIMATE = 4
35
+ /** Turns whose settled spend the meter remembers (oldest dropped first). */
36
+ export const TURN_SPEND_MAX = 512
37
+
38
+ /** Curated prices (plan D10), then the fallback. P5's live catalog can be passed in instead. */
39
+ export function defaultPriceFor(model: string): ModelPrice {
40
+ const curated = KEY_CATALOG_CURATED.find(m => m.id === model)
41
+ return curated ? { inUsdPerM: curated.inUsdPerM, outUsdPerM: curated.outUsdPerM } : FALLBACK_PRICE
42
+ }
43
+
44
+ export function tokensToUsd(tokens: { input: number; output: number }, price: ModelPrice): number {
45
+ return (Math.max(0, tokens.input) * price.inUsdPerM + Math.max(0, tokens.output) * price.outUsdPerM) / 1e6
46
+ }
47
+
48
+ /** Plan 4.5 reserve: input estimate plus min(max_tokens, 4096) output. A missing max_tokens reserves 4096. */
49
+ export function estimateReserveUsd(input: { bodyBytes: number; maxTokens?: number | null; price: ModelPrice }): number {
50
+ const inTokens = Math.ceil(Math.max(0, input.bodyBytes) / BYTES_PER_TOKEN_ESTIMATE)
51
+ const mt = typeof input.maxTokens === 'number' && Number.isFinite(input.maxTokens) && input.maxTokens > 0 ? input.maxTokens : RESERVE_MAX_OUTPUT_TOKENS
52
+ const outTokens = Math.min(mt, RESERVE_MAX_OUTPUT_TOKENS)
53
+ return tokensToUsd({ input: inTokens, output: outTokens }, input.price)
54
+ }
55
+
56
+ /** QA W2: the smallest reply worth starting; less headroom than this refuses the request. */
57
+ export const MIN_RESERVE_OUTPUT_TOKENS = 256
58
+
59
+ /**
60
+ * QA W2: reserve the request's own max_tokens (no 4096 clamp), but never more than the key has
61
+ * left today. Null when even the input plus a short reply does not fit: refuse before sending.
62
+ * A reply longer than what was reserved is cut by the relay when the spend reaches the cap.
63
+ */
64
+ export function reserveWithinHeadroomUsd(input: { bodyBytes: number; maxTokens?: number | null; price: ModelPrice; headroomUsd: number }): number | null {
65
+ const inTokens = Math.ceil(Math.max(0, input.bodyBytes) / BYTES_PER_TOKEN_ESTIMATE)
66
+ const mt = typeof input.maxTokens === 'number' && Number.isFinite(input.maxTokens) && input.maxTokens > 0 ? input.maxTokens : RESERVE_MAX_OUTPUT_TOKENS
67
+ const inputUsd = tokensToUsd({ input: inTokens, output: 0 }, input.price)
68
+ const fullUsd = inputUsd + tokensToUsd({ input: 0, output: mt }, input.price)
69
+ const floorUsd = inputUsd + tokensToUsd({ input: 0, output: Math.min(mt, MIN_RESERVE_OUTPUT_TOKENS) }, input.price)
70
+ const headroom = Number.isFinite(input.headroomUsd) ? input.headroomUsd : 0
71
+ if (headroom < floorUsd) return null
72
+ return Math.min(fullUsd, headroom)
73
+ }
74
+
75
+ interface Reservation {
76
+ keyRef: KeyRef
77
+ turnId: string
78
+ profile: string
79
+ lane: KeyLane
80
+ usd: number
81
+ }
82
+
83
+ interface DaySpend {
84
+ day: string
85
+ settledUsd: number
86
+ usageDailyUsd?: number
87
+ }
88
+
89
+ export interface ReserveInput {
90
+ keyRef: KeyRef
91
+ turnId: string
92
+ estimateUsd: number
93
+ /** Ledger detail; the contract's KeyMeter.reserve leaves them out. */
94
+ profile?: string
95
+ lane?: KeyLane
96
+ tokens?: { input: number; output: number }
97
+ }
98
+
99
+ export type ReserveResult = { ok: true; reservationId: string } | { ok: false; spentTodayUsd: number; capUsd: number }
100
+
101
+ export interface KeyRelayMeterOptions {
102
+ now?: () => number
103
+ /** The key's daily cap, or null when the key is gone (refuse). Default: the key store. */
104
+ capUsd?: (keyRef: KeyRef) => number | null
105
+ /** Ledger writer. Default: data/key-ledger.jsonl. */
106
+ appendRow?: typeof appendKeyLedgerRow
107
+ /** Settled spend already on disk for a key and UTC day. Default: the ledger file. */
108
+ loadSettled?: (keyRef: KeyRef, day: string) => number
109
+ log?: (line: string) => void
110
+ }
111
+
112
+ export class KeyRelayMeter implements KeyMeter {
113
+ private readonly now: () => number
114
+ private readonly capOf: (keyRef: KeyRef) => number | null
115
+ private readonly append: typeof appendKeyLedgerRow
116
+ private readonly loadSettled: (keyRef: KeyRef, day: string) => number
117
+ private readonly log: (line: string) => void
118
+ private readonly days = new Map<KeyRef, DaySpend>()
119
+ private readonly reservations = new Map<string, Reservation>()
120
+ /** P11: settled spend per turn id (newest TURN_SPEND_MAX), so a keyed answer can carry its cost. */
121
+ private readonly turnSpend = new Map<string, number>()
122
+ /** P11 (plan 4.5 "Cap value"): the cap Control last saved per key, this boot. */
123
+ private readonly savedCaps = new Map<KeyRef, number>()
124
+ private readonly capMismatchLogged = new Set<KeyRef>()
125
+
126
+ constructor(options: KeyRelayMeterOptions = {}) {
127
+ this.now = options.now ?? Date.now
128
+ this.capOf = options.capUsd ?? (ref => getPublicKey(ref)?.capUsd ?? null)
129
+ this.append = options.appendRow ?? appendKeyLedgerRow
130
+ this.loadSettled = options.loadSettled ?? ((ref, day) => ledgerSpentUsd(ref, day))
131
+ this.log = options.log ?? (line => console.warn(line))
132
+ }
133
+
134
+ private today(keyRef: KeyRef): DaySpend {
135
+ const day = utcDay(this.now())
136
+ let d = this.days.get(keyRef)
137
+ if (!d || d.day !== day) {
138
+ let settledUsd = 0
139
+ try { settledUsd = this.loadSettled(keyRef, day) } catch { settledUsd = 0 }
140
+ d = { day, settledUsd }
141
+ this.days.set(keyRef, d)
142
+ }
143
+ return d
144
+ }
145
+
146
+ private write(row: Parameters<typeof appendKeyLedgerRow>[0]): void {
147
+ try { this.append(row) } catch (err) {
148
+ // The in-memory counter still holds the spend; only the audit row is lost.
149
+ this.log(`[key-relay] ledger write skipped: ${(err as Error)?.name ?? 'error'}`)
150
+ }
151
+ }
152
+
153
+ capUsd(keyRef: KeyRef): number | null {
154
+ const raw = this.capOf(keyRef)
155
+ const cap = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
156
+ // A key that is gone always refuses, whatever was saved before.
157
+ if (cap === null) return null
158
+ // Plan 4.5: the server keeps Control's last saved value. A cap that changed in the file by any
159
+ // other path (a Lane A turn editing provider-keys.json) is ignored and logged once. The first
160
+ // value seen this boot is the baseline until Control saves one.
161
+ const saved = this.savedCaps.get(keyRef)
162
+ if (saved === undefined) {
163
+ this.savedCaps.set(keyRef, cap)
164
+ return cap
165
+ }
166
+ if (saved !== cap && !this.capMismatchLogged.has(keyRef)) {
167
+ this.capMismatchLogged.add(keyRef)
168
+ this.log(`[key-relay] cap for ${keyRef} changed outside COS Control; keeping the saved cap`)
169
+ }
170
+ return saved
171
+ }
172
+
173
+ /** P11: the key-lanes routes call this when COS Control saves a key or changes its cap. */
174
+ noteSavedCap(keyRef: KeyRef, capUsd: number): void {
175
+ if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) return
176
+ this.savedCaps.set(keyRef, capUsd)
177
+ this.capMismatchLogged.delete(keyRef)
178
+ }
179
+
180
+ /** P11: what this turn has settled so far on any key (0 when nothing settled). */
181
+ turnSettledUsd(turnId: string): number {
182
+ return this.turnSpend.get(turnId) ?? 0
183
+ }
184
+
185
+ private addTurnSpend(turnId: string, usd: number): void {
186
+ if (!turnId) return
187
+ const prior = this.turnSpend.get(turnId) ?? 0
188
+ this.turnSpend.delete(turnId)
189
+ this.turnSpend.set(turnId, prior + usd)
190
+ while (this.turnSpend.size > TURN_SPEND_MAX) {
191
+ const oldest = this.turnSpend.keys().next().value
192
+ if (oldest === undefined) break
193
+ this.turnSpend.delete(oldest)
194
+ }
195
+ }
196
+
197
+ openReservationsUsd(keyRef: KeyRef): number {
198
+ let sum = 0
199
+ for (const r of this.reservations.values()) if (r.keyRef === keyRef) sum += r.usd
200
+ return sum
201
+ }
202
+
203
+ /** Plan 4.5: max(local ledger, usage_daily) plus open reservations. */
204
+ spentTodayUsd(keyRef: KeyRef): number {
205
+ const d = this.today(keyRef)
206
+ return Math.max(d.settledUsd, d.usageDailyUsd ?? 0) + this.openReservationsUsd(keyRef)
207
+ }
208
+
209
+ /** OpenRouter's usage_daily for this key (read at turn start and while a turn runs). */
210
+ observeUsageDaily(keyRef: KeyRef, usd: number): void {
211
+ if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return
212
+ const d = this.today(keyRef)
213
+ d.usageDailyUsd = Math.max(d.usageDailyUsd ?? 0, usd)
214
+ }
215
+
216
+ /** QA W2: what the key may still spend today (cap minus spent, open reservations included). */
217
+ headroomUsd(keyRef: KeyRef): number {
218
+ const cap = this.capUsd(keyRef)
219
+ return cap === null ? 0 : cap - this.spentTodayUsd(keyRef)
220
+ }
221
+
222
+ /** True when the key has nothing left under its cap today (refuse a turn before spawn). */
223
+ atCap(keyRef: KeyRef): boolean {
224
+ const cap = this.capUsd(keyRef)
225
+ return cap === null || this.spentTodayUsd(keyRef) >= cap
226
+ }
227
+
228
+ reserve(input: ReserveInput): ReserveResult {
229
+ const cap = this.capUsd(input.keyRef)
230
+ const spent = this.spentTodayUsd(input.keyRef)
231
+ const estimate = Number.isFinite(input.estimateUsd) && input.estimateUsd > 0 ? input.estimateUsd : 0
232
+ if (cap === null || spent + estimate > cap) {
233
+ return { ok: false, spentTodayUsd: spent, capUsd: cap ?? 0 }
234
+ }
235
+ const reservationId = `res_${randomBytes(9).toString('base64url')}`
236
+ const r: Reservation = { keyRef: input.keyRef, turnId: input.turnId, profile: input.profile ?? '', lane: input.lane ?? 'key', usd: estimate }
237
+ this.reservations.set(reservationId, r)
238
+ this.write({
239
+ ts: new Date(this.now()).toISOString(), keyRef: r.keyRef, profile: r.profile, lane: r.lane, turnId: r.turnId,
240
+ tokens: input.tokens ?? { input: 0, output: 0 }, usd: estimate, kind: 'reserve',
241
+ })
242
+ return { ok: true, reservationId }
243
+ }
244
+
245
+ settle(input: { reservationId: string; usd: number; tokens: { input: number; output: number }; generationId?: string }): void {
246
+ const r = this.reservations.get(input.reservationId)
247
+ if (!r) return
248
+ this.reservations.delete(input.reservationId)
249
+ const usd = Number.isFinite(input.usd) && input.usd > 0 ? input.usd : 0
250
+ this.today(r.keyRef).settledUsd += usd
251
+ this.addTurnSpend(r.turnId, usd)
252
+ this.write({
253
+ ts: new Date(this.now()).toISOString(), keyRef: r.keyRef, profile: r.profile, lane: r.lane, turnId: r.turnId,
254
+ ...(input.generationId ? { generationId: input.generationId } : {}),
255
+ tokens: input.tokens, usd, kind: 'settle',
256
+ })
257
+ }
258
+
259
+ /** Settle at the full reserved amount (no usage arrived). */
260
+ settleReserved(reservationId: string, tokens: { input: number; output: number } = { input: 0, output: 0 }, generationId?: string): void {
261
+ const r = this.reservations.get(reservationId)
262
+ if (!r) return
263
+ this.settle({ reservationId, usd: r.usd, tokens, ...(generationId ? { generationId } : {}) })
264
+ }
265
+
266
+ /** The reserved amount of an open reservation, or null. */
267
+ reservedUsd(reservationId: string): number | null {
268
+ return this.reservations.get(reservationId)?.usd ?? null
269
+ }
270
+
271
+ /** Later correction from the provider's own generation cost (plan 4.5 reconcile). */
272
+ reconcile(input: { keyRef: KeyRef; turnId: string; profile: string; lane: KeyLane; generationId: string; deltaUsd: number; tokens: { input: number; output: number } }): void {
273
+ if (!Number.isFinite(input.deltaUsd) || input.deltaUsd === 0) return
274
+ const d = this.today(input.keyRef)
275
+ d.settledUsd = Math.max(0, d.settledUsd + input.deltaUsd)
276
+ this.write({
277
+ ts: new Date(this.now()).toISOString(), keyRef: input.keyRef, profile: input.profile, lane: input.lane, turnId: input.turnId,
278
+ generationId: input.generationId, tokens: input.tokens, usd: input.deltaUsd, kind: 'reconcile',
279
+ })
280
+ }
281
+
282
+ /** Drop every in-memory number (Remove all keys). The ledger file is the route's to delete. */
283
+ clear(): void {
284
+ this.days.clear()
285
+ this.reservations.clear()
286
+ this.turnSpend.clear()
287
+ this.savedCaps.clear()
288
+ this.capMismatchLogged.clear()
289
+ }
290
+ }
291
+
292
+ let shared: KeyRelayMeter | null = null
293
+
294
+ /** The one meter the relay, the direct lane (P5) and K3 share. Created on first use. */
295
+ export function getSharedKeyMeter(): KeyRelayMeter {
296
+ if (!shared) shared = new KeyRelayMeter()
297
+ return shared
298
+ }
299
+
300
+ /** Tests only. */
301
+ export function __resetSharedKeyMeterForTests(): void {
302
+ shared = null
303
+ }