@gotcos/glasses-server 6.79.2 → 6.81.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.
- package/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- 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
|
+
}
|