@gotcos/glasses-server 6.79.2 → 6.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- 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/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/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/openai-compat.ts +28 -3
- 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
|
+
}
|
|
@@ -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
|
})
|
package/server/routes/query.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { errMsg } from '../lib/utils.js'
|
|
|
8
8
|
import {
|
|
9
9
|
isCursorModel,
|
|
10
10
|
normalizeEffortPreference,
|
|
11
|
-
normalizeModelPreference,
|
|
12
11
|
} from '../../shared/model-preference.js'
|
|
12
|
+
import { parsePreference } from '../../shared/key-preference.js'
|
|
13
|
+
import type { KeyProfileTurn } from '../lib/key-profile-routing.js'
|
|
13
14
|
import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
|
|
14
15
|
import { getMediaStore } from '../lib/media-store.js'
|
|
15
16
|
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
maintenanceErrorPayload,
|
|
20
21
|
} from '../lib/maintenance-lifecycle.js'
|
|
21
22
|
import { currentMessageEra, LEGACY_MESSAGE_ERA } from '../lib/message-era.js'
|
|
23
|
+
import { keyAnswerDoneFields } from '../lib/key-answer-fields.js'
|
|
24
|
+
import { isProfilePreference } from '../../shared/key-preference.js'
|
|
22
25
|
|
|
23
26
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
24
27
|
WebSearch: 'Searching web...',
|
|
@@ -92,8 +95,11 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
92
95
|
return res.status(400).json({ error: 'query string or attachment required' })
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
// Validate model if provided
|
|
96
|
-
|
|
98
|
+
// Validate model if provided. Key lanes K1 (plan 5.2): Accept. A `profile:<id>` is passed to
|
|
99
|
+
// model-router as is (it runs on the profile or is refused there); every family guard below
|
|
100
|
+
// reads the closed slot, so a model slot behaves exactly as before.
|
|
101
|
+
const requestedBinding = parsePreference(model)
|
|
102
|
+
const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
|
|
97
103
|
const validEffort = normalizeEffortPreference(effort)
|
|
98
104
|
|
|
99
105
|
// Validate globalMsgNum if provided
|
|
@@ -136,12 +142,17 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
136
142
|
done = true
|
|
137
143
|
})
|
|
138
144
|
|
|
145
|
+
// Set only by model-router's onKeyProfileTurn: the binding is the turn's model on the stream.
|
|
146
|
+
let keyTurn: KeyProfileTurn | undefined
|
|
139
147
|
try {
|
|
140
148
|
const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
|
|
149
|
+
...(requestedBinding?.kind === 'model' ? {} : {
|
|
150
|
+
onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
|
|
151
|
+
}),
|
|
141
152
|
onStart: (model, sid, cliSessionId, metadata) => {
|
|
142
153
|
streamSessionId = sid
|
|
143
154
|
if (!done) {
|
|
144
|
-
const payload = { model, sessionId: sid, cliSessionId, ...metadata }
|
|
155
|
+
const payload = { model: keyTurn ? keyTurn.binding : model, sessionId: sid, cliSessionId, ...metadata }
|
|
145
156
|
res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
146
157
|
emitDisplay({ type: 'start', data: payload })
|
|
147
158
|
}
|
|
@@ -192,7 +203,8 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
192
203
|
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
193
204
|
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
194
205
|
const payload = {
|
|
195
|
-
text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
|
|
206
|
+
text: fullText, sessionId: streamSessionId, model: keyTurn ? keyTurn.binding : model, cliSessionId, ...runMetadata,
|
|
207
|
+
...keyAnswerDoneFields(runMetadata),
|
|
196
208
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
197
209
|
}
|
|
198
210
|
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
@@ -215,7 +227,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
215
227
|
maintenanceLease.release()
|
|
216
228
|
}
|
|
217
229
|
},
|
|
218
|
-
}, validModel, imageInputs,
|
|
230
|
+
}, requestedBinding?.kind === 'profile' ? model as KeyProfileTurn['binding'] : validModel, imageInputs,
|
|
219
231
|
// Pass reference if provided (for "recall message N" feature)
|
|
220
232
|
reference && typeof reference === 'object' && reference.query && reference.response
|
|
221
233
|
? { query: String(reference.query), response: String(reference.response) }
|
|
@@ -228,6 +240,8 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
228
240
|
requestAttachments: attachmentRefs,
|
|
229
241
|
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
230
242
|
...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
|
|
243
|
+
// QA U9: the one-turn plan-limit offer runs on the key without binding the session.
|
|
244
|
+
...(req.body.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true } : {}),
|
|
231
245
|
},
|
|
232
246
|
)
|
|
233
247
|
} catch (err: unknown) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Key lanes (K1, plan v4 section 5.1): the ONE parser that may read a key profile out of a model
|
|
2
|
+
// preference. `isModelPreference` and `normalizeModelPreference` (model-preference.ts) stay
|
|
3
|
+
// closed and unchanged, so every site that does not call parsePreference keeps refusing a
|
|
4
|
+
// profile exactly as 6.79.0 does. Call this only at the sites plan 5.2 marks Accept.
|
|
5
|
+
//
|
|
6
|
+
// Encoding: a profile-bound preference is `profile:<id>`. The prefix keeps a profile id from ever
|
|
7
|
+
// colliding with a model slot name (`opus` is a valid id shape). On `/v1` a profile is offered as
|
|
8
|
+
// `cos-key-<id>` (plan 9.4), handled before `resolveModel` and only for profiles that opt in.
|
|
9
|
+
import { normalizeModelPreference, type ModelPreference } from './model-preference.js'
|
|
10
|
+
|
|
11
|
+
export const KEY_PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
12
|
+
export const KEY_PROFILE_PREFERENCE_PREFIX = 'profile:'
|
|
13
|
+
export const V1_KEY_MODEL_PREFIX = 'cos-key-'
|
|
14
|
+
|
|
15
|
+
export type ParsedPreference =
|
|
16
|
+
| { kind: 'model'; value: ModelPreference }
|
|
17
|
+
| { kind: 'profile'; id: string }
|
|
18
|
+
|
|
19
|
+
export function isKeyProfileId(value: unknown): value is string {
|
|
20
|
+
return typeof value === 'string' && KEY_PROFILE_ID_RE.test(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `profile:<id>`, or throws on a malformed id (a caller bug, never user input). */
|
|
24
|
+
export function profilePreference(id: string): string {
|
|
25
|
+
if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
|
|
26
|
+
return KEY_PROFILE_PREFERENCE_PREFIX + id
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A model slot (normalized exactly as normalizeModelPreference does, so `codex-high` still
|
|
31
|
+
* migrates), a `profile:<id>`, or undefined for anything else. Never throws.
|
|
32
|
+
*/
|
|
33
|
+
export function parsePreference(value: unknown): ParsedPreference | undefined {
|
|
34
|
+
const model = normalizeModelPreference(value)
|
|
35
|
+
if (model) return { kind: 'model', value: model }
|
|
36
|
+
if (typeof value !== 'string' || !value.startsWith(KEY_PROFILE_PREFERENCE_PREFIX)) return undefined
|
|
37
|
+
const id = value.slice(KEY_PROFILE_PREFERENCE_PREFIX.length)
|
|
38
|
+
return isKeyProfileId(id) ? { kind: 'profile', id } : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True only for a well-formed `profile:<id>`. A model slot is false. */
|
|
42
|
+
export function isProfilePreference(value: unknown): boolean {
|
|
43
|
+
return parsePreference(value)?.kind === 'profile'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/v1` model name for a profile (plan 9.4). */
|
|
47
|
+
export function v1KeyModelName(id: string): string {
|
|
48
|
+
if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
|
|
49
|
+
return V1_KEY_MODEL_PREFIX + id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The profile id in a `cos-key-<id>` `/v1` model name, or undefined. */
|
|
53
|
+
export function parseV1KeyModelName(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value !== 'string' || !value.startsWith(V1_KEY_MODEL_PREFIX)) return undefined
|
|
55
|
+
const id = value.slice(V1_KEY_MODEL_PREFIX.length)
|
|
56
|
+
return isKeyProfileId(id) ? id : undefined
|
|
57
|
+
}
|