@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,442 @@
1
+ // Key lanes (K1, plan v4 section 3.1): `/api/key-lanes/*`.
2
+ //
3
+ // While keyLanesEnabled() is false, every path here falls through to the server's existing 404,
4
+ // byte for byte, with ONE deliberate exception agreed for K1: `POST /api/key-lanes/enable`, so COS
5
+ // Control can turn lanes on from its own Settings row without an env edit. That door answers only
6
+ // an authenticated (X-Cos-Token, checked here as well as by the global gate), loopback,
7
+ // non-browser request, and never while COS_KEY_LANES=0. It is the only call that creates
8
+ // key-lanes.json. Everything else (wrong method, wrong path, phone, browser, missing token) gets
9
+ // today's 404.
10
+ //
11
+ // While enabled: the exposure list in plan 3.1, nothing else. Writes are Mac only (loopback, no
12
+ // browser Origin), so a G2 or phone request cannot add, change or remove a key. No route ever
13
+ // returns a secret: keys go out as PublicKeyRecord (ref, kind, last 4, cap). No route logs a body.
14
+ import express, { Router, type ErrorRequestHandler, type Request, type RequestHandler } from 'express'
15
+ import { copyFileSync, existsSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { dataPath } from '../lib/data-dir.js'
19
+ import {
20
+ keyLanesActive,
21
+ keyLanesEnabled,
22
+ keyLanesEnvSwitch,
23
+ keyLanesStatusProvider,
24
+ runKeyLanesInactiveHooks,
25
+ setKeyLanesEnabled,
26
+ } from '../lib/key-lanes.js'
27
+ import { deleteProfile, deleteProfilesFile, getProfile, listProfiles, upsertProfile, validateProfileInput } from '../lib/key-profiles.js'
28
+ import type { BreakerState, KeyKind, KeyLanesStatusBody } from '../lib/key-relay-contract.js'
29
+ import { KEY_REF_RE } from '../lib/key-relay-contract.js'
30
+ import {
31
+ deleteKey,
32
+ deleteKeyStore,
33
+ getPublicKey,
34
+ isKeyKind,
35
+ keyShapeProblem,
36
+ listPublicKeys,
37
+ normalizeCapUsd,
38
+ normalizeCompatibleBaseUrl,
39
+ saveKey,
40
+ updateKeyCap,
41
+ } from '../lib/key-store.js'
42
+ import { isLoopbackSocket } from '../lib/network-policy.js'
43
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
44
+ import { isKeyProfileId } from '../../shared/key-preference.js'
45
+ import { curatedKeyModels, downselectKeyCatalog, getKeyCatalog, KEY_CATALOG_CURATED, type KeyCatalogModel } from '../lib/key-catalog.js'
46
+ import { keyNoticeError } from '../lib/key-notices.js'
47
+ import { getSharedKeyMeter } from '../lib/key-relay-meter.js'
48
+ import { existingKeyRelay, installKeyRelaySeams } from '../lib/key-relay.js'
49
+
50
+ export const KEY_LANES_ENABLE_PATH = '/api/key-lanes/enable'
51
+ export const KEY_LEDGER_FILE = 'key-ledger.jsonl'
52
+ export const CODEX_HOMES_DIR = 'codex-homes'
53
+ const VALIDATE_TIMEOUT_MS = 10_000
54
+
55
+ export type KeyValidation =
56
+ | { ok: true; limitRemainingUsd?: number; isFreeTier?: boolean }
57
+ | { ok: false; error: string; message: string }
58
+
59
+ export interface CatalogModel {
60
+ id: string
61
+ name: string
62
+ inUsdPerM: number
63
+ outUsdPerM: number
64
+ }
65
+
66
+ export interface KeyLanesRouteDeps {
67
+ /** The pairing token. The door checks it itself so it never relies on mount order alone. */
68
+ apiToken?: () => string
69
+ /** Plan 10.3 provider check. Tests inject a fake; the default calls the provider once. */
70
+ validateKey?: (kind: KeyKind, secret: string, baseUrl?: string) => Promise<KeyValidation>
71
+ /** Public OpenRouter catalog (no key). Called only by GET /key-lanes/catalog. */
72
+ fetchCatalog?: () => Promise<CatalogModel[]>
73
+ downloadsDir?: () => string
74
+ now?: () => Date
75
+ }
76
+
77
+ // ── Who may write ────────────────────────────────────────────────────────────
78
+
79
+ export type MacOnlyRefusal = 'not_loopback' | 'browser'
80
+
81
+ /**
82
+ * Null for a request from a native client on this Mac (COS Control). A phone or G2 companion
83
+ * arrives on a LAN or Tailscale socket; a WebView or browser sends Origin (the companion's is
84
+ * `null`). An agent child on loopback with the token cannot be told apart in
85
+ * K1; it can still never read a secret back.
86
+ */
87
+ export function macOnlyRefusal(req: Pick<Request, 'socket' | 'headers'>): MacOnlyRefusal | null {
88
+ if (!isLoopbackSocket(req)) return 'not_loopback'
89
+ // Browsers send Origin on every POST and DELETE (Fetch Metadata alone is not a tell: Node's
90
+ // fetch sends sec-fetch-mode too).
91
+ if (req.headers.origin !== undefined) return 'browser'
92
+ return null
93
+ }
94
+
95
+ function requestPath(req: Request): string {
96
+ return (req.originalUrl || req.url).split('?')[0]
97
+ }
98
+
99
+ /** The one door that answers while lanes are off. Everything it does not admit is today's 404. */
100
+ export function enableDoorOpen(req: Request, apiToken: () => string): boolean {
101
+ return req.method === 'POST'
102
+ && requestPath(req) === KEY_LANES_ENABLE_PATH
103
+ && keyLanesEnvSwitch() !== 'off'
104
+ && timingSafeTokenEqual(req.headers['x-cos-token'], apiToken())
105
+ && macOnlyRefusal(req) === null
106
+ }
107
+
108
+ function admitted(req: Request, apiToken: () => string): boolean {
109
+ return keyLanesEnabled() || enableDoorOpen(req, apiToken)
110
+ }
111
+
112
+ const defaultApiToken = () => process.env.COS_API_TOKEN ?? ''
113
+
114
+ /**
115
+ * Mounted at `/api/key-lanes` BEFORE the global 10 MB JSON parser. A request it does not admit
116
+ * passes through untouched, so the global parser and the 404 behave exactly as before. An admitted
117
+ * one is parsed here at 32 KB, and a malformed body is refused without echoing a byte of it (Node's
118
+ * JSON errors quote the input, which could be a key).
119
+ */
120
+ export function createKeyLanesBodyParser(deps: Pick<KeyLanesRouteDeps, 'apiToken'> = {}): RequestHandler[] {
121
+ const apiToken = deps.apiToken ?? defaultApiToken
122
+ const parse = express.json({ limit: '32kb' })
123
+ const gate: RequestHandler = (req, res, next) => {
124
+ if (!admitted(req, apiToken)) return next()
125
+ parse(req, res, next)
126
+ }
127
+ const refuse: ErrorRequestHandler = (_err, req, res, next) => {
128
+ if (!admitted(req, apiToken)) return next(_err)
129
+ res.status(400).json({ error: 'bad_json', message: 'The request body is not valid JSON.' })
130
+ }
131
+ return [gate, refuse as unknown as RequestHandler]
132
+ }
133
+
134
+ // ── Provider checks (plan 10.3) ──────────────────────────────────────────────
135
+
136
+ const PROVIDER_NAME: Record<KeyKind, string> = {
137
+ openrouter: 'OpenRouter',
138
+ anthropic: 'Anthropic',
139
+ openai: 'OpenAI',
140
+ 'openai-compatible': 'The endpoint',
141
+ }
142
+
143
+ /** One GET per save. Never logs, never puts the key in a URL, never returns it. */
144
+ export async function defaultValidateKey(kind: KeyKind, secret: string, baseUrl?: string): Promise<KeyValidation> {
145
+ const name = PROVIDER_NAME[kind]
146
+ const target = kind === 'openrouter' ? 'https://openrouter.ai/api/v1/key'
147
+ : kind === 'anthropic' ? 'https://api.anthropic.com/v1/models'
148
+ : kind === 'openai' ? 'https://api.openai.com/v1/models'
149
+ : `${baseUrl ?? ''}/models`
150
+ const headers: Record<string, string> = kind === 'anthropic'
151
+ ? { 'x-api-key': secret, 'anthropic-version': '2023-06-01' }
152
+ : { authorization: `Bearer ${secret}` }
153
+ let response: Response
154
+ try {
155
+ response = await fetch(target, { headers, signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), redirect: 'error' })
156
+ } catch {
157
+ return { ok: false, error: 'validation_unreachable', message: `Could not reach ${name} to check the key. Try again.` }
158
+ }
159
+ if (response.status === 401 || response.status === 403) {
160
+ return { ok: false, error: 'key_rejected', message: `${name} rejected this key. Check it and paste it again.` }
161
+ }
162
+ if (!response.ok) return { ok: false, error: 'validation_failed', message: `${name} could not check the key (HTTP ${response.status}). Try again.` }
163
+ if (kind !== 'openrouter') return { ok: true }
164
+ let data: { limit?: unknown; limit_remaining?: unknown; is_free_tier?: unknown } | undefined
165
+ try { data = ((await response.json()) as { data?: typeof data }).data } catch { data = undefined }
166
+ if (!data) return { ok: false, error: 'validation_failed', message: 'OpenRouter sent an answer COS could not read. Try again.' }
167
+ if (data.limit === null || data.limit === undefined) {
168
+ return { ok: false, error: 'no_credit_limit', message: keyNoticeError('no_credit_limit') }
169
+ }
170
+ if (typeof data.limit_remaining === 'number' && data.limit_remaining <= 0) {
171
+ return { ok: false, error: 'out_of_credit', message: 'This OpenRouter key has no credit left.' }
172
+ }
173
+ return {
174
+ ok: true,
175
+ ...(typeof data.limit_remaining === 'number' ? { limitRemainingUsd: data.limit_remaining } : {}),
176
+ ...(typeof data.is_free_tier === 'boolean' ? { isFreeTier: data.is_free_tier } : {}),
177
+ }
178
+ }
179
+
180
+ // ── Catalog (plan 10.4) ──────────────────────────────────────────────────────
181
+
182
+ /** The curated two (D10), from P5's key-catalog.ts, in the shape this route has always served. */
183
+ export const CURATED_KEY_MODELS = KEY_CATALOG_CURATED.map(({ id, name, inUsdPerM, outUsdPerM, role }) => ({ id, name, inUsdPerM, outUsdPerM, role }))
184
+
185
+ /** P11: the shared 24 h public catalog (key-catalog.ts). Throws when only the built-in list is known. */
186
+ export async function defaultFetchCatalog(): Promise<CatalogModel[]> {
187
+ const snapshot = await getKeyCatalog().get()
188
+ if (snapshot.from !== 'openrouter') throw new Error('catalog unavailable')
189
+ return snapshot.models
190
+ }
191
+
192
+ /** The next UTC midnight: when every key's daily cap resets (plan 4.5, OpenRouter usage_daily). */
193
+ export function keyCapResetsAt(now: Date): string {
194
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)).toISOString()
195
+ }
196
+
197
+ // ── Router ───────────────────────────────────────────────────────────────────
198
+
199
+ const CLOSED_BREAKER: BreakerState = { open: false, failures: 0 }
200
+
201
+ function statusBody(now: Date): KeyLanesStatusBody & { resetsAt: string } {
202
+ const provider = keyLanesStatusProvider()
203
+ return {
204
+ enabled: keyLanesEnabled(),
205
+ active: keyLanesActive(),
206
+ keys: listPublicKeys().map(k => ({
207
+ ref: k.ref,
208
+ kind: k.kind,
209
+ last4: k.last4,
210
+ capUsd: k.capUsd,
211
+ spentTodayUsd: provider.spentTodayUsd?.(k.ref) ?? 0,
212
+ breaker: provider.breaker?.(k.ref) ?? CLOSED_BREAKER,
213
+ })),
214
+ profiles: listProfiles(),
215
+ keyedSessionIds: provider.keyedSessionIds?.() ?? [],
216
+ // P11: the phone shows when today's caps reset. Never a secret: keys go out as ref and last 4.
217
+ resetsAt: keyCapResetsAt(now),
218
+ }
219
+ }
220
+
221
+ function ledgerExportName(dir: string, now: Date): string {
222
+ const stem = `cos-key-ledger-${now.toISOString().slice(0, 10)}`
223
+ let candidate = join(dir, `${stem}.jsonl`)
224
+ for (let n = 2; existsSync(candidate) && n < 100; n++) candidate = join(dir, `${stem}-${n}.jsonl`)
225
+ return candidate
226
+ }
227
+
228
+ export function createKeyLanesRouter(deps: KeyLanesRouteDeps = {}): Router {
229
+ const apiToken = deps.apiToken ?? defaultApiToken
230
+ const validateKey = deps.validateKey ?? defaultValidateKey
231
+ const fetchCatalog = deps.fetchCatalog ?? defaultFetchCatalog
232
+ const downloadsDir = deps.downloadsDir ?? (() => join(homedir(), 'Downloads'))
233
+ const now = deps.now ?? (() => new Date())
234
+
235
+ const router = Router()
236
+
237
+ // Off: the door or today's 404. `next('router')` leaves this router untouched.
238
+ router.use('/key-lanes', (req, _res, next) => {
239
+ if (admitted(req, apiToken)) return next()
240
+ return next('router')
241
+ })
242
+
243
+ const macOnly: RequestHandler = (req, res, next) => {
244
+ const refusal = macOnlyRefusal(req)
245
+ if (!refusal) return next()
246
+ return res.status(403).json({ error: 'mac_only', reason: refusal, message: 'Keys are managed in COS Control on this Mac.' })
247
+ }
248
+
249
+ /** After a write that may have ended the last enabled profile. */
250
+ const settle = async (wasActive: boolean, reason: 'disabled' | 'no_enabled_profile') => {
251
+ if (wasActive && !keyLanesActive()) await runKeyLanesInactiveHooks(reason)
252
+ }
253
+
254
+ router.post('/key-lanes/enable', macOnly, (_req, res) => {
255
+ setKeyLanesEnabled(true, now)
256
+ // P11: lanes turned on without a restart get the relay's status answers at once (map writes only).
257
+ installKeyRelaySeams()
258
+ res.json({ enabled: keyLanesEnabled(), active: keyLanesActive() })
259
+ })
260
+
261
+ router.post('/key-lanes/disable', macOnly, async (_req, res) => {
262
+ const wasActive = keyLanesActive()
263
+ setKeyLanesEnabled(false, now)
264
+ await settle(wasActive, 'disabled')
265
+ const forcedOn = keyLanesEnvSwitch() === 'on'
266
+ res.json({ enabled: keyLanesEnabled(), active: keyLanesActive(), ...(forcedOn ? { forcedOnBy: 'COS_KEY_LANES' } : {}) })
267
+ })
268
+
269
+ router.get('/key-lanes/status', (_req, res) => {
270
+ res.set('Cache-Control', 'private, no-store')
271
+ res.json(statusBody(now()))
272
+ })
273
+
274
+ router.post('/key-lanes/keys', macOnly, async (req, res) => {
275
+ res.set('Cache-Control', 'private, no-store')
276
+ const body = (req.body ?? {}) as { kind?: unknown; key?: unknown; ref?: unknown; capUsd?: unknown; baseUrl?: unknown }
277
+ // Cap change on a saved key: no secret in the body.
278
+ if (body.key === undefined && typeof body.ref === 'string') {
279
+ const cap = normalizeCapUsd(body.capUsd)
280
+ if (body.capUsd === undefined || cap === null) return res.status(400).json({ error: 'invalid_cap', message: 'A daily cap is between $0.01 and $1000.' })
281
+ const updated = updateKeyCap(body.ref, cap)
282
+ if (!updated) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
283
+ // P11 (plan 4.5): the relay meters against the cap Control saved, not a later file edit.
284
+ getSharedKeyMeter().noteSavedCap(updated.ref, updated.capUsd)
285
+ return res.json({ key: updated })
286
+ }
287
+ if (!isKeyKind(body.kind)) return res.status(400).json({ error: 'invalid_kind', message: 'Pick OpenRouter, Anthropic, OpenAI or a custom endpoint.' })
288
+ const kind = body.kind
289
+ const shape = keyShapeProblem(kind, body.key)
290
+ if (shape) return res.status(400).json({ error: 'invalid_key', message: shape })
291
+ const cap = normalizeCapUsd(body.capUsd)
292
+ if (cap === null) return res.status(400).json({ error: 'invalid_cap', message: 'A daily cap is between $0.01 and $1000.' })
293
+ let baseUrl: string | undefined
294
+ if (kind === 'openai-compatible') {
295
+ baseUrl = normalizeCompatibleBaseUrl(body.baseUrl) ?? undefined
296
+ if (!baseUrl) return res.status(400).json({ error: 'invalid_base_url', message: 'Give the endpoint URL, starting with https://.' })
297
+ }
298
+ const secret = (body.key as string).trim()
299
+ let check: KeyValidation
300
+ try {
301
+ check = await validateKey(kind, secret, baseUrl)
302
+ } catch {
303
+ check = { ok: false, error: 'validation_failed', message: 'COS could not check the key. Try again.' }
304
+ }
305
+ if (!check.ok) return res.status(422).json({ error: check.error, message: check.message })
306
+ const saved = saveKey({ kind, secret, capUsd: cap, validatedAt: now().toISOString(), ...(baseUrl ? { baseUrl } : {}) }, now)
307
+ getSharedKeyMeter().noteSavedCap(saved.ref, saved.capUsd)
308
+ return res.json({
309
+ key: saved,
310
+ ...(check.limitRemainingUsd !== undefined || check.isFreeTier !== undefined
311
+ ? { credit: { ...(check.limitRemainingUsd !== undefined ? { remainingUsd: check.limitRemainingUsd } : {}), ...(check.isFreeTier !== undefined ? { freeTier: check.isFreeTier } : {}) } }
312
+ : {}),
313
+ })
314
+ })
315
+
316
+ // Remove one key (plan 17 item 3). Refused while an enabled profile uses it, unless ?force=1,
317
+ // which turns those profiles off first so no turn can pick a key that is gone.
318
+ router.delete('/key-lanes/keys/:ref', macOnly, async (req, res) => {
319
+ const ref = typeof req.params.ref === 'string' ? req.params.ref : ''
320
+ if (!KEY_REF_RE.test(ref)) return res.status(400).json({ error: 'invalid_key_ref', message: 'Unknown key.' })
321
+ if (!getPublicKey(ref)) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
322
+ const force = req.query.force === '1'
323
+ const users = listProfiles().filter(p => p.keyRef === ref && p.enabled)
324
+ if (users.length && !force) {
325
+ return res.status(409).json({
326
+ error: 'key_in_use',
327
+ message: `Turn off the profiles that use this key first: ${users.map(p => p.label).join(', ')}.`,
328
+ profiles: users.map(p => p.id),
329
+ })
330
+ }
331
+ const wasActive = keyLanesActive()
332
+ for (const p of users) upsertProfile({ ...p, enabled: false, updatedAt: now().toISOString() })
333
+ for (const p of users) existingKeyRelay()?.revokeProfile(p.id)
334
+ deleteKey(ref)
335
+ await settle(wasActive, 'no_enabled_profile')
336
+ return res.json({ removed: true, disabledProfiles: users.map(p => p.id), active: keyLanesActive() })
337
+ })
338
+
339
+ // QA U6 / C8: Control's "Reset" for a key paused after three failures (plan 4.6).
340
+ router.post('/key-lanes/breaker/reset', macOnly, (req, res) => {
341
+ const ref = (req.body as { ref?: unknown } | undefined)?.ref
342
+ if (typeof ref !== 'string' || !KEY_REF_RE.test(ref)) return res.status(400).json({ error: 'invalid_key_ref', message: 'Unknown key.' })
343
+ if (!getPublicKey(ref)) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
344
+ const relay = existingKeyRelay()
345
+ relay?.resetBreaker('key', ref)
346
+ return res.json({ reset: true, breaker: relay?.breakerState('key', ref) ?? CLOSED_BREAKER })
347
+ })
348
+
349
+ router.post('/key-lanes/profiles', macOnly, async (req, res) => {
350
+ const wasActive = keyLanesActive()
351
+ const result = validateProfileInput((req.body ?? {}) as Record<string, unknown>, ref => getPublicKey(ref)?.kind, now)
352
+ if (!result.ok) return res.status(400).json({ error: result.error, message: result.message })
353
+ const before = getProfile(result.profile.id)
354
+ const write = upsertProfile(result.profile)
355
+ if (!write.ok) return res.status(409).json({ error: write.error, message: 'That is the most key profiles COS keeps. Remove one first.' })
356
+ const p = result.profile
357
+ if (before) {
358
+ const relay = existingKeyRelay()
359
+ // QA W5: a profile turned off or pointed elsewhere stops its live turns now.
360
+ if (!p.enabled || before.keyRef !== p.keyRef || before.model !== p.model || before.engine !== p.engine) relay?.revokeProfile(p.id)
361
+ // QA U6: a new model or key is a fresh start for the profile's breaker.
362
+ if (before.keyRef !== p.keyRef || before.model !== p.model) relay?.resetBreaker('profile', p.id)
363
+ }
364
+ await settle(wasActive, 'no_enabled_profile')
365
+ return res.json({ profile: result.profile, active: keyLanesActive() })
366
+ })
367
+
368
+ router.delete('/key-lanes/profiles/:id', macOnly, async (req, res) => {
369
+ if (!isKeyProfileId(req.params.id)) return res.status(400).json({ error: 'invalid_profile', message: 'Unknown profile id.' })
370
+ const wasActive = keyLanesActive()
371
+ const removed = deleteProfile(req.params.id)
372
+ if (!removed) return res.status(404).json({ error: 'unknown_profile', message: 'No profile with that id.' })
373
+ existingKeyRelay()?.revokeProfile(req.params.id)
374
+ await settle(wasActive, 'no_enabled_profile')
375
+ return res.json({ removed: true, active: keyLanesActive() })
376
+ })
377
+
378
+ router.post('/key-lanes/remove-all', macOnly, async (req, res) => {
379
+ const body = (req.body ?? {}) as { exportLedger?: unknown }
380
+ if (body.exportLedger !== undefined && typeof body.exportLedger !== 'boolean') {
381
+ return res.status(400).json({ error: 'invalid_request', message: 'exportLedger is true or false.' })
382
+ }
383
+ const ledger = dataPath(KEY_LEDGER_FILE)
384
+ let ledgerExportedTo: string | undefined
385
+ if (body.exportLedger === true && existsSync(ledger)) {
386
+ // Export first: if it fails, nothing has been removed yet.
387
+ try {
388
+ const dir = downloadsDir()
389
+ mkdirSync(dir, { recursive: true })
390
+ ledgerExportedTo = ledgerExportName(dir, now())
391
+ copyFileSync(ledger, ledgerExportedTo)
392
+ } catch {
393
+ return res.status(500).json({ error: 'export_failed', message: 'Could not save the spend ledger to Downloads. Nothing was removed.' })
394
+ }
395
+ }
396
+ // Revoke nonces and close the relay (P2), clear session bindings (P6) before the files go.
397
+ const hookFailures = await runKeyLanesInactiveHooks('remove_all')
398
+ const removed: string[] = []
399
+ if (deleteKeyStore()) removed.push('keys')
400
+ if (deleteProfilesFile()) removed.push('profiles')
401
+ const homes = dataPath(CODEX_HOMES_DIR)
402
+ if (existsSync(homes)) { rmSync(homes, { recursive: true, force: true }); removed.push('codexHomes') }
403
+ if (existsSync(ledger)) { unlinkSync(ledger); removed.push('ledger') }
404
+ setKeyLanesEnabled(false, now)
405
+ return res.json({
406
+ removed,
407
+ enabled: keyLanesEnabled(),
408
+ active: keyLanesActive(),
409
+ ...(ledgerExportedTo ? { ledgerExportedTo } : {}),
410
+ ...(hookFailures.length ? { hookFailures } : {}),
411
+ })
412
+ })
413
+
414
+ router.get('/key-lanes/catalog', async (req, res) => {
415
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().toLowerCase().slice(0, 40) : ''
416
+ // QA G5: one 24 h cache (key-catalog.ts), not a second one here.
417
+ let models: CatalogModel[] | null
418
+ try {
419
+ models = await fetchCatalog()
420
+ } catch {
421
+ models = null
422
+ }
423
+ // P11: curated prices and lanes from P5's key-catalog.ts, the one catalog module.
424
+ const curated = curatedKeyModels(models as KeyCatalogModel[] | null)
425
+ .map(({ id, name, inUsdPerM, outUsdPerM, role }) => ({ id, name, inUsdPerM, outUsdPerM, role }))
426
+ // Lane B takes only anthropic/ and openai/ slugs; every other model is direct only (plan 4.4).
427
+ // QA G5: P5's downselect (20 rows, one 64-character lens row each, unsafe glyphs cleaned),
428
+ // reporting only the rows that match the search.
429
+ let search: Array<CatalogModel & { lanes: string[]; label: string }> | undefined
430
+ if (q && models) {
431
+ const byId = new Map(models.map(m => [m.id, m]))
432
+ search = downselectKeyCatalog(models as KeyCatalogModel[], { query: q })
433
+ .flatMap(r => {
434
+ const m = byId.get(r.id)
435
+ return m && (m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) ? [{ ...m, lanes: r.lanes, label: r.label }] : []
436
+ })
437
+ }
438
+ res.json({ curated, pricesFrom: models ? 'openrouter' : 'built-in', ...(search ? { search } : {}) })
439
+ })
440
+
441
+ return router
442
+ }
@@ -13,6 +13,10 @@ import {
13
13
  } from '../lib/codex-model-catalog.js'
14
14
  import { resolveCursorPreferenceForModelId } from '../lib/cursor-model-catalog.js'
15
15
  import { tryInstantResponse } from '../lib/response-cache.js'
16
+ import { keyLanesActive } from '../lib/key-lanes.js'
17
+ import { KEY_PROFILE_V1_NOT_OFFERED, v1OfferedProfile, v1OfferedProfiles } from '../lib/key-profile-routing.js'
18
+ import type { SessionModelBinding } from '../lib/conversation.js'
19
+ import { parseV1KeyModelName, profilePreference, v1KeyModelName } from '../../shared/key-preference.js'
16
20
 
17
21
  import crypto from 'node:crypto'
18
22
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
@@ -188,10 +192,22 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
188
192
  model,
189
193
  typeof req.query.model === 'string' ? req.query.model : undefined,
190
194
  )
191
- const resolvedModel = resolveModel(selectedModel, query)
195
+ // Key lanes K1 (plan 9.4): `cos-key-<id>` is handled BEFORE resolveModel, which stays closed.
196
+ // Only an active profile with "Offer on /v1" on runs; any other `cos-key-<id>` is refused, never
197
+ // answered on the subscription. Every other model name takes the 6.79.0 path unchanged.
198
+ const keyProfileId = parseV1KeyModelName(selectedModel)
199
+ if (keyProfileId !== undefined && !v1OfferedProfile(keyProfileId)) {
200
+ maintenanceLease.release()
201
+ return res.status(400).json({
202
+ error: { message: KEY_PROFILE_V1_NOT_OFFERED, type: 'invalid_request_error', code: 'model_not_offered' },
203
+ })
204
+ }
205
+ const resolvedModel: SessionModelBinding = keyProfileId !== undefined
206
+ ? profilePreference(keyProfileId) as SessionModelBinding
207
+ : resolveModel(selectedModel, query)
192
208
  const completionId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`
193
209
  const timestamp = Math.floor(Date.now() / 1000)
194
- const responseModel = MODEL_NAMES[resolvedModel]
210
+ const responseModel = keyProfileId !== undefined ? v1KeyModelName(keyProfileId) : MODEL_NAMES[resolvedModel as ModelPreference]
195
211
 
196
212
  // ── Predictive response cache — bypass Claude for common queries ──
197
213
  const cached = tryInstantResponse(query)
@@ -557,7 +573,10 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
557
573
  })
558
574
 
559
575
  // GET /v1/models — required by some clients for model discovery
560
- openaiCompatRouter.get('/v1/models', async (_req, res) => {
576
+ // Key lanes K1 (plan 9.4, D8): open and byte-identical until a key lane is active; then it asks
577
+ // for the chat Bearer and lists `cos-key-<id>` for each profile offered on /v1.
578
+ openaiCompatRouter.get('/v1/models', async (req, res) => {
579
+ if (keyLanesActive() && !validateAuth(req, res)) return
561
580
  const catalog = await getCodexModelCatalog()
562
581
  res.json({
563
582
  object: 'list',
@@ -574,6 +593,12 @@ openaiCompatRouter.get('/v1/models', async (_req, res) => {
574
593
  created: 1709251200,
575
594
  owned_by: 'openai',
576
595
  })),
596
+ ...v1OfferedProfiles().map(profile => ({
597
+ id: v1KeyModelName(profile.id),
598
+ object: 'model',
599
+ created: 1709251200,
600
+ owned_by: 'cos-key',
601
+ })),
577
602
  ],
578
603
  })
579
604
  })
@@ -8,8 +8,9 @@ import { errMsg } from '../lib/utils.js'
8
8
  import {
9
9
  isCursorModel,
10
10
  normalizeEffortPreference,
11
- normalizeModelPreference,
12
11
  } from '../../shared/model-preference.js'
12
+ import { parsePreference } from '../../shared/key-preference.js'
13
+ import type { KeyProfileTurn } from '../lib/key-profile-routing.js'
13
14
  import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
14
15
  import { getMediaStore } from '../lib/media-store.js'
15
16
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
@@ -19,6 +20,8 @@ import {
19
20
  maintenanceErrorPayload,
20
21
  } from '../lib/maintenance-lifecycle.js'
21
22
  import { currentMessageEra, LEGACY_MESSAGE_ERA } from '../lib/message-era.js'
23
+ import { keyAnswerDoneFields } from '../lib/key-answer-fields.js'
24
+ import { isProfilePreference } from '../../shared/key-preference.js'
22
25
 
23
26
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
24
27
  WebSearch: 'Searching web...',
@@ -92,8 +95,11 @@ queryRouter.post('/query', async (req, res) => {
92
95
  return res.status(400).json({ error: 'query string or attachment required' })
93
96
  }
94
97
 
95
- // Validate model if provided
96
- const validModel = normalizeModelPreference(model)
98
+ // Validate model if provided. Key lanes K1 (plan 5.2): Accept. A `profile:<id>` is passed to
99
+ // model-router as is (it runs on the profile or is refused there); every family guard below
100
+ // reads the closed slot, so a model slot behaves exactly as before.
101
+ const requestedBinding = parsePreference(model)
102
+ const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
97
103
  const validEffort = normalizeEffortPreference(effort)
98
104
 
99
105
  // Validate globalMsgNum if provided
@@ -136,12 +142,17 @@ queryRouter.post('/query', async (req, res) => {
136
142
  done = true
137
143
  })
138
144
 
145
+ // Set only by model-router's onKeyProfileTurn: the binding is the turn's model on the stream.
146
+ let keyTurn: KeyProfileTurn | undefined
139
147
  try {
140
148
  const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
149
+ ...(requestedBinding?.kind === 'model' ? {} : {
150
+ onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
151
+ }),
141
152
  onStart: (model, sid, cliSessionId, metadata) => {
142
153
  streamSessionId = sid
143
154
  if (!done) {
144
- const payload = { model, sessionId: sid, cliSessionId, ...metadata }
155
+ const payload = { model: keyTurn ? keyTurn.binding : model, sessionId: sid, cliSessionId, ...metadata }
145
156
  res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
146
157
  emitDisplay({ type: 'start', data: payload })
147
158
  }
@@ -192,7 +203,8 @@ queryRouter.post('/query', async (req, res) => {
192
203
  const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
193
204
  const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
194
205
  const payload = {
195
- text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
206
+ text: fullText, sessionId: streamSessionId, model: keyTurn ? keyTurn.binding : model, cliSessionId, ...runMetadata,
207
+ ...keyAnswerDoneFields(runMetadata),
196
208
  ...(attachments.length > 0 ? { attachments } : {}),
197
209
  }
198
210
  res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
@@ -215,7 +227,7 @@ queryRouter.post('/query', async (req, res) => {
215
227
  maintenanceLease.release()
216
228
  }
217
229
  },
218
- }, validModel, imageInputs,
230
+ }, requestedBinding?.kind === 'profile' ? model as KeyProfileTurn['binding'] : validModel, imageInputs,
219
231
  // Pass reference if provided (for "recall message N" feature)
220
232
  reference && typeof reference === 'object' && reference.query && reference.response
221
233
  ? { query: String(reference.query), response: String(reference.response) }
@@ -228,6 +240,8 @@ queryRouter.post('/query', async (req, res) => {
228
240
  requestAttachments: attachmentRefs,
229
241
  attachmentPromptBlock: resolvedAttachments.promptBlock,
230
242
  ...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
243
+ // QA U9: the one-turn plan-limit offer runs on the key without binding the session.
244
+ ...(req.body.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true } : {}),
231
245
  },
232
246
  )
233
247
  } catch (err: unknown) {
@@ -0,0 +1,57 @@
1
+ // Key lanes (K1, plan v4 section 5.1): the ONE parser that may read a key profile out of a model
2
+ // preference. `isModelPreference` and `normalizeModelPreference` (model-preference.ts) stay
3
+ // closed and unchanged, so every site that does not call parsePreference keeps refusing a
4
+ // profile exactly as 6.79.0 does. Call this only at the sites plan 5.2 marks Accept.
5
+ //
6
+ // Encoding: a profile-bound preference is `profile:<id>`. The prefix keeps a profile id from ever
7
+ // colliding with a model slot name (`opus` is a valid id shape). On `/v1` a profile is offered as
8
+ // `cos-key-<id>` (plan 9.4), handled before `resolveModel` and only for profiles that opt in.
9
+ import { normalizeModelPreference, type ModelPreference } from './model-preference.js'
10
+
11
+ export const KEY_PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
12
+ export const KEY_PROFILE_PREFERENCE_PREFIX = 'profile:'
13
+ export const V1_KEY_MODEL_PREFIX = 'cos-key-'
14
+
15
+ export type ParsedPreference =
16
+ | { kind: 'model'; value: ModelPreference }
17
+ | { kind: 'profile'; id: string }
18
+
19
+ export function isKeyProfileId(value: unknown): value is string {
20
+ return typeof value === 'string' && KEY_PROFILE_ID_RE.test(value)
21
+ }
22
+
23
+ /** `profile:<id>`, or throws on a malformed id (a caller bug, never user input). */
24
+ export function profilePreference(id: string): string {
25
+ if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
26
+ return KEY_PROFILE_PREFERENCE_PREFIX + id
27
+ }
28
+
29
+ /**
30
+ * A model slot (normalized exactly as normalizeModelPreference does, so `codex-high` still
31
+ * migrates), a `profile:<id>`, or undefined for anything else. Never throws.
32
+ */
33
+ export function parsePreference(value: unknown): ParsedPreference | undefined {
34
+ const model = normalizeModelPreference(value)
35
+ if (model) return { kind: 'model', value: model }
36
+ if (typeof value !== 'string' || !value.startsWith(KEY_PROFILE_PREFERENCE_PREFIX)) return undefined
37
+ const id = value.slice(KEY_PROFILE_PREFERENCE_PREFIX.length)
38
+ return isKeyProfileId(id) ? { kind: 'profile', id } : undefined
39
+ }
40
+
41
+ /** True only for a well-formed `profile:<id>`. A model slot is false. */
42
+ export function isProfilePreference(value: unknown): boolean {
43
+ return parsePreference(value)?.kind === 'profile'
44
+ }
45
+
46
+ /** `/v1` model name for a profile (plan 9.4). */
47
+ export function v1KeyModelName(id: string): string {
48
+ if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
49
+ return V1_KEY_MODEL_PREFIX + id
50
+ }
51
+
52
+ /** The profile id in a `cos-key-<id>` `/v1` model name, or undefined. */
53
+ export function parseV1KeyModelName(value: unknown): string | undefined {
54
+ if (typeof value !== 'string' || !value.startsWith(V1_KEY_MODEL_PREFIX)) return undefined
55
+ const id = value.slice(V1_KEY_MODEL_PREFIX.length)
56
+ return isKeyProfileId(id) ? id : undefined
57
+ }