@gotcos/glasses-server 6.79.2 → 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 (66) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  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/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/model-router.ts +96 -7
  44. package/server/lib/morning-brief-config.ts +3 -0
  45. package/server/lib/native-head.ts +13 -1
  46. package/server/lib/occupancy-probes.ts +4 -0
  47. package/server/lib/ollama-run-ledger.ts +96 -13
  48. package/server/lib/prompt-edit.ts +2 -0
  49. package/server/lib/query-job-coordinator.ts +2 -1
  50. package/server/lib/query-job-runtime.ts +67 -15
  51. package/server/lib/query-job-store.ts +7 -1
  52. package/server/lib/query-job-types.ts +15 -1
  53. package/server/lib/session-hook-events.ts +28 -0
  54. package/server/lib/session-hook-ledger.ts +10 -0
  55. package/server/lib/thread-occupancy.ts +22 -1
  56. package/server/lib/token-audit.ts +18 -4
  57. package/server/lib/work-handoff-requests.ts +3 -0
  58. package/server/routes/agent-session-bindings.ts +15 -0
  59. package/server/routes/agent-sessions.ts +17 -5
  60. package/server/routes/handoffs.ts +22 -1
  61. package/server/routes/health.ts +5 -0
  62. package/server/routes/key-lanes.ts +442 -0
  63. package/server/routes/openai-compat.ts +28 -3
  64. package/server/routes/query.ts +20 -6
  65. package/shared/key-preference.ts +57 -0
  66. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,346 @@
1
+ // Key lanes K1, package P11 (plan v4 14.2 row P11, 17): the wiring between the packages.
2
+ //
3
+ // P2 owns the relay, P3 and P4 the Lane B bridges, P5 the direct lane, P6 the profile router.
4
+ // Each left a seam for this file to fill:
5
+ //
6
+ // registerLaneBRelay / registerCodexKeyRelay the shared loopback relay (P2) for keyed CLI turns
7
+ // registerCodexKeyedThreadRecorder a keyed Codex thread is recorded like a Claude one (P3)
8
+ // registerKeyProfileEngine claude|codex|direct the runner P6's router hands a `profile:<id>` turn to
9
+ // registerDirectRelay the in-process door the direct lane posts through
10
+ // installKeyRelaySeams the relay's answers on GET /api/key-lanes/status
11
+ //
12
+ // HARD RULE: wireKeyLanes() does map writes, plus the one read keyLanesEnabled() makes of
13
+ // data/key-lanes.json (an existsSync when it is absent; QA N1: this comment said "no file is read").
14
+ // Nothing is written, no socket or timer is opened, and the direct lane module is not even loaded
15
+ // until a direct profile turn runs. The status seams are installed at boot only when
16
+ // keyLanesEnabled(); every registered runner and relay resolver refuses (or is never reached)
17
+ // while keyLanesActive() is false, so a server with no key takes the 6.79.1 path byte for byte.
18
+ import { randomUUID } from 'node:crypto'
19
+ import { callClaudeStreaming } from './claude-bridge.js'
20
+ import { runCodexKeyProfileTurn } from './codex-bridge.js'
21
+ import { registerCodexKeyRelay, registerCodexKeyedThreadRecorder } from './codex-profile-home.js'
22
+ import { keyCatalogPeekPrice } from './key-lanes-pricing.js'
23
+ import { keyLanesActive, keyLanesEnabled, registerKeyLanesStatusProvider } from './key-lanes.js'
24
+ import { registerKeyProfileEngine, type KeyProfileEngineRunner, type KeyProfileTurn } from './key-profile-routing.js'
25
+ import { __setKeyCatalogForTests, getKeyCatalog, KeyCatalog } from './key-catalog.js'
26
+ import type { KeyProfile, KeyRef, KeyRelay, RelayNonceBinding } from './key-relay-contract.js'
27
+ import { KEY_NOTICE_ERROR_IDS, keyModelLabel, keyNoticeError, type KeyNoticeErrorId } from './key-notices.js'
28
+ import { getPublicKey } from './key-store.js'
29
+ import type { StreamCallbacks } from './claude-bridge.js'
30
+ import { breakerScopeFor, classifyUpstreamStatus, countsTowardBreaker, redactSecrets } from './key-relay-errors.js'
31
+ import { estimateReserveUsd, getSharedKeyMeter, tokensToUsd, type KeyRelayMeter } from './key-relay-meter.js'
32
+ import { getKeyRelay, installKeyRelaySeams, KeyRelayRefusal, type KeyRelayServer, type RelayTurnFailure } from './key-relay.js'
33
+ import { readKeySecretForRelay } from './key-store.js'
34
+ import { keyedSessionIds, recordKeyedSession } from './keyed-sessions.js'
35
+ import { registerLaneBRelay } from './lane-b-env.js'
36
+ import type { DirectRefusalKind, DirectRelay, DirectRelayPost, DirectRelayTurn, DirectTurnBinding, DirectUsage } from './direct-bridge.js'
37
+
38
+ /**
39
+ * QA C2 journeys: `COS_KEY_RELAY_TEST_UPSTREAM=http://127.0.0.1:<port>` points the relay's
40
+ * upstreams, OpenRouter's key API and the public catalog at a LOOPBACK mock, so the journey
41
+ * harness can boot index.ts and run a keyed turn end to end. Anything that is not
42
+ * http://127.0.0.1 is ignored with a warning. Not in managed-runtime-contract.json: a managed
43
+ * install never forwards it.
44
+ */
45
+ export function keyRelayTestUpstream(env: NodeJS.ProcessEnv = process.env): string | null {
46
+ const raw = env.COS_KEY_RELAY_TEST_UPSTREAM
47
+ if (!raw) return null
48
+ try {
49
+ const u = new URL(raw)
50
+ if (u.protocol === 'http:' && u.hostname === '127.0.0.1' && u.port) return u.origin
51
+ } catch { /* fall through */ }
52
+ console.warn('[key-lanes] COS_KEY_RELAY_TEST_UPSTREAM ignored: only http://127.0.0.1:<port> is accepted')
53
+ return null
54
+ }
55
+
56
+ /** The one relay, created on first keyed use with live catalog prices (P5) for the meter. */
57
+ export function sharedKeyRelay(): KeyRelayServer {
58
+ const test = keyRelayTestUpstream()
59
+ return getKeyRelay({
60
+ priceFor: keyCatalogPeekPrice,
61
+ onTurnFailure: recordKeyTurnFailure,
62
+ ...(test ? {
63
+ upstreams: { openrouterMessages: `${test}/api/v1/messages`, openrouterResponses: `${test}/api/v1/responses`, anthropicMessages: `${test}/anthropic/v1/messages`, openaiResponses: `${test}/openai/v1/responses` },
64
+ openrouterApiBase: `${test}/api/v1`,
65
+ } : {}),
66
+ })
67
+ }
68
+
69
+ // ── Turn failures: the key-notices line, never the CLI's raw error ───────────
70
+ // The relay reports the plan 4.6 "ends the turn" rows (cap, 401, 402, model gone) for a turn id,
71
+ // and issueTurn refuses a turn before spawn (breaker open, cap used, key gone). The CLI then
72
+ // fails with its own wording ("API Error: 402 ..."). The Lane B runners below wrap onError so
73
+ // the lens, the durable job and COS Control read the P8 line for that failure instead.
74
+
75
+ interface KeyTurnFailure { kind: KeyNoticeErrorId; keyRef: KeyRef; scope?: 'key' | 'profile'; id?: string; ends?: boolean }
76
+ const KEY_TURN_FAILURES_MAX = 256
77
+ const turnFailures = new Map<string, KeyTurnFailure>()
78
+
79
+ function rememberFailure(turnId: string, failure: KeyTurnFailure): void {
80
+ if (!turnId) return
81
+ // A failure that ended the turn is the reason; a later retry note (429, an ended token) never hides it.
82
+ if (turnFailures.get(turnId)?.ends && !failure.ends) return
83
+ turnFailures.delete(turnId)
84
+ turnFailures.set(turnId, failure)
85
+ while (turnFailures.size > KEY_TURN_FAILURES_MAX) {
86
+ const oldest = turnFailures.keys().next().value
87
+ if (oldest === undefined) break
88
+ turnFailures.delete(oldest)
89
+ }
90
+ }
91
+
92
+ /** The relay's onTurnFailure. */
93
+ export function recordKeyTurnFailure(f: RelayTurnFailure): void {
94
+ // QA G4: one line per failure kind (names and kinds only, never a body or a token).
95
+ console.info(`[key-lanes] turn failure kind=${f.kind} status=${f.status} profile=${f.profileId}`)
96
+ const counted = f.kind === 'key_rejected' || f.kind === 'out_of_credit' || f.kind === 'model_gone'
97
+ const scope = f.kind === 'model_gone' ? 'profile' : 'key'
98
+ const ends = f.endsTurn ?? (f.kind === 'cap_refused' || counted)
99
+ rememberFailure(f.turnId, { kind: f.kind, keyRef: f.keyRef, ends, ...(counted ? { scope, id: scope === 'key' ? f.keyRef : f.profileId } : {}) })
100
+ }
101
+
102
+ function refusalId(err: KeyRelayRefusal): KeyNoticeErrorId | null {
103
+ if (err.code === 'breaker_open') return 'breaker_open'
104
+ if (err.kind === 'lanes_inactive') return 'profile_off'
105
+ if (err.code === 'key_missing') return 'profile_removed'
106
+ return (KEY_NOTICE_ERROR_IDS as readonly string[]).includes(err.kind) ? err.kind as KeyNoticeErrorId : null
107
+ }
108
+
109
+ /** The P8 line for a turn the relay failed or refused, once (it is consumed), or null. */
110
+ export function keyTurnFailureLine(turnId: string, profile: Pick<KeyProfile, 'label' | 'model'>, now: Date = new Date()): string | null {
111
+ const f = turnFailures.get(turnId)
112
+ if (!f) return null
113
+ turnFailures.delete(turnId)
114
+ // A counted failure that opened its breaker reads as the pause, not as one more rejection.
115
+ const paused = f.scope && f.id && f.kind !== 'breaker_open' && sharedKeyRelay().breakers.isOpen(f.scope, f.id)
116
+ const key = getPublicKey(f.keyRef)
117
+ return keyNoticeError(paused ? 'breaker_open' : f.kind, {
118
+ ...(key ? { keyKind: key.kind, capUsd: key.capUsd } : {}),
119
+ modelLabel: keyModelLabel(profile.model),
120
+ profileLabel: profile.label,
121
+ now,
122
+ })
123
+ }
124
+
125
+ /** The Lane B relay as the bridges see it: the shared relay, with pre-spawn refusals remembered. */
126
+ function laneBRelayView(): KeyRelay {
127
+ const relay = sharedKeyRelay()
128
+ return {
129
+ issueTurn: async (binding: RelayNonceBinding, options) => {
130
+ try {
131
+ return await relay.issueTurn(binding, options)
132
+ } catch (err) {
133
+ const id = err instanceof KeyRelayRefusal ? refusalId(err) : null
134
+ if (id) rememberFailure(binding.turnId, { kind: id, keyRef: binding.keyRef, ends: true })
135
+ throw err
136
+ }
137
+ },
138
+ revokeTurn: (turnId, reason) => relay.revokeTurn(turnId, reason),
139
+ revokeAll: reason => relay.revokeAll(reason),
140
+ port: () => relay.port(),
141
+ close: () => relay.close(),
142
+ }
143
+ }
144
+
145
+ /** onError reads the turn's key-notices line when the relay has one; onDone forgets it. */
146
+ function withKeyFailureLines(callbacks: StreamCallbacks, turnId: string, profile: KeyProfile): StreamCallbacks {
147
+ turnFailures.delete(turnId)
148
+ return {
149
+ ...callbacks,
150
+ onError: error => callbacks.onError(keyTurnFailureLine(turnId, profile) ?? error),
151
+ onDone: (...args) => { turnFailures.delete(turnId); return callbacks.onDone(...args) },
152
+ }
153
+ }
154
+
155
+ // ── Runners (P6's engine seam) ───────────────────────────────────────────────
156
+
157
+ /**
158
+ * QA C11: an OpenRouter profile whose slug left the (cached) catalog is refused before anything
159
+ * spawns, with the model-gone line. No cached catalog: the turn goes ahead and a refresh starts.
160
+ */
161
+ export function keyProfileSlugGoneLine(turn: Pick<KeyProfileTurn, 'profile' | 'keyKind'>, catalog = getKeyCatalog()): string | null {
162
+ if (turn.keyKind !== 'openrouter') return null
163
+ const models = catalog.peek()
164
+ if (!models) { catalog.refreshInBackground(); return null }
165
+ if (models.some(m => m.id === turn.profile.model)) return null
166
+ console.info(`[key-lanes] refused kind=model_gone profile=${turn.profile.id} (slug not in the catalog)`)
167
+ return keyNoticeError('model_gone', { keyKind: turn.keyKind, modelLabel: keyModelLabel(turn.profile.model), profileLabel: turn.profile.label })
168
+ }
169
+
170
+ async function refuseGoneSlug(sessionId: string, callbacks: StreamCallbacks, turn: KeyProfileTurn): Promise<string | null> {
171
+ const line = keyProfileSlugGoneLine(turn)
172
+ if (!line) return null
173
+ await callbacks.onError(line)
174
+ return sessionId
175
+ }
176
+
177
+ /** Lane B Claude (P3): the profile's timeout tier for the clock, its model on `--model`. */
178
+ export const runClaudeKeyProfileTurn: KeyProfileEngineRunner = async (query, sessionId, callbacks, turn, images, reference, globalMsgNum, options) => {
179
+ const gone = await refuseGoneSlug(sessionId, callbacks, turn)
180
+ if (gone !== null) return gone
181
+ // The relay keys failures by turn id, so the turn gets one here when the caller has none.
182
+ const turnId = options?.turnId ?? randomUUID()
183
+ return callClaudeStreaming(query, sessionId, withKeyFailureLines(callbacks, turnId, turn.profile), turn.profile.timeoutTier, images, reference, globalMsgNum, { ...options, turnId, keyProfile: turn.profile })
184
+ }
185
+
186
+ /** Lane B Codex (P4). */
187
+ export const runCodexKeyTurn: KeyProfileEngineRunner = async (query, sessionId, callbacks, turn, images, reference, globalMsgNum, options) => {
188
+ const gone = await refuseGoneSlug(sessionId, callbacks, turn)
189
+ if (gone !== null) return gone
190
+ const turnId = options?.turnId ?? randomUUID()
191
+ return runCodexKeyProfileTurn(query, sessionId, withKeyFailureLines(callbacks, turnId, turn.profile), turn, images, reference, globalMsgNum, { ...options, turnId })
192
+ }
193
+
194
+ /** Lane C1 (P5). The module loads on the first direct turn, never at boot. */
195
+ export const runDirectKeyTurn: KeyProfileEngineRunner = async (query, sessionId, callbacks, turn, images, reference, globalMsgNum, options) => {
196
+ const gone = await refuseGoneSlug(sessionId, callbacks, turn)
197
+ if (gone !== null) return gone
198
+ const direct = await import('./direct-bridge.js')
199
+ if (!direct.directRelayRegistered()) direct.registerDirectRelay(() => sharedDirectRelay())
200
+ return direct.callDirectStreaming(query, sessionId, callbacks, turn.profile, images, reference, globalMsgNum, options)
201
+ }
202
+
203
+ // ── The direct relay (P5's in-process door over P2's meter, breakers and secret read) ──
204
+
205
+ export interface DirectRelayAdapterDeps {
206
+ isActive?: () => boolean
207
+ secretFor?: (ref: KeyRef) => string | null
208
+ meter?: KeyRelayMeter
209
+ breakers?: () => KeyRelayServer['breakers']
210
+ priceFor?: (model: string) => { inUsdPerM: number; outUsdPerM: number }
211
+ fetch?: typeof fetch
212
+ }
213
+
214
+ function authHeaders(secret: string): Record<string, string> {
215
+ // OpenRouter, OpenAI, Anthropic's OpenAI-compatible endpoint and custom endpoints all take Bearer.
216
+ return { authorization: `Bearer ${secret}`, 'content-type': 'application/json', accept: 'text/event-stream, application/json' }
217
+ }
218
+
219
+ /**
220
+ * A DirectRelay that reserves against the key's daily cap on the shared meter (the same counter
221
+ * Lane B uses), forwards to the binding's endpoint with the real key, and settles what the direct
222
+ * lane measured. It never retries (the lane's own loop does). The key is read at request time and
223
+ * put only in the request headers; an upstream error body is scrubbed of it before the lane sees it.
224
+ */
225
+ export function createDirectRelay(deps: DirectRelayAdapterDeps = {}): DirectRelay {
226
+ const isActive = deps.isActive ?? (() => keyLanesActive())
227
+ const secretFor = deps.secretFor ?? readKeySecretForRelay
228
+ const meter = () => deps.meter ?? getSharedKeyMeter()
229
+ const breakers = deps.breakers ?? (() => sharedKeyRelay().breakers)
230
+ const priceFor = deps.priceFor ?? keyCatalogPeekPrice
231
+ const fetchImpl = deps.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init))
232
+
233
+ const preflight = (b: DirectTurnBinding): DirectRefusalKind | null => {
234
+ if (!isActive()) return 'lanes_inactive'
235
+ if (!secretFor(b.keyRef)) return 'key_missing'
236
+ const br = breakers()
237
+ if (br.isOpen('key', b.keyRef) || br.isOpen('profile', b.profileId)) return 'breaker_open'
238
+ if (meter().atCap(b.keyRef)) return 'cap_refused'
239
+ return null
240
+ }
241
+
242
+ return {
243
+ async openTurn(binding) {
244
+ const refused = preflight(binding)
245
+ if (refused) {
246
+ console.info(`[key-lanes] direct refused kind=${refused} profile=${binding.profileId}`)
247
+ return { ok: false, refused }
248
+ }
249
+ const price = priceFor(binding.model)
250
+ const turn: DirectRelayTurn = {
251
+ async post(body, signal) {
252
+ const refusedNow = preflight(binding)
253
+ if (refusedNow) return { refused: refusedNow }
254
+ const secret = secretFor(binding.keyRef)
255
+ if (!secret) return { refused: 'key_missing' }
256
+ // Only the profile's model goes upstream, whatever the body says.
257
+ const payload = JSON.stringify({ ...body, model: binding.model })
258
+ const maxTokens = typeof body.max_tokens === 'number' ? body.max_tokens : null
259
+ // QA W2: the direct lane caps each reply at its own max_tokens, so the whole of it is
260
+ // reserved; the meter refuses a reservation that does not fit under today's cap.
261
+ const fullUsd = estimateReserveUsd({ bodyBytes: Buffer.byteLength(payload), maxTokens, price })
262
+ const reserved = meter().reserve({
263
+ keyRef: binding.keyRef,
264
+ turnId: binding.turnId,
265
+ estimateUsd: fullUsd,
266
+ profile: binding.profileId,
267
+ lane: 'direct',
268
+ })
269
+ if (!reserved.ok) return { refused: 'cap_refused' }
270
+ let response: Response
271
+ try {
272
+ response = await fetchImpl(binding.endpoint, { method: 'POST', headers: authHeaders(secret), body: payload, signal, redirect: 'error' })
273
+ } catch (err) {
274
+ meter().settle({ reservationId: reserved.reservationId, usd: 0, tokens: { input: 0, output: 0 } })
275
+ throw err instanceof Error ? new Error(redactSecrets(err.message, [secret])) : err
276
+ }
277
+ const br = breakers()
278
+ if (!response.ok) {
279
+ const text = redactSecrets(await response.text().catch(() => ''), [secret])
280
+ const kind = classifyUpstreamStatus(response.status, text)
281
+ if (countsTowardBreaker(kind)) br.record(breakerScopeFor(kind), breakerScopeFor(kind) === 'key' ? binding.keyRef : binding.profileId, kind, false)
282
+ response = new Response(text, { status: response.status, statusText: response.statusText, headers: response.headers })
283
+ } else {
284
+ br.record('key', binding.keyRef, null, true)
285
+ br.record('profile', binding.profileId, null, true)
286
+ }
287
+ let settled = false
288
+ const post: DirectRelayPost = {
289
+ response,
290
+ settle(usage: DirectUsage | null) {
291
+ if (settled) return
292
+ settled = true
293
+ if (usage === null) { meter().settleReserved(reserved.reservationId); return }
294
+ const tokens = { input: Math.max(0, usage.input || 0), output: Math.max(0, usage.output || 0) }
295
+ const usd = typeof usage.costUsd === 'number' && Number.isFinite(usage.costUsd) && usage.costUsd >= 0
296
+ ? usage.costUsd
297
+ : tokensToUsd(tokens, price)
298
+ meter().settle({ reservationId: reserved.reservationId, usd, tokens, ...(usage.generationId ? { generationId: usage.generationId } : {}) })
299
+ },
300
+ }
301
+ return post
302
+ },
303
+ spentTodayUsd: () => meter().spentTodayUsd(binding.keyRef),
304
+ close() { /* nothing held: each post settles its own reservation */ },
305
+ }
306
+ return { ok: true, turn }
307
+ },
308
+ }
309
+ }
310
+
311
+ let directRelay: DirectRelay | null = null
312
+ function sharedDirectRelay(): DirectRelay {
313
+ if (!directRelay) directRelay = createDirectRelay()
314
+ return directRelay
315
+ }
316
+
317
+ // ── Boot ─────────────────────────────────────────────────────────────────────
318
+
319
+ let wired = false
320
+
321
+ /** Called once from index.ts. Map writes only; see the HARD RULE above. */
322
+ export function wireKeyLanes(): void {
323
+ if (wired) return
324
+ wired = true
325
+ registerLaneBRelay(() => laneBRelayView())
326
+ registerCodexKeyRelay(() => laneBRelayView())
327
+ registerCodexKeyedThreadRecorder(thread => recordKeyedSession({ id: thread.id, engine: 'codex', profileId: thread.profileId, cosSessionId: thread.cosSessionId }))
328
+ registerKeyProfileEngine('claude', runClaudeKeyProfileTurn)
329
+ registerKeyProfileEngine('codex', runCodexKeyTurn)
330
+ registerKeyProfileEngine('direct', runDirectKeyTurn)
331
+ // Read only by GET /api/key-lanes/status, which answers only while lanes are enabled.
332
+ registerKeyLanesStatusProvider({ keyedSessionIds })
333
+ const test = keyRelayTestUpstream()
334
+ if (test) {
335
+ console.warn(`[key-lanes] test upstream ${test}: keyed requests and the catalog go to this loopback mock`)
336
+ __setKeyCatalogForTests(new KeyCatalog({ url: `${test}/api/v1/models` }))
337
+ }
338
+ if (keyLanesEnabled()) installKeyRelaySeams()
339
+ }
340
+
341
+ /** Tests only. */
342
+ export function __resetKeyLanesWiringForTests(): void {
343
+ wired = false
344
+ directRelay = null
345
+ turnFailures.clear()
346
+ }
@@ -0,0 +1,145 @@
1
+ // Key lanes (K1, plan v4 section 3): the two predicates every key-lane branch starts with.
2
+ //
3
+ // keyLanesEnabled() COS_KEY_LANES=1, or data/key-lanes.json says enabled:true.
4
+ // COS_KEY_LANES=0 forces off and wins over the file.
5
+ // keyLanesActive() enabled AND provider-profiles.json holds at least one enabled profile.
6
+ //
7
+ // HARD RULE (zero impact without a key): with the switch off nothing here creates a file, opens a
8
+ // socket, starts a timer or adds a response field. Reading never creates. Every new branch in the
9
+ // server starts `if (!keyLanesActive()) return <old path>` (or keyLanesEnabled() for the exposure
10
+ // list rows), before any other new work.
11
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
12
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
13
+ import { DATA_DIR, dataPath } from './data-dir.js'
14
+ import { enabledProfileCount, listProfiles } from './key-profiles.js'
15
+ import { listPublicKeys } from './key-store.js'
16
+ import type {
17
+ KeyLanesHealthDetail,
18
+ KeyLanesInactiveHook,
19
+ KeyLanesInactiveReason,
20
+ KeyLanesStatusProvider,
21
+ PublicKeyProfileOption,
22
+ } from './key-relay-contract.js'
23
+
24
+ export const KEY_LANES_ENV = 'COS_KEY_LANES'
25
+ export const KEY_LANES_STATE_FILE = 'key-lanes.json'
26
+
27
+ export type KeyLanesEnvSwitch = 'on' | 'off' | 'unset'
28
+
29
+ /** Exactly `1` is on and exactly `0` is off; anything else (absent, empty, `true`) is unset. */
30
+ export function keyLanesEnvSwitch(env: NodeJS.ProcessEnv = process.env): KeyLanesEnvSwitch {
31
+ const raw = env[KEY_LANES_ENV]?.trim()
32
+ if (raw === '1') return 'on'
33
+ if (raw === '0') return 'off'
34
+ return 'unset'
35
+ }
36
+
37
+ export function keyLanesStatePath(): string {
38
+ return dataPath(KEY_LANES_STATE_FILE)
39
+ }
40
+
41
+ interface KeyLanesStateFile {
42
+ v: 1
43
+ enabled: boolean
44
+ updatedAt: string
45
+ }
46
+
47
+ /** The file's `enabled`, or null when it is missing or unreadable. Never writes. */
48
+ function readStateFileEnabled(): boolean | null {
49
+ const file = keyLanesStatePath()
50
+ if (!existsSync(file)) return null
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<KeyLanesStateFile>
53
+ return parsed && typeof parsed === 'object' && parsed.enabled === true
54
+ } catch {
55
+ // A torn or foreign file reads as off. It is never repaired or rewritten from a read.
56
+ return false
57
+ }
58
+ }
59
+
60
+ // The file is read once (the first call, at boot in practice) and again on enable/disable.
61
+ let fileEnabled: boolean | null | undefined
62
+
63
+ export function keyLanesEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
64
+ const sw = keyLanesEnvSwitch(env)
65
+ if (sw === 'off') return false
66
+ if (sw === 'on') return true
67
+ if (fileEnabled === undefined) fileEnabled = readStateFileEnabled()
68
+ return fileEnabled === true
69
+ }
70
+
71
+ export function keyLanesActive(env: NodeJS.ProcessEnv = process.env): boolean {
72
+ if (!keyLanesEnabled(env)) return false
73
+ return enabledProfileCount() > 0
74
+ }
75
+
76
+ /**
77
+ * Write the state file (the enable and disable routes, and Remove all keys). This is the ONLY
78
+ * writer of key-lanes.json. 0600, atomic, in a 0700 data dir.
79
+ */
80
+ export function setKeyLanesEnabled(enabled: boolean, now: () => Date = () => new Date()): void {
81
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
82
+ const body: KeyLanesStateFile = { v: 1, enabled, updatedAt: now().toISOString() }
83
+ durableAtomicWriteFileSync(keyLanesStatePath(), JSON.stringify(body, null, 2) + '\n', { mode: 0o600 })
84
+ fileEnabled = enabled
85
+ }
86
+
87
+ /** Re-read the state file on the next predicate call. */
88
+ export function refreshKeyLanesEnabled(): void {
89
+ fileEnabled = undefined
90
+ }
91
+
92
+ // ── Response fields (plan 3.1 exposure list). Omitted, never null, when off. ──
93
+
94
+ /** `/api/health`: `details.keyLanes` while enabled, nothing at all otherwise. */
95
+ export function keyLanesHealthFields(env: NodeJS.ProcessEnv = process.env): { details?: { keyLanes: KeyLanesHealthDetail } } {
96
+ if (!keyLanesEnabled(env)) return {}
97
+ return { details: { keyLanes: { enabled: true, active: keyLanesActive(env) } } }
98
+ }
99
+
100
+ /** `/api/models`: `keyProfiles` while active (enabled profiles whose key still exists), nothing otherwise. */
101
+ export function keyProfilesModelsField(env: NodeJS.ProcessEnv = process.env): { keyProfiles?: PublicKeyProfileOption[] } {
102
+ if (!keyLanesActive(env)) return {}
103
+ const kinds = new Map(listPublicKeys().map(k => [k.ref, k.kind]))
104
+ const keyProfiles: PublicKeyProfileOption[] = []
105
+ for (const p of listProfiles()) {
106
+ const keyKind = kinds.get(p.keyRef)
107
+ if (!p.enabled || !keyKind) continue
108
+ keyProfiles.push({ id: p.id, label: p.label, engine: p.engine, model: p.model, keyKind, timeoutTier: p.timeoutTier })
109
+ }
110
+ return { keyProfiles }
111
+ }
112
+
113
+ // ── Seams other packages register into (P2 relay, P3 keyed sessions, P6 bindings). ──
114
+
115
+ let statusProvider: KeyLanesStatusProvider = {}
116
+ const inactiveHooks = new Map<string, KeyLanesInactiveHook>()
117
+
118
+ export function registerKeyLanesStatusProvider(provider: KeyLanesStatusProvider): void {
119
+ statusProvider = { ...statusProvider, ...provider }
120
+ }
121
+
122
+ export function keyLanesStatusProvider(): KeyLanesStatusProvider {
123
+ return statusProvider
124
+ }
125
+
126
+ /** Named so a re-registration replaces rather than stacks. */
127
+ export function onKeyLanesInactive(name: string, hook: KeyLanesInactiveHook): void {
128
+ inactiveHooks.set(name, hook)
129
+ }
130
+
131
+ /** Run every hook; one failing hook never stops the others. Returns the names that threw. */
132
+ export async function runKeyLanesInactiveHooks(reason: KeyLanesInactiveReason): Promise<string[]> {
133
+ const failed: string[] = []
134
+ for (const [name, hook] of inactiveHooks) {
135
+ try { await hook(reason) } catch { failed.push(name) }
136
+ }
137
+ return failed
138
+ }
139
+
140
+ /** Tests only: forget the cached file read and every registration. */
141
+ export function __resetKeyLanesForTests(): void {
142
+ fileEnabled = undefined
143
+ statusProvider = {}
144
+ inactiveHooks.clear()
145
+ }
@@ -0,0 +1,123 @@
1
+ // Key lanes K1 P2 (plan v4 4.5): the spend ledger, `data/key-ledger.jsonl`.
2
+ //
3
+ // One JSON row per reserve, settle or reconcile. 0600, append-only (one write(2) per row under
4
+ // O_APPEND, so rows from parallel turns never interleave), never written through a symlink. A row
5
+ // carries a key REF, never a secret, never a nonce. Reading never creates: a missing file is an
6
+ // empty ledger, an unreadable line is skipped and left in place.
7
+ //
8
+ // Spend for a UTC day is the sum of `settle` rows plus `reconcile` deltas. `reserve` rows are an
9
+ // audit trail only: open reservations live in the meter's memory, so a crash between reserve and
10
+ // settle forgets the reservation (OpenRouter's own usage_daily and the key's credit limit are the
11
+ // backstop, plan 4.5).
12
+ import { closeSync, constants as fsConstants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
14
+ import { dataPath } from './data-dir.js'
15
+ import type { KeyLane, KeyLedgerKind, KeyLedgerRow, KeyRef } from './key-relay-contract.js'
16
+ import { KEY_REF_RE } from './key-relay-contract.js'
17
+
18
+ export const KEY_LEDGER_FILE = 'key-ledger.jsonl'
19
+ const ID_MAX = 128
20
+ const KINDS: readonly KeyLedgerKind[] = ['reserve', 'settle', 'reconcile']
21
+ const LANES: readonly KeyLane[] = ['key', 'direct']
22
+
23
+ export function keyLedgerPath(): string {
24
+ return dataPath(KEY_LEDGER_FILE)
25
+ }
26
+
27
+ /** The UTC day (plan 4.5: OpenRouter usage_daily resets at UTC midnight). */
28
+ export function utcDay(ms: number): string {
29
+ return new Date(ms).toISOString().slice(0, 10)
30
+ }
31
+
32
+ function clipId(v: unknown): string {
33
+ return typeof v === 'string' ? v.slice(0, ID_MAX) : ''
34
+ }
35
+
36
+ function finiteNonNeg(v: unknown): number {
37
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0
38
+ }
39
+
40
+ /** Build a row from known fields only, so nothing else (a secret, a nonce) can ride along. */
41
+ export function toLedgerRow(input: KeyLedgerRow): KeyLedgerRow {
42
+ if (!KEY_REF_RE.test(input.keyRef)) throw new Error('key ledger: bad key ref')
43
+ if (!KINDS.includes(input.kind)) throw new Error('key ledger: bad kind')
44
+ if (!LANES.includes(input.lane)) throw new Error('key ledger: bad lane')
45
+ const usd = typeof input.usd === 'number' && Number.isFinite(input.usd) ? Math.round(input.usd * 1e8) / 1e8 : 0
46
+ return {
47
+ ts: clipId(input.ts),
48
+ keyRef: input.keyRef,
49
+ profile: clipId(input.profile),
50
+ lane: input.lane,
51
+ turnId: clipId(input.turnId),
52
+ ...(input.generationId ? { generationId: clipId(input.generationId) } : {}),
53
+ tokens: { input: Math.round(finiteNonNeg(input.tokens?.input)), output: Math.round(finiteNonNeg(input.tokens?.output)) },
54
+ usd,
55
+ kind: input.kind,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Append one row. QA W6: opened with O_NOFOLLOW, so a symlink planted between any check and the
61
+ * write is refused by the kernel (ELOOP), never followed; the mode is set on the open descriptor.
62
+ */
63
+ export function appendKeyLedgerRow(row: KeyLedgerRow, file: string = keyLedgerPath()): void {
64
+ const line = JSON.stringify(toLedgerRow(row)) + '\n'
65
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
66
+ let fd: number
67
+ try {
68
+ // O_NONBLOCK too: a FIFO planted at the path fails at once (ENXIO) instead of hanging the server.
69
+ fd = openSync(file, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, 0o600)
70
+ } catch (err) {
71
+ if ((err as NodeJS.ErrnoException)?.code === 'ELOOP') throw new Error('refusing to write the key ledger through a symlink')
72
+ throw err
73
+ }
74
+ try {
75
+ fchmodSync(fd, 0o600)
76
+ writeSync(fd, line)
77
+ } finally {
78
+ closeSync(fd)
79
+ }
80
+ }
81
+
82
+ /** Every parseable row. Never creates the file. */
83
+ export function readKeyLedger(file: string = keyLedgerPath()): KeyLedgerRow[] {
84
+ if (!existsSync(file)) return []
85
+ const st = lstatSync(file)
86
+ if (st.isSymbolicLink() || !st.isFile()) return []
87
+ const rows: KeyLedgerRow[] = []
88
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
89
+ if (!line.trim()) continue
90
+ try {
91
+ const r = JSON.parse(line) as Partial<KeyLedgerRow>
92
+ if (r && typeof r.keyRef === 'string' && typeof r.kind === 'string' && typeof r.usd === 'number' && typeof r.ts === 'string') rows.push(r as KeyLedgerRow)
93
+ } catch { /* a torn line is skipped, never rewritten */ }
94
+ }
95
+ return rows
96
+ }
97
+
98
+ /**
99
+ * Spend for one key on one UTC day, from the file: settle plus reconcile deltas, PLUS every
100
+ * reservation no settle row answered (QA W3: a crash mid-stream left it open, and the provider
101
+ * may have billed it; an Anthropic or OpenAI key has no usage API to say otherwise). Settles
102
+ * answer a turn's reservations in order. Read at a process's first use of the day, before it
103
+ * opens any reservation of its own.
104
+ */
105
+ export function ledgerSpentUsd(keyRef: KeyRef, day: string, file: string = keyLedgerPath()): number {
106
+ let sum = 0
107
+ const open = new Map<string, number[]>()
108
+ for (const r of readKeyLedger(file)) {
109
+ if (r.keyRef !== keyRef || r.ts.slice(0, 10) !== day) continue
110
+ const usd = Number.isFinite(r.usd) ? r.usd : 0
111
+ const turn = typeof r.turnId === 'string' ? r.turnId : ''
112
+ if (r.kind === 'reserve') {
113
+ open.set(turn, [...(open.get(turn) ?? []), Math.max(0, usd)])
114
+ } else if (r.kind === 'settle') {
115
+ sum += usd
116
+ open.get(turn)?.shift()
117
+ } else if (r.kind === 'reconcile') {
118
+ sum += usd
119
+ }
120
+ }
121
+ for (const left of open.values()) for (const usd of left) sum += usd
122
+ return Math.max(0, sum)
123
+ }