@gotcos/glasses-server 6.79.1 → 6.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +98 -0
- package/README.md +20 -1
- package/managed-runtime-contract.json +6 -1
- package/package.json +9 -1
- package/server/index.ts +11 -0
- 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/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- 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/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- 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/live-cues.ts +39 -3
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/prompt-drafts.ts +75 -8
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- 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
|
+
}
|