@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.
Files changed (70) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +10 -1
  6. package/server/index.ts +14 -1
  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/meeting-autonomous-finalize.ts +267 -0
  44. package/server/lib/meeting-stop-intents.ts +277 -0
  45. package/server/lib/model-router.ts +96 -7
  46. package/server/lib/morning-brief-config.ts +3 -0
  47. package/server/lib/native-head.ts +13 -1
  48. package/server/lib/occupancy-probes.ts +4 -0
  49. package/server/lib/ollama-run-ledger.ts +96 -13
  50. package/server/lib/prompt-edit.ts +2 -0
  51. package/server/lib/query-job-coordinator.ts +2 -1
  52. package/server/lib/query-job-runtime.ts +67 -15
  53. package/server/lib/query-job-store.ts +7 -1
  54. package/server/lib/query-job-types.ts +15 -1
  55. package/server/lib/session-hook-events.ts +28 -0
  56. package/server/lib/session-hook-ledger.ts +10 -0
  57. package/server/lib/thread-occupancy.ts +22 -1
  58. package/server/lib/token-audit.ts +18 -4
  59. package/server/lib/work-handoff-requests.ts +3 -0
  60. package/server/routes/agent-session-bindings.ts +15 -0
  61. package/server/routes/agent-sessions.ts +17 -5
  62. package/server/routes/handoffs.ts +22 -1
  63. package/server/routes/health.ts +5 -0
  64. package/server/routes/key-lanes.ts +442 -0
  65. package/server/routes/meeting.ts +326 -47
  66. package/server/routes/openai-compat.ts +28 -3
  67. package/server/routes/query.ts +20 -6
  68. package/server/routes/transcribe-stream.ts +48 -1
  69. package/shared/key-preference.ts +57 -0
  70. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,262 @@
1
+ // Key lanes K1 P5 (plan v4 9.1): the OpenAI chat-completions SSE parser and tool-call accumulator
2
+ // for Lane C1 (direct answers on the Mac).
3
+ //
4
+ // Why not the Ollama parser (ollama-bridge.ts parseOllamaChatDelta): Ollama streams NDJSON and
5
+ // hands over each tool call WHOLE, with `arguments` as an object and no id. OpenAI-style SSE
6
+ // (OpenRouter, OpenAI, any compatible endpoint) sends `data:` events, and a tool call arrives as
7
+ // INDEXED FRAGMENTS: the first fragment for index N carries `id`, `type` and `function.name`, and
8
+ // later fragments for the same index carry more of `function.arguments` as a STRING. The next
9
+ // request must send the assembled calls back as `{ id, type: 'function', function: { name,
10
+ // arguments } }` and each result as `{ role: 'tool', tool_call_id: <that id> }`. Ollama's
11
+ // `tool_name` is meaningless here.
12
+ //
13
+ // Pure: no I/O, no timers, no env. Bounded: an unterminated event, a call's arguments and the
14
+ // number of calls per response are all capped, and going past a cap is an error, never a silent cut.
15
+
16
+ export const DIRECT_SSE_LIMITS = {
17
+ /** An event without its blank-line terminator may not grow past this. */
18
+ eventBufferChars: 1024 * 1024,
19
+ /** One tool call's assembled arguments. */
20
+ argumentChars: 64 * 1024,
21
+ /** Tool calls in one response. */
22
+ callsPerResponse: 16,
23
+ /** One tool name. */
24
+ nameChars: 128,
25
+ } as const
26
+
27
+ export interface DirectToolCall {
28
+ id: string
29
+ type: 'function'
30
+ function: { name: string; arguments: string }
31
+ }
32
+
33
+ export interface DirectSseUsage {
34
+ input: number
35
+ output: number
36
+ /** Provider-reported cost in USD (OpenRouter `usage.cost`), when present. */
37
+ costUsd?: number
38
+ }
39
+
40
+ export type DirectSseEvent =
41
+ | { type: 'text'; text: string }
42
+ | { type: 'error'; message: string; code?: string | number }
43
+ | { type: 'done' }
44
+
45
+ function nonNegative(v: unknown): number {
46
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0
47
+ }
48
+
49
+ interface Slot {
50
+ index: number
51
+ id: string
52
+ name: string
53
+ args: string
54
+ }
55
+
56
+ /**
57
+ * Assembles indexed fragments into whole calls.
58
+ *
59
+ * Index is the key. A fragment without an index (some compatible servers) joins the slot whose id
60
+ * it repeats, else opens a new slot when it brings a new id, else extends the last slot. The first
61
+ * non-empty id wins. A name that repeats the one already held is ignored (servers that resend it
62
+ * on every fragment); anything else is appended (servers that stream the name too). Arguments
63
+ * concatenate; an OBJECT (non-standard) replaces them with its JSON.
64
+ */
65
+ export class DirectToolCallAccumulator {
66
+ private readonly slots: Slot[] = []
67
+ private overflow: string | null = null
68
+
69
+ constructor(private readonly idPrefix: string) {}
70
+
71
+ add(fragment: unknown): void {
72
+ if (this.overflow || !fragment || typeof fragment !== 'object') return
73
+ const f = fragment as { index?: unknown; id?: unknown; function?: { name?: unknown; arguments?: unknown } }
74
+ const id = typeof f.id === 'string' ? f.id.trim() : ''
75
+ let slot: Slot | undefined
76
+ if (typeof f.index === 'number' && Number.isInteger(f.index) && f.index >= 0) {
77
+ slot = this.slots.find(s => s.index === f.index)
78
+ if (!slot) slot = this.open(f.index)
79
+ } else if (id && this.slots.some(s => s.id === id)) {
80
+ slot = this.slots.find(s => s.id === id)
81
+ } else if (id || this.slots.length === 0) {
82
+ slot = this.open(this.slots.length ? Math.max(...this.slots.map(s => s.index)) + 1 : 0)
83
+ } else {
84
+ slot = this.slots[this.slots.length - 1]
85
+ }
86
+ if (!slot) return
87
+ if (id && !slot.id) slot.id = id
88
+ const name = f.function?.name
89
+ if (typeof name === 'string' && name) {
90
+ if (!slot.name) slot.name = name
91
+ else if (name !== slot.name) slot.name += name
92
+ if (slot.name.length > DIRECT_SSE_LIMITS.nameChars) this.overflow = 'tool name too long'
93
+ }
94
+ const args = f.function?.arguments
95
+ if (typeof args === 'string') slot.args += args
96
+ else if (args && typeof args === 'object') slot.args = JSON.stringify(args)
97
+ if (slot.args.length > DIRECT_SSE_LIMITS.argumentChars) this.overflow = 'tool arguments too long'
98
+ }
99
+
100
+ private open(index: number): Slot | undefined {
101
+ if (this.slots.length >= DIRECT_SSE_LIMITS.callsPerResponse) {
102
+ this.overflow = 'too many tool calls in one response'
103
+ return undefined
104
+ }
105
+ const slot: Slot = { index, id: '', name: '', args: '' }
106
+ this.slots.push(slot)
107
+ return slot
108
+ }
109
+
110
+ /** Why the calls cannot be trusted (a cap was passed), or null. */
111
+ error(): string | null {
112
+ return this.overflow
113
+ }
114
+
115
+ get size(): number {
116
+ return this.slots.length
117
+ }
118
+
119
+ /**
120
+ * The whole calls, in index order. A call the server sent without an id gets `<prefix>_<index>`
121
+ * so the assistant message and its tool result still pair up. A call with no name is dropped.
122
+ */
123
+ calls(): DirectToolCall[] {
124
+ return [...this.slots]
125
+ .sort((a, b) => a.index - b.index)
126
+ .filter(s => s.name.trim())
127
+ .map(s => ({
128
+ id: s.id || `${this.idPrefix}_${s.index}`,
129
+ type: 'function' as const,
130
+ function: { name: s.name.trim(), arguments: s.args.trim() ? s.args : '{}' },
131
+ }))
132
+ }
133
+ }
134
+
135
+ /**
136
+ * One chat-completions response stream. Feed it bytes with `push`, read the text, error and done
137
+ * events it returns, and after the stream ends read `tools`, `usage`, `finishReason` and
138
+ * `generationId`.
139
+ */
140
+ export class DirectSseParser {
141
+ private readonly decoder = new TextDecoder()
142
+ private buffer = ''
143
+ private ended = false
144
+ readonly tools: DirectToolCallAccumulator
145
+ usage: DirectSseUsage | null = null
146
+ finishReason: string | null = null
147
+ generationId: string | null = null
148
+ /** True once `data: [DONE]` was seen. */
149
+ sawDone = false
150
+
151
+ constructor(idPrefix: string) {
152
+ this.tools = new DirectToolCallAccumulator(idPrefix)
153
+ }
154
+
155
+ push(chunk: Uint8Array | string): DirectSseEvent[] {
156
+ if (this.ended) return []
157
+ this.buffer += typeof chunk === 'string' ? chunk : this.decoder.decode(chunk, { stream: true })
158
+ const events: DirectSseEvent[] = []
159
+ for (;;) {
160
+ const m = /\r?\n\r?\n/.exec(this.buffer)
161
+ if (!m) break
162
+ const raw = this.buffer.slice(0, m.index)
163
+ this.buffer = this.buffer.slice(m.index + m[0].length)
164
+ this.onEvent(raw, events)
165
+ if (this.ended) return events
166
+ }
167
+ if (this.buffer.length > DIRECT_SSE_LIMITS.eventBufferChars) {
168
+ this.buffer = ''
169
+ this.ended = true
170
+ events.push({ type: 'error', message: 'stream event too large' })
171
+ }
172
+ return events
173
+ }
174
+
175
+ /** The stream closed: parse a final event that had no blank-line terminator. */
176
+ finish(): DirectSseEvent[] {
177
+ if (this.ended) return []
178
+ const events: DirectSseEvent[] = []
179
+ const tail = this.buffer + this.decoder.decode()
180
+ this.buffer = ''
181
+ if (tail.trim()) this.onEvent(tail, events)
182
+ this.ended = true
183
+ return events
184
+ }
185
+
186
+ private onEvent(raw: string, events: DirectSseEvent[]): void {
187
+ const data = raw.split(/\r?\n/)
188
+ .filter(line => line.startsWith('data:'))
189
+ .map(line => line.slice(5).replace(/^ /, ''))
190
+ .join('\n')
191
+ // Comments (`: OPENROUTER PROCESSING`), `event:` and `id:` lines carry nothing we need.
192
+ if (!data.trim()) return
193
+ if (data.trim() === '[DONE]') {
194
+ this.sawDone = true
195
+ this.ended = true
196
+ events.push({ type: 'done' })
197
+ return
198
+ }
199
+ let chunk: Record<string, unknown>
200
+ try {
201
+ chunk = JSON.parse(data) as Record<string, unknown>
202
+ } catch {
203
+ return
204
+ }
205
+ if (!this.generationId && typeof chunk.id === 'string' && chunk.id) this.generationId = chunk.id
206
+ const err = chunk.error as { message?: unknown; code?: unknown } | undefined
207
+ if (err && typeof err === 'object') {
208
+ const message = typeof err.message === 'string' && err.message.trim() ? err.message.trim().slice(0, 240) : 'upstream error'
209
+ events.push({ type: 'error', message, ...(typeof err.code === 'string' || typeof err.code === 'number' ? { code: err.code } : {}) })
210
+ this.ended = true
211
+ return
212
+ }
213
+ const usage = chunk.usage as Record<string, unknown> | undefined
214
+ if (usage && typeof usage === 'object') {
215
+ const cost = usage.cost
216
+ this.usage = {
217
+ input: nonNegative(usage.prompt_tokens),
218
+ output: nonNegative(usage.completion_tokens),
219
+ ...(typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? { costUsd: cost } : {}),
220
+ }
221
+ }
222
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : []
223
+ for (const choice of choices) {
224
+ if (!choice || typeof choice !== 'object') continue
225
+ const c = choice as { index?: unknown; delta?: { content?: unknown; tool_calls?: unknown }; finish_reason?: unknown }
226
+ // One answer per request: only choice 0 is read.
227
+ if (typeof c.index === 'number' && c.index !== 0) continue
228
+ const content = c.delta?.content
229
+ if (typeof content === 'string' && content) events.push({ type: 'text', text: content })
230
+ if (Array.isArray(c.delta?.tool_calls)) for (const fragment of c.delta!.tool_calls as unknown[]) this.tools.add(fragment)
231
+ if (typeof c.finish_reason === 'string' && c.finish_reason) {
232
+ this.finishReason = c.finish_reason
233
+ if (c.finish_reason === 'error') {
234
+ events.push({ type: 'error', message: 'upstream finished with an error' })
235
+ this.ended = true
236
+ return
237
+ }
238
+ }
239
+ }
240
+ const toolError = this.tools.error()
241
+ if (toolError) {
242
+ events.push({ type: 'error', message: toolError })
243
+ this.ended = true
244
+ }
245
+ }
246
+ }
247
+
248
+ /** A non-stream JSON body (a server that ignored `stream: true`), read into the same shape. */
249
+ export function parseDirectJsonBody(text: string, idPrefix: string): { parser: DirectSseParser; events: DirectSseEvent[] } {
250
+ const parser = new DirectSseParser(idPrefix)
251
+ const events: DirectSseEvent[] = []
252
+ try {
253
+ const body = JSON.parse(text) as { id?: unknown; usage?: unknown; error?: unknown; choices?: Array<{ message?: { content?: unknown; tool_calls?: unknown }; finish_reason?: unknown }> }
254
+ const choices = (body.choices ?? []).map((c, index) => ({
255
+ index,
256
+ delta: { content: c?.message?.content, tool_calls: Array.isArray(c?.message?.tool_calls) ? (c.message!.tool_calls as unknown[]).map((t, i) => ({ index: i, ...(t as object) })) : undefined },
257
+ finish_reason: c?.finish_reason,
258
+ }))
259
+ events.push(...parser.push(`data: ${JSON.stringify({ id: body.id, usage: body.usage, error: body.error, choices })}\n\n`))
260
+ } catch { /* not JSON: an empty parser, the caller reports an empty response */ }
261
+ return { parser, events }
262
+ }
@@ -87,6 +87,8 @@ import {
87
87
  import { findBannedPermissionArg } from './banned-permission-args.js'
88
88
  import { PROVIDER_AUTH_RE, PROVIDER_OVERFLOW_RE, PROVIDER_QUOTA_RE } from './provider-failure-text.js'
89
89
  import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnavailable, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
90
+ import { anyKeyedSessions, isKeyedSession } from './keyed-sessions.js'
91
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
90
92
 
91
93
  /**
92
94
  * Providers that can be forked.
@@ -134,6 +136,8 @@ export type ForkFailure =
134
136
  /** The source thread changed while we forked it. The one unforgivable outcome. */
135
137
  | 'source_thread_mutated'
136
138
  | 'fork_internal_error'
139
+ /** Key lanes K1 (plan v4 7.3): the source thread ran on a key. Refused before any child. */
140
+ | 'keyed_session'
137
141
 
138
142
  /**
139
143
  * How much of a new thread may exist on the user's Mac as a result of this call.
@@ -314,6 +318,7 @@ export function buildClaudeForkArgs(nativeThreadId: string): string[] {
314
318
  '--fork-session',
315
319
  '--permission-mode', 'plan',
316
320
  '--allowedTools', '',
321
+ ...claudeKeySecretsGuardArgs(),
317
322
  ]
318
323
  }
319
324
 
@@ -647,6 +652,12 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
647
652
  }
648
653
  const sourceNativeThreadId: string = request.nativeThreadId
649
654
 
655
+ // Key lanes K1 (plan v4 7.3): a thread that ran on a key is not forked onto the subscription.
656
+ // One failed stat when no keyed session was ever recorded.
657
+ if (anyKeyedSessions() && isKeyedSession(sourceNativeThreadId)) {
658
+ return fail('keyed_session', 'none', { provider, sourceNativeThreadId, durationMs: duration() })
659
+ }
660
+
650
661
  const base = { provider, sourceNativeThreadId }
651
662
 
652
663
  if (!isForkPermissionPolicy(request.policy)) {
@@ -0,0 +1,49 @@
1
+ // Key lanes K1, P11 (plan v4 3.1): the additive fields a keyed answer carries, at the top level
2
+ // where the app reads them: `lane`, `credential` (kind, ref, profile id, never a secret),
3
+ // `profile`, and `usd`. On the live `done` event (keyAnswerDoneFields) and on the durable job
4
+ // record (keyAnswerJobFields), so a recovered answer shows its cost too. A subscription answer has
5
+ // no `keyLane` in its metadata, so both return {} and the payload and the record stay byte-identical.
6
+ import type { AnswerKeyFields, KeyKind, KeyLane } from './key-relay-contract.js'
7
+ import { KEY_KINDS, KEY_REF_RE } from './key-relay-contract.js'
8
+ import { isKeyProfileId } from '../../shared/key-preference.js'
9
+
10
+ export function keyAnswerDoneFields(metadata: { keyLane?: AnswerKeyFields } | undefined): Record<string, unknown> {
11
+ const keyLane = metadata?.keyLane
12
+ if (!keyLane) return {}
13
+ return { lane: keyLane.lane, credential: keyLane.credential, profile: keyLane.credential.profileId }
14
+ }
15
+
16
+ /** What a durable job record keeps for a keyed answer. */
17
+ export interface QueryJobKeyAnswerFields {
18
+ lane?: KeyLane
19
+ credential?: { kind: KeyKind; ref: string; profileId: string }
20
+ profile?: string
21
+ usd?: number
22
+ }
23
+
24
+ /**
25
+ * The validated key fields of a completion or a replayed journal patch, or {} when any part is
26
+ * missing or malformed. Built only from the bridge's metadata on a keyed run.
27
+ */
28
+ export function keyAnswerJobFields(input: unknown): QueryJobKeyAnswerFields {
29
+ if (!input || typeof input !== 'object') return {}
30
+ const x = input as { lane?: unknown; credential?: unknown; profile?: unknown; usd?: unknown }
31
+ if (x.lane !== 'key' && x.lane !== 'direct') return {}
32
+ const c = x.credential as { kind?: unknown; ref?: unknown; profileId?: unknown } | undefined
33
+ if (!c || typeof c !== 'object') return {}
34
+ if (!KEY_KINDS.includes(c.kind as KeyKind) || typeof c.ref !== 'string' || !KEY_REF_RE.test(c.ref) || !isKeyProfileId(c.profileId)) return {}
35
+ if (x.profile !== c.profileId) return {}
36
+ const usd = typeof x.usd === 'number' && Number.isFinite(x.usd) && x.usd >= 0 ? Math.round(x.usd * 1e6) / 1e6 : undefined
37
+ return {
38
+ lane: x.lane,
39
+ credential: { kind: c.kind as KeyKind, ref: c.ref, profileId: c.profileId },
40
+ profile: c.profileId,
41
+ ...(usd !== undefined ? { usd } : {}),
42
+ }
43
+ }
44
+
45
+ /** The completion fields a durable runtime passes for a keyed run: done fields plus usd. */
46
+ export function keyAnswerCompletionFields(metadata: { keyLane?: AnswerKeyFields; usd?: number } | undefined): QueryJobKeyAnswerFields {
47
+ if (!metadata?.keyLane) return {}
48
+ return keyAnswerJobFields({ ...keyAnswerDoneFields(metadata), usd: metadata.usd })
49
+ }
@@ -0,0 +1,301 @@
1
+ // Key lanes K1 P5 (plan v4 10.4, D10): the model catalog for key profiles.
2
+ //
3
+ // Source: OpenRouter's PUBLIC `GET /api/v1/models` (no key, never a key header), cached in
4
+ // memory for 24 hours. Nothing is written to disk and nothing is fetched at import: the first
5
+ // fetch happens only when a caller asks (the catalog route, or a direct turn refreshing prices in
6
+ // the background). With key lanes off nobody asks, so this module does nothing.
7
+ //
8
+ // Curated list (D10): Claude Sonnet 5.5 via OpenRouter is the default, Claude Haiku 5.5 is the
9
+ // one cheaper option. Their built-in prices are the plan's; a live catalog row replaces them.
10
+ // "Downselect" turns the full catalog into at most 20 picker rows of at most 64 characters (the
11
+ // lens list budget), curated first, every row carrying the lanes it can run on (plan 4.4: Lane B
12
+ // takes only `anthropic/` and `openai/` slugs; everything else is direct only).
13
+ //
14
+ // Tests never touch the network: the URL and fetch are injectable and the suite points them at a
15
+ // loopback mock server.
16
+
17
+ export const KEY_CATALOG_URL = 'https://openrouter.ai/api/v1/models'
18
+ export const KEY_CATALOG_TTL_MS = 24 * 60 * 60 * 1000
19
+ export const KEY_CATALOG_TIMEOUT_MS = 8_000
20
+ /** A catalog body bigger than this is refused rather than parsed (today's is about 1 MB). */
21
+ export const KEY_CATALOG_MAX_BYTES = 16 * 1024 * 1024
22
+ /** Lens list budget (plan 10.4): 20 rows of 64 characters. */
23
+ export const KEY_CATALOG_MAX_ROWS = 20
24
+ export const KEY_CATALOG_ROW_CHARS = 64
25
+
26
+ export const DEFAULT_KEY_MODEL = 'anthropic/claude-sonnet-5.5'
27
+ export const CHEAPER_KEY_MODEL = 'anthropic/claude-haiku-5.5'
28
+
29
+ export interface KeyCatalogModel {
30
+ id: string
31
+ name: string
32
+ inUsdPerM: number
33
+ outUsdPerM: number
34
+ contextLength?: number
35
+ /** From `supported_parameters`; undefined when the catalog does not say. */
36
+ supportsTools?: boolean
37
+ }
38
+
39
+ export type CuratedRole = 'default' | 'cheaper'
40
+ export interface CuratedKeyModel extends KeyCatalogModel {
41
+ role: CuratedRole
42
+ }
43
+
44
+ /** D10. Prices are plan 10.4's and are replaced by the live catalog row when one is cached. */
45
+ export const KEY_CATALOG_CURATED: readonly CuratedKeyModel[] = [
46
+ { id: DEFAULT_KEY_MODEL, name: 'Claude Sonnet 5.5', inUsdPerM: 2, outUsdPerM: 10, supportsTools: true, role: 'default' },
47
+ { id: CHEAPER_KEY_MODEL, name: 'Claude Haiku 5.5', inUsdPerM: 0.1, outUsdPerM: 0.5, supportsTools: true, role: 'cheaper' },
48
+ ]
49
+
50
+ export type KeyCatalogLane = 'key' | 'direct'
51
+
52
+ /** Plan 4.4: which lanes a slug can run on. */
53
+ export function catalogLanesFor(id: string): KeyCatalogLane[] {
54
+ return id.startsWith('anthropic/') || id.startsWith('openai/') ? ['key', 'direct'] : ['direct']
55
+ }
56
+
57
+ function perMillion(raw: unknown): number | null {
58
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : NaN
59
+ if (!Number.isFinite(n) || n < 0) return null
60
+ return Math.round(n * 1e6 * 1e6) / 1e6
61
+ }
62
+
63
+ /**
64
+ * Parse an OpenRouter `/models` body. Rows without an id or a usable price are dropped, as are
65
+ * rows that do not output text (image-only models cannot answer on the lens).
66
+ */
67
+ export function parseKeyCatalog(body: unknown): KeyCatalogModel[] {
68
+ const rows = body && typeof body === 'object' && Array.isArray((body as { data?: unknown }).data)
69
+ ? (body as { data: unknown[] }).data
70
+ : []
71
+ const out: KeyCatalogModel[] = []
72
+ const seen = new Set<string>()
73
+ for (const raw of rows) {
74
+ if (!raw || typeof raw !== 'object') continue
75
+ const m = raw as {
76
+ id?: unknown; name?: unknown; context_length?: unknown
77
+ pricing?: { prompt?: unknown; completion?: unknown }
78
+ architecture?: { output_modalities?: unknown }
79
+ supported_parameters?: unknown
80
+ }
81
+ if (typeof m.id !== 'string' || !m.id.trim() || m.id.length > 200 || seen.has(m.id)) continue
82
+ const inUsdPerM = perMillion(m.pricing?.prompt)
83
+ const outUsdPerM = perMillion(m.pricing?.completion)
84
+ if (inUsdPerM === null || outUsdPerM === null) continue
85
+ const outputs = m.architecture?.output_modalities
86
+ if (Array.isArray(outputs) && !outputs.includes('text')) continue
87
+ seen.add(m.id)
88
+ out.push({
89
+ id: m.id,
90
+ name: typeof m.name === 'string' && m.name.trim() ? m.name.trim() : m.id,
91
+ inUsdPerM,
92
+ outUsdPerM,
93
+ ...(typeof m.context_length === 'number' && Number.isFinite(m.context_length) ? { contextLength: m.context_length } : {}),
94
+ ...(Array.isArray(m.supported_parameters) ? { supportsTools: m.supported_parameters.includes('tools') } : {}),
95
+ })
96
+ }
97
+ return out
98
+ }
99
+
100
+ export interface KeyCatalogOptions {
101
+ url?: string
102
+ fetchImpl?: typeof fetch
103
+ now?: () => number
104
+ ttlMs?: number
105
+ timeoutMs?: number
106
+ }
107
+
108
+ export interface KeyCatalogSnapshot {
109
+ models: KeyCatalogModel[]
110
+ /** `openrouter` when a live catalog is cached, `built-in` when only the curated list is known. */
111
+ from: 'openrouter' | 'built-in'
112
+ fetchedAt?: number
113
+ }
114
+
115
+ /** A 24-hour in-memory cache of the public catalog. One fetch in flight at a time. */
116
+ export class KeyCatalog {
117
+ private readonly url: string
118
+ private readonly fetchImpl: typeof fetch
119
+ private readonly now: () => number
120
+ private readonly ttlMs: number
121
+ private readonly timeoutMs: number
122
+ private cached: { at: number; models: KeyCatalogModel[] } | null = null
123
+ private inflight: Promise<KeyCatalogModel[]> | null = null
124
+
125
+ constructor(options: KeyCatalogOptions = {}) {
126
+ this.url = options.url ?? KEY_CATALOG_URL
127
+ this.fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args))
128
+ this.now = options.now ?? Date.now
129
+ this.ttlMs = options.ttlMs ?? KEY_CATALOG_TTL_MS
130
+ this.timeoutMs = options.timeoutMs ?? KEY_CATALOG_TIMEOUT_MS
131
+ }
132
+
133
+ /** The cached models while fresh, else null. Never fetches. */
134
+ peek(): KeyCatalogModel[] | null {
135
+ if (!this.cached || this.now() - this.cached.at >= this.ttlMs) return null
136
+ return this.cached.models
137
+ }
138
+
139
+ /** Fresh cache, or one fetch. A failed fetch keeps any stale copy and reports built-in when none. */
140
+ async get(): Promise<KeyCatalogSnapshot> {
141
+ const fresh = this.peek()
142
+ if (fresh) return { models: fresh, from: 'openrouter', fetchedAt: this.cached!.at }
143
+ try {
144
+ const models = await this.fetchOnce()
145
+ return { models, from: 'openrouter', fetchedAt: this.cached!.at }
146
+ } catch {
147
+ if (this.cached) return { models: this.cached.models, from: 'openrouter', fetchedAt: this.cached.at }
148
+ return { models: [], from: 'built-in' }
149
+ }
150
+ }
151
+
152
+ /** Start a refresh when stale and do not wait for it. Errors are swallowed (prices fall back). */
153
+ refreshInBackground(): void {
154
+ if (this.peek() || this.inflight) return
155
+ void this.fetchOnce().catch(() => undefined)
156
+ }
157
+
158
+ private fetchOnce(): Promise<KeyCatalogModel[]> {
159
+ if (this.inflight) return this.inflight
160
+ this.inflight = (async () => {
161
+ try {
162
+ // Public endpoint: no Authorization header, ever. Redirects are refused so the request
163
+ // cannot be bounced somewhere else.
164
+ const response = await this.fetchImpl(this.url, {
165
+ method: 'GET',
166
+ headers: { accept: 'application/json' },
167
+ redirect: 'error',
168
+ signal: AbortSignal.timeout(this.timeoutMs),
169
+ })
170
+ if (!response.ok) throw new Error(`catalog HTTP ${response.status}`)
171
+ const length = Number(response.headers.get('content-length') ?? '0')
172
+ if (length > KEY_CATALOG_MAX_BYTES) throw new Error('catalog too large')
173
+ const text = await response.text()
174
+ if (text.length > KEY_CATALOG_MAX_BYTES) throw new Error('catalog too large')
175
+ const models = parseKeyCatalog(JSON.parse(text))
176
+ if (models.length === 0) throw new Error('catalog empty')
177
+ this.cached = { at: this.now(), models }
178
+ return models
179
+ } finally {
180
+ this.inflight = null
181
+ }
182
+ })()
183
+ return this.inflight
184
+ }
185
+ }
186
+
187
+ let shared: KeyCatalog | null = null
188
+
189
+ /** The process catalog, created on first use (never at import). */
190
+ export function getKeyCatalog(): KeyCatalog {
191
+ if (!shared) shared = new KeyCatalog()
192
+ return shared
193
+ }
194
+
195
+ /** Tests only. */
196
+ export function __setKeyCatalogForTests(catalog: KeyCatalog | null): void {
197
+ shared = catalog
198
+ }
199
+
200
+ /** The curated two with live prices (and tool support) when the catalog has them. */
201
+ export function curatedKeyModels(models: readonly KeyCatalogModel[] | null): CuratedKeyModel[] {
202
+ const live = new Map((models ?? []).map(m => [m.id, m]))
203
+ return KEY_CATALOG_CURATED.map(c => {
204
+ const m = live.get(c.id)
205
+ return m
206
+ ? { ...c, inUsdPerM: m.inUsdPerM, outUsdPerM: m.outUsdPerM, ...(m.supportsTools !== undefined ? { supportsTools: m.supportsTools } : {}), ...(m.contextLength ? { contextLength: m.contextLength } : {}) }
207
+ : { ...c }
208
+ })
209
+ }
210
+
211
+ export interface ModelPriceInfo {
212
+ inUsdPerM: number
213
+ outUsdPerM: number
214
+ from: 'openrouter' | 'built-in' | 'unknown'
215
+ }
216
+
217
+ /** Price for one slug: live row, else curated, else unknown (the caller decides the fallback). */
218
+ export function keyModelPrice(id: string, models: readonly KeyCatalogModel[] | null): ModelPriceInfo {
219
+ const live = models?.find(m => m.id === id)
220
+ if (live) return { inUsdPerM: live.inUsdPerM, outUsdPerM: live.outUsdPerM, from: 'openrouter' }
221
+ const curated = KEY_CATALOG_CURATED.find(m => m.id === id)
222
+ if (curated) return { inUsdPerM: curated.inUsdPerM, outUsdPerM: curated.outUsdPerM, from: 'built-in' }
223
+ return { inUsdPerM: 0, outUsdPerM: 0, from: 'unknown' }
224
+ }
225
+
226
+ /** True or false when the catalog (or the curated list) says; undefined when nobody knows. */
227
+ export function keyModelSupportsTools(id: string, models: readonly KeyCatalogModel[] | null): boolean | undefined {
228
+ const live = models?.find(m => m.id === id)
229
+ if (live && live.supportsTools !== undefined) return live.supportsTools
230
+ return KEY_CATALOG_CURATED.find(m => m.id === id)?.supportsTools
231
+ }
232
+
233
+ export interface KeyCatalogRow {
234
+ id: string
235
+ /** One lens row: name and price per million, at most KEY_CATALOG_ROW_CHARS characters. */
236
+ label: string
237
+ inUsdPerM: number
238
+ outUsdPerM: number
239
+ lanes: KeyCatalogLane[]
240
+ role?: CuratedRole
241
+ }
242
+
243
+ /** Glyphs the lens font lacks (brief rule 8), em and en dash, and control characters. */
244
+ const LENS_UNSAFE = /[\u0000-\u001f\u007f-\u009f–—←-⇿♦✓⚠↻↺⌛⋯░▓⬡`]/gu
245
+
246
+ function lensRow(text: string, max = KEY_CATALOG_ROW_CHARS): string {
247
+ const clean = text.replace(LENS_UNSAFE, ' ').replace(/\s+/g, ' ').trim()
248
+ const glyphs = [...clean]
249
+ if (glyphs.length <= max) return clean
250
+ return `${glyphs.slice(0, max - 1).join('').trimEnd()}…`
251
+ }
252
+
253
+ function money(n: number): string {
254
+ if (n === 0) return '$0'
255
+ if (n < 0.01) return `$${n.toFixed(4).replace(/0+$/, '')}`
256
+ return `$${n < 1 ? n.toFixed(2) : Number(n.toFixed(2)).toString()}`
257
+ }
258
+
259
+ /** `Claude Sonnet 5.5 | $2/$10 per M`, cut to one lens row. The price suffix is kept whole. */
260
+ export function catalogRowLabel(m: Pick<KeyCatalogModel, 'name' | 'inUsdPerM' | 'outUsdPerM'>): string {
261
+ const price = ` | ${money(m.inUsdPerM)}/${money(m.outUsdPerM)} per M`
262
+ const room = KEY_CATALOG_ROW_CHARS - [...price].length
263
+ return lensRow(`${lensRow(m.name, Math.max(8, room))}${price}`)
264
+ }
265
+
266
+ export interface DownselectOptions {
267
+ query?: string
268
+ /** Only models that can run on this lane. */
269
+ lane?: KeyCatalogLane
270
+ /** Only models the catalog says can call tools. */
271
+ toolsOnly?: boolean
272
+ limit?: number
273
+ }
274
+
275
+ /**
276
+ * The picker list: curated first (with live prices), then catalog matches for `query` in the
277
+ * catalog's own order, de-duplicated, at most `limit` (20) rows of at most 64 characters.
278
+ * Without a query only the curated rows come back (plan 10.4: "More models" is a search).
279
+ */
280
+ export function downselectKeyCatalog(models: readonly KeyCatalogModel[] | null, options: DownselectOptions = {}): KeyCatalogRow[] {
281
+ const limit = Math.max(0, Math.min(KEY_CATALOG_MAX_ROWS, Math.floor(options.limit ?? KEY_CATALOG_MAX_ROWS)))
282
+ const q = (options.query ?? '').trim().toLowerCase().slice(0, 40)
283
+ const keep = (m: KeyCatalogModel) =>
284
+ (!options.lane || catalogLanesFor(m.id).includes(options.lane))
285
+ && (!options.toolsOnly || m.supportsTools === true)
286
+ const rows: KeyCatalogRow[] = []
287
+ const seen = new Set<string>()
288
+ const push = (m: KeyCatalogModel, role?: CuratedRole) => {
289
+ if (rows.length >= limit || seen.has(m.id) || !keep(m)) return
290
+ seen.add(m.id)
291
+ rows.push({ id: m.id, label: catalogRowLabel(m), inUsdPerM: m.inUsdPerM, outUsdPerM: m.outUsdPerM, lanes: catalogLanesFor(m.id), ...(role ? { role } : {}) })
292
+ }
293
+ for (const c of curatedKeyModels(models)) push(c, c.role)
294
+ if (q && models) {
295
+ for (const m of models) {
296
+ if (rows.length >= limit) break
297
+ if (m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) push(m)
298
+ }
299
+ }
300
+ return rows
301
+ }
@@ -0,0 +1,12 @@
1
+ // Key lanes K1, P11: the one price function the relay meter and the direct lane share. Live
2
+ // OpenRouter catalog prices when P5's 24 h cache has them (never fetched from here), else the
3
+ // curated price, else P2's fallback (the most expensive Claude tier, so an unknown price can only
4
+ // make the cap stricter). Pure apart from reading the in-memory catalog cache.
5
+ import { getKeyCatalog, keyModelPrice } from './key-catalog.js'
6
+ import { FALLBACK_PRICE, type ModelPrice } from './key-relay-meter.js'
7
+
8
+ export function keyCatalogPeekPrice(model: string): ModelPrice {
9
+ const info = keyModelPrice(model, getKeyCatalog().peek())
10
+ if (info.from === 'unknown') return FALLBACK_PRICE
11
+ return { inUsdPerM: info.inUsdPerM, outUsdPerM: info.outUsdPerM }
12
+ }