@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,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
|
+
}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
LiveCuesArmError,
|
|
19
19
|
} from '../lib/live-cues-engine.js'
|
|
20
20
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
21
|
+
import { applyLiveCuesV2Settings, liveCuesV2SettingsViewV2 } from '../lib/live-cues-v2.js'
|
|
22
|
+
import { LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, limitInBounds, type LiveCuesV2StoredLimits } from '../lib/live-cues-v2-settings.js'
|
|
21
23
|
import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
22
24
|
import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
|
|
23
25
|
|
|
@@ -186,14 +188,23 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
|
|
|
186
188
|
// COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
|
|
187
189
|
// data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
|
|
188
190
|
// liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
|
|
189
|
-
|
|
191
|
+
//
|
|
192
|
+
// 6.79.2: COS Control's spend limits ride the same routes, opt-in by version so an older Control is answered byte for byte as
|
|
193
|
+
// before. GET ?v=2 and POST {v: 2, ...any of enabled, gate, maxCuesPerMeeting, cooldownSeconds, dailyWriterCalls} answer the
|
|
194
|
+
// v1 body plus {v: 2, limits, writerCallsPerMeeting, today}. A limit an env var sets is locked (409 env_locked). An older
|
|
195
|
+
// server ignores ?v=2 and answers without `v`, which is Control's signal to hide the limits.
|
|
196
|
+
liveCuesRouter.get('/live-cues/v2/settings', (req, res) => {
|
|
190
197
|
res.set('Cache-Control', 'private, no-store')
|
|
191
|
-
|
|
198
|
+
const v = req.query.v
|
|
199
|
+
if (v !== undefined && v !== '2') return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
|
|
200
|
+
try { return res.json(v === '2' ? liveCuesV2SettingsViewV2() : liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
|
|
192
201
|
})
|
|
193
202
|
|
|
203
|
+
const V2_SETTINGS_KEYS = new Set<string>(['v', 'enabled', ...LIVE_CUES_V2_LIMIT_NAMES])
|
|
194
204
|
liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
|
|
195
205
|
res.set('Cache-Control', 'private, no-store')
|
|
196
|
-
const body = req.body as { enabled?: unknown } | undefined
|
|
206
|
+
const body = req.body as { enabled?: unknown; v?: unknown } | undefined
|
|
207
|
+
if (body && typeof body === 'object' && !Array.isArray(body) && body.v !== undefined) return postSettingsV2(body as Record<string, unknown>, res)
|
|
197
208
|
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
|
|
198
209
|
return res.status(400).json({ error: 'invalid_request' })
|
|
199
210
|
}
|
|
@@ -205,3 +216,28 @@ liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
|
|
|
205
216
|
return res.status(500).json({ error: 'settings_write_failed' })
|
|
206
217
|
}
|
|
207
218
|
})
|
|
219
|
+
|
|
220
|
+
/** POST {v: 2, ...}: every field is checked before anything is saved, so a refused request changes nothing. */
|
|
221
|
+
function postSettingsV2(body: Record<string, unknown>, res: import('express').Response) {
|
|
222
|
+
if (body.v !== 2) return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
|
|
223
|
+
const unknown = Object.keys(body).find(k => !V2_SETTINGS_KEYS.has(k))
|
|
224
|
+
if (unknown) return res.status(400).json({ error: 'invalid_request', reason: 'unknown_field', field: unknown })
|
|
225
|
+
if (body.enabled !== undefined && typeof body.enabled !== 'boolean') return res.status(400).json({ error: 'invalid_request', reason: 'invalid_enabled', field: 'enabled' })
|
|
226
|
+
const limits: LiveCuesV2StoredLimits = {}
|
|
227
|
+
for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
|
|
228
|
+
if (body[name] === undefined) continue
|
|
229
|
+
if (!limitInBounds(name, body[name])) {
|
|
230
|
+
const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
|
|
231
|
+
return res.status(400).json({ error: 'invalid_request', reason: 'out_of_bounds', field: name, min: b.min, max: b.max, integer: b.integer })
|
|
232
|
+
}
|
|
233
|
+
limits[name] = body[name] as number
|
|
234
|
+
}
|
|
235
|
+
if (body.enabled === undefined && Object.keys(limits).length === 0) return res.status(400).json({ error: 'invalid_request', reason: 'empty' })
|
|
236
|
+
try {
|
|
237
|
+
const result = applyLiveCuesV2Settings({ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}), limits })
|
|
238
|
+
if (!result.ok) return res.status(409).json(result.error === 'env_locked' ? { error: 'env_locked', fields: result.fields } : { error: result.error })
|
|
239
|
+
return res.json(liveCuesV2SettingsViewV2())
|
|
240
|
+
} catch {
|
|
241
|
+
return res.status(500).json({ error: 'settings_write_failed' })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
})
|