@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,173 @@
|
|
|
1
|
+
// Key lanes K1, package P6 (plan v4 sections 5.2 and 9.4): how a model preference that names a
|
|
2
|
+
// key profile is routed, refused or ignored. One module so every Accept site resolves a profile
|
|
3
|
+
// the same way and every refusal reads the same on the lens.
|
|
4
|
+
//
|
|
5
|
+
// resolveKeyProfileTurn `profile:<id>` to a runnable turn, or a lens line saying why not.
|
|
6
|
+
// Never a silent subscription turn: an unknown, disabled or keyless
|
|
7
|
+
// profile, or key lanes switched off, is a refusal.
|
|
8
|
+
// registerKeyProfileEngine The seam P3 (claude), P4 (codex) and P5 (direct) register their
|
|
9
|
+
// keyed runner into. With no runner for an engine, the turn is refused.
|
|
10
|
+
// profileSteerProvider What a profile-bound job looks like to the steer gate (claude-steer
|
|
11
|
+
// returns `profile_session`; this decides which engine that means).
|
|
12
|
+
// v1KeyProfileFor `/v1` `cos-key-<id>` to a profile offered there, or a refusal.
|
|
13
|
+
//
|
|
14
|
+
// HARD RULE: nothing here runs for a preference that is not a well formed `profile:<id>` or
|
|
15
|
+
// `cos-key-<id>`. Every caller checks that syntax first (a pure string test, no file read), so a
|
|
16
|
+
// server with no profile takes exactly the 6.79.0 path. Importing this module reads no file,
|
|
17
|
+
// opens no socket and starts no timer. It imports only types from conversation.ts, so a test that
|
|
18
|
+
// loads it does not load the session store.
|
|
19
|
+
import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
|
|
20
|
+
import type { ModelImageInput } from './model-image-input.js'
|
|
21
|
+
import type { KeyKind, KeyProfile, KeyProfileEngine } from './key-relay-contract.js'
|
|
22
|
+
import type { QueryProvider } from './claude-steer.js'
|
|
23
|
+
import { keyLanesActive } from './key-lanes.js'
|
|
24
|
+
import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
|
|
25
|
+
import { getProfile, listProfiles } from './key-profiles.js'
|
|
26
|
+
import { getPublicKey } from './key-store.js'
|
|
27
|
+
import type { KeyProfileBinding, PromptReference } from './conversation.js'
|
|
28
|
+
import { normalizeModelPreference } from '../../shared/model-preference.js'
|
|
29
|
+
import { isProfilePreference, profilePreference } from '../../shared/key-preference.js'
|
|
30
|
+
|
|
31
|
+
/** A profile that may run now: lanes active, the profile enabled, its key saved. */
|
|
32
|
+
export interface KeyProfileTurn {
|
|
33
|
+
binding: KeyProfileBinding
|
|
34
|
+
profile: KeyProfile
|
|
35
|
+
keyKind: KeyKind
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type KeyProfileRefusalCode = 'key_profile_off' | 'key_profile_unavailable'
|
|
39
|
+
|
|
40
|
+
export type KeyProfileResolution =
|
|
41
|
+
| { ok: true; turn: KeyProfileTurn }
|
|
42
|
+
| { ok: false; code: KeyProfileRefusalCode; message: string }
|
|
43
|
+
|
|
44
|
+
// ── Lens copy (plan 11.3 "Profile off", 5.2 rejects). No em dashes, no arrows, one line each. ──
|
|
45
|
+
|
|
46
|
+
/** Plan 5.2 and 11.3: the one refusal for a profile that cannot run (missing, disabled, no key, lanes off). */
|
|
47
|
+
export function keyProfileOffLine(label: string): string {
|
|
48
|
+
return KEY_LANE_REFUSAL_COPY.profileOff(label)
|
|
49
|
+
}
|
|
50
|
+
/** No runner is registered for the profile's engine on this build. */
|
|
51
|
+
export function keyProfileUnavailableLine(label: string): string {
|
|
52
|
+
return KEY_LANE_REFUSAL_COPY.profileUnavailable(label)
|
|
53
|
+
}
|
|
54
|
+
/** Plan 9.4: a `cos-key-<id>` that is not an active profile with "Offer on /v1" on. */
|
|
55
|
+
export const KEY_PROFILE_V1_NOT_OFFERED = KEY_LANE_REFUSAL_COPY.v1NotOffered
|
|
56
|
+
|
|
57
|
+
function labelFor(id: string, profile: KeyProfile | undefined): string {
|
|
58
|
+
return profile?.label?.trim() || id
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve `profile:<id>`. Lanes off, an unknown id, a disabled profile or a profile whose key
|
|
63
|
+
* was removed all refuse with the same line (the user's fix is the same: pick a model).
|
|
64
|
+
*/
|
|
65
|
+
export function resolveKeyProfileTurn(id: string): KeyProfileResolution {
|
|
66
|
+
// Read only for its label when lanes are off: a stale binding still names the profile.
|
|
67
|
+
const profile = getProfile(id)
|
|
68
|
+
const off = (): KeyProfileResolution => ({ ok: false, code: 'key_profile_off', message: keyProfileOffLine(labelFor(id, profile)) })
|
|
69
|
+
if (!keyLanesActive()) return off()
|
|
70
|
+
if (!profile || !profile.enabled) return off()
|
|
71
|
+
const key = getPublicKey(profile.keyRef)
|
|
72
|
+
if (!key) return off()
|
|
73
|
+
return { ok: true, turn: { binding: profilePreference(profile.id) as KeyProfileBinding, profile, keyKind: key.kind } }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Engine seam (P3, P4, P5 register) ────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A keyed runner. Same arguments as the subscription bridges, plus the resolved turn. Contract:
|
|
80
|
+
* - call exactly one of callbacks.onDone / callbacks.onError (model-router's session run lock
|
|
81
|
+
* releases on that terminal callback);
|
|
82
|
+
* - stamp conversation exchanges with `turn.binding` (conversation accepts it by syntax);
|
|
83
|
+
* - the `model` it passes to onStart / onDone is ignored for linkage and display: callers read
|
|
84
|
+
* the binding from the `onKeyProfileTurn` callback below instead;
|
|
85
|
+
* - honor `options.steer` when present (only a `claude` engine profile is ever offered one).
|
|
86
|
+
*/
|
|
87
|
+
export type KeyProfileEngineRunner = (
|
|
88
|
+
query: string,
|
|
89
|
+
sessionId: string,
|
|
90
|
+
callbacks: StreamCallbacks,
|
|
91
|
+
turn: KeyProfileTurn,
|
|
92
|
+
images?: ModelImageInput[],
|
|
93
|
+
reference?: PromptReference,
|
|
94
|
+
globalMsgNum?: number,
|
|
95
|
+
options?: CallOptions,
|
|
96
|
+
) => Promise<string>
|
|
97
|
+
|
|
98
|
+
const engines = new Map<KeyProfileEngine, KeyProfileEngineRunner>()
|
|
99
|
+
|
|
100
|
+
/** Named by engine, so a re-registration replaces rather than stacks. */
|
|
101
|
+
export function registerKeyProfileEngine(engine: KeyProfileEngine, runner: KeyProfileEngineRunner): void {
|
|
102
|
+
engines.set(engine, runner)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function keyProfileEngineRunner(engine: KeyProfileEngine): KeyProfileEngineRunner | undefined {
|
|
106
|
+
return engines.get(engine)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Tests only. */
|
|
110
|
+
export function __resetKeyProfileEnginesForTests(): void {
|
|
111
|
+
engines.clear()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Optional callback a caller of callModelStreaming may add. model-router calls it once, before
|
|
116
|
+
* the keyed runner starts, and never for a subscription turn. The durable runtime and the SSE
|
|
117
|
+
* route use it to report the binding (not the runner's tier) as the turn's model.
|
|
118
|
+
*/
|
|
119
|
+
export interface KeyProfileTurnCallbacks {
|
|
120
|
+
onKeyProfileTurn?: (turn: KeyProfileTurn) => void
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Linkage and steer ────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export type KeyProfileLinkageProvider = 'claude' | 'codex' | undefined
|
|
126
|
+
|
|
127
|
+
/** The job linkage provider for an engine. Direct (Lane C) has no CLI session; it is omitted. */
|
|
128
|
+
export function linkageProviderForEngine(engine: KeyProfileEngine): KeyProfileLinkageProvider {
|
|
129
|
+
if (engine === 'claude') return 'claude'
|
|
130
|
+
if (engine === 'codex') return 'codex'
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* claude-steer's guard returned `profile_session`. Steerable (`claude`) only for an active,
|
|
136
|
+
* enabled Lane B Claude profile; anything else answers with a provider the coordinator never
|
|
137
|
+
* steers, so a Codex or direct profile, or a profile that would be refused, is `unsupported`.
|
|
138
|
+
*/
|
|
139
|
+
export function profileSteerProvider(id: string): QueryProvider {
|
|
140
|
+
const resolved = resolveKeyProfileTurn(id)
|
|
141
|
+
return resolved.ok && resolved.turn.profile.engine === 'claude' ? 'claude' : 'codex'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── /v1 (plan 9.4) ───────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Profiles `/v1` offers: lanes active, enabled, "Offer on /v1" on, key saved. Empty when inactive. */
|
|
147
|
+
export function v1OfferedProfiles(): KeyProfile[] {
|
|
148
|
+
if (!keyLanesActive()) return []
|
|
149
|
+
return listProfiles().filter(p => p.enabled && p.offerOnV1 && getPublicKey(p.keyRef) !== undefined)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The offered profile for a `cos-key-<id>` id, or undefined (the route refuses it). */
|
|
153
|
+
export function v1OfferedProfile(id: string): KeyProfile | undefined {
|
|
154
|
+
return v1OfferedProfiles().find(p => p.id === id)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── COS_G2_DEFAULT_MODEL (plan 5.2: closed) ──────────────────────────────────
|
|
158
|
+
|
|
159
|
+
let envProfileWarned = false
|
|
160
|
+
|
|
161
|
+
/** A profile id in COS_G2_DEFAULT_MODEL is ignored, with ONE warning per boot. Returns the closed default. */
|
|
162
|
+
export function closedEnvDefaultModel(raw: string | undefined = process.env.COS_G2_DEFAULT_MODEL) {
|
|
163
|
+
if (!envProfileWarned && isProfilePreference(raw?.trim())) {
|
|
164
|
+
envProfileWarned = true
|
|
165
|
+
console.warn('[key-lanes] COS_G2_DEFAULT_MODEL names a key profile; ignored. Pick a profile from the picker instead.')
|
|
166
|
+
}
|
|
167
|
+
return normalizeModelPreference(raw)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Tests only. */
|
|
171
|
+
export function __resetEnvProfileWarningForTests(): void {
|
|
172
|
+
envProfileWarned = false
|
|
173
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Key lanes (K1, plan v4 sections 3.1, 4.4, 5, 12): saved key profiles, `data/provider-profiles.json`.
|
|
2
|
+
//
|
|
3
|
+
// A profile binds one saved key to one engine and one model. No secret lives here, only the key's
|
|
4
|
+
// ref. `keyLanesActive()` is true only while this file parses with at least one enabled profile.
|
|
5
|
+
// Reading never creates: a missing or corrupt file is "no profiles" and is left untouched.
|
|
6
|
+
import { existsSync, statSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { isKeyProfileId } from '../../shared/key-preference.js'
|
|
8
|
+
import { dataPath } from './data-dir.js'
|
|
9
|
+
import type { KeyKind, KeyProfile, KeyProfileEngine, KeyProfileTimeoutTier } from './key-relay-contract.js'
|
|
10
|
+
import { KEY_REF_RE } from './key-relay-contract.js'
|
|
11
|
+
import { readPrivateJsonFile, writePrivateJsonFile } from './key-store.js'
|
|
12
|
+
|
|
13
|
+
export const KEY_PROFILES_FILE = 'provider-profiles.json'
|
|
14
|
+
export const KEY_PROFILE_ENGINES: readonly KeyProfileEngine[] = ['claude', 'codex', 'direct']
|
|
15
|
+
export const KEY_PROFILE_TIMEOUT_TIERS: readonly KeyProfileTimeoutTier[] = ['opus', 'fable', 'sonnet', 'haiku']
|
|
16
|
+
export const MAX_KEY_PROFILES = 20
|
|
17
|
+
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/
|
|
18
|
+
const LABEL_MAX = 40
|
|
19
|
+
|
|
20
|
+
interface ProfilesFile {
|
|
21
|
+
v: 1
|
|
22
|
+
profiles: KeyProfile[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function keyProfilesPath(): string {
|
|
26
|
+
return dataPath(KEY_PROFILES_FILE)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isProfile(v: unknown): v is KeyProfile {
|
|
30
|
+
if (!v || typeof v !== 'object') return false
|
|
31
|
+
const p = v as Partial<KeyProfile>
|
|
32
|
+
return isKeyProfileId(p.id)
|
|
33
|
+
&& typeof p.label === 'string'
|
|
34
|
+
&& typeof p.keyRef === 'string' && KEY_REF_RE.test(p.keyRef)
|
|
35
|
+
&& typeof p.engine === 'string' && (KEY_PROFILE_ENGINES as readonly string[]).includes(p.engine)
|
|
36
|
+
&& typeof p.model === 'string' && MODEL_ID_RE.test(p.model)
|
|
37
|
+
&& typeof p.enabled === 'boolean'
|
|
38
|
+
&& typeof p.timeoutTier === 'string' && (KEY_PROFILE_TIMEOUT_TIERS as readonly string[]).includes(p.timeoutTier)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cache: { stamp: string; profiles: KeyProfile[] } | null = null
|
|
42
|
+
|
|
43
|
+
export function listProfiles(): KeyProfile[] {
|
|
44
|
+
const file = keyProfilesPath()
|
|
45
|
+
let stamp: string
|
|
46
|
+
try {
|
|
47
|
+
const st = statSync(file)
|
|
48
|
+
stamp = `${st.ino}:${st.size}:${st.mtimeMs}`
|
|
49
|
+
} catch {
|
|
50
|
+
cache = null
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
if (cache?.stamp === stamp) return cache.profiles
|
|
54
|
+
const parsed = readPrivateJsonFile(file) as Partial<ProfilesFile> | undefined
|
|
55
|
+
const profiles = parsed && Array.isArray(parsed.profiles)
|
|
56
|
+
? parsed.profiles.filter(isProfile).map(p => ({ ...p, offerOnV1: p.offerOnV1 === true, directTools: p.directTools === true }))
|
|
57
|
+
: []
|
|
58
|
+
cache = { stamp, profiles }
|
|
59
|
+
return profiles
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function enabledProfileCount(): number {
|
|
63
|
+
return listProfiles().filter(p => p.enabled).length
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getProfile(id: string): KeyProfile | undefined {
|
|
67
|
+
return listProfiles().find(p => p.id === id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeProfiles(profiles: KeyProfile[]): void {
|
|
71
|
+
const body: ProfilesFile = { v: 1, profiles }
|
|
72
|
+
writePrivateJsonFile(keyProfilesPath(), body)
|
|
73
|
+
cache = null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ProfileInput {
|
|
77
|
+
id?: unknown
|
|
78
|
+
label?: unknown
|
|
79
|
+
keyRef?: unknown
|
|
80
|
+
engine?: unknown
|
|
81
|
+
model?: unknown
|
|
82
|
+
enabled?: unknown
|
|
83
|
+
timeoutTier?: unknown
|
|
84
|
+
offerOnV1?: unknown
|
|
85
|
+
directTools?: unknown
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Plan 4.4 model policy at save time: Lane B Claude takes OpenRouter `anthropic/*` slugs or an
|
|
90
|
+
* Anthropic key's own `claude-*` ids; Lane B Codex takes `openai/*` slugs or an OpenAI key's ids;
|
|
91
|
+
* anything else is Lane C (direct) only, which cannot run on an Anthropic key.
|
|
92
|
+
*/
|
|
93
|
+
export function engineModelProblem(engine: KeyProfileEngine, model: string, keyKind: KeyKind): string | null {
|
|
94
|
+
if (engine === 'claude') {
|
|
95
|
+
if (keyKind === 'openrouter' && model.startsWith('anthropic/')) return null
|
|
96
|
+
if (keyKind === 'anthropic' && model.startsWith('claude-')) return null
|
|
97
|
+
return 'Claude profiles take an anthropic/ model on OpenRouter or a claude- model on an Anthropic key.'
|
|
98
|
+
}
|
|
99
|
+
if (engine === 'codex') {
|
|
100
|
+
if (keyKind === 'openrouter' && model.startsWith('openai/')) return null
|
|
101
|
+
if (keyKind === 'openai' && !model.includes('/')) return null
|
|
102
|
+
return 'Codex profiles take an openai/ model on OpenRouter or a model on an OpenAI key.'
|
|
103
|
+
}
|
|
104
|
+
if (keyKind === 'anthropic') return 'Direct profiles need an OpenRouter, OpenAI or custom key.'
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type ProfileValidation = { ok: true; profile: KeyProfile } | { ok: false; error: string; message: string }
|
|
109
|
+
|
|
110
|
+
/** Validate an upsert against the saved keys. `keyKindOf` returns undefined for an unknown ref. */
|
|
111
|
+
export function validateProfileInput(
|
|
112
|
+
input: ProfileInput,
|
|
113
|
+
keyKindOf: (ref: string) => KeyKind | undefined,
|
|
114
|
+
now: () => Date = () => new Date(),
|
|
115
|
+
): ProfileValidation {
|
|
116
|
+
const bad = (message: string): ProfileValidation => ({ ok: false, error: 'invalid_profile', message })
|
|
117
|
+
if (!isKeyProfileId(input.id)) return bad('Profile id is 1 to 32 lowercase letters, digits or dashes.')
|
|
118
|
+
if (typeof input.label !== 'string' || !input.label.trim() || input.label.trim().length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(input.label)) {
|
|
119
|
+
return bad(`Give the profile a name of up to ${LABEL_MAX} characters.`)
|
|
120
|
+
}
|
|
121
|
+
if (typeof input.keyRef !== 'string' || !KEY_REF_RE.test(input.keyRef)) return bad('Pick a saved key.')
|
|
122
|
+
const keyKind = keyKindOf(input.keyRef)
|
|
123
|
+
if (!keyKind) return { ok: false, error: 'unknown_key', message: 'That key is not saved on this Mac.' }
|
|
124
|
+
if (typeof input.engine !== 'string' || !(KEY_PROFILE_ENGINES as readonly string[]).includes(input.engine)) return bad('Pick Claude, Codex or Direct.')
|
|
125
|
+
if (typeof input.model !== 'string' || !MODEL_ID_RE.test(input.model)) return bad('Pick a model.')
|
|
126
|
+
const engine = input.engine as KeyProfileEngine
|
|
127
|
+
const modelProblem = engineModelProblem(engine, input.model, keyKind)
|
|
128
|
+
if (modelProblem) return { ok: false, error: 'model_not_allowed', message: modelProblem }
|
|
129
|
+
const tier = input.timeoutTier === undefined ? 'sonnet' : input.timeoutTier
|
|
130
|
+
if (typeof tier !== 'string' || !(KEY_PROFILE_TIMEOUT_TIERS as readonly string[]).includes(tier)) return bad('Unknown timeout tier.')
|
|
131
|
+
for (const [name, v] of [['enabled', input.enabled], ['offerOnV1', input.offerOnV1], ['directTools', input.directTools]] as const) {
|
|
132
|
+
if (v !== undefined && typeof v !== 'boolean') return bad(`${name} is true or false.`)
|
|
133
|
+
}
|
|
134
|
+
const at = now().toISOString()
|
|
135
|
+
const prior = getProfile(input.id)
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
profile: {
|
|
139
|
+
id: input.id,
|
|
140
|
+
label: input.label.trim(),
|
|
141
|
+
keyRef: input.keyRef,
|
|
142
|
+
engine,
|
|
143
|
+
model: input.model,
|
|
144
|
+
enabled: input.enabled === undefined ? true : input.enabled === true,
|
|
145
|
+
timeoutTier: tier as KeyProfileTimeoutTier,
|
|
146
|
+
offerOnV1: input.offerOnV1 === true,
|
|
147
|
+
// D7: tools stay off unless the caller turns them on for this profile.
|
|
148
|
+
directTools: input.directTools === true,
|
|
149
|
+
createdAt: prior?.createdAt ?? at,
|
|
150
|
+
updatedAt: at,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Insert or replace by id. Refuses past MAX_KEY_PROFILES. */
|
|
156
|
+
export function upsertProfile(profile: KeyProfile): { ok: true } | { ok: false; error: 'too_many_profiles' } {
|
|
157
|
+
const profiles = [...listProfiles()]
|
|
158
|
+
const i = profiles.findIndex(p => p.id === profile.id)
|
|
159
|
+
if (i < 0 && profiles.length >= MAX_KEY_PROFILES) return { ok: false, error: 'too_many_profiles' }
|
|
160
|
+
if (i >= 0) profiles[i] = profile
|
|
161
|
+
else profiles.push(profile)
|
|
162
|
+
writeProfiles(profiles)
|
|
163
|
+
return { ok: true }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** True when a profile was removed. */
|
|
167
|
+
export function deleteProfile(id: string): boolean {
|
|
168
|
+
const profiles = listProfiles()
|
|
169
|
+
const next = profiles.filter(p => p.id !== id)
|
|
170
|
+
if (next.length === profiles.length) return false
|
|
171
|
+
writeProfiles(next)
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Remove all keys (plan 12). True when a file was deleted. */
|
|
176
|
+
export function deleteProfilesFile(): boolean {
|
|
177
|
+
cache = null
|
|
178
|
+
const file = keyProfilesPath()
|
|
179
|
+
if (!existsSync(file)) return false
|
|
180
|
+
unlinkSync(file)
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Key lanes (K1, plan v4 sections 4, 8, 12): the FROZEN contract between P1 (foundation), P2 (the
|
|
2
|
+
// loopback metering relay), P3 (Lane B Claude), P4 (Lane B Codex), P5 (Lane C direct) and P11.
|
|
3
|
+
//
|
|
4
|
+
// Types and inert constants only. Nothing here reads a file, opens a socket, starts a timer or
|
|
5
|
+
// touches process.env, so importing it changes nothing for a server with key lanes off.
|
|
6
|
+
// Changing a name or a shape here after P1 lands needs every consumer package to agree.
|
|
7
|
+
|
|
8
|
+
// ── Credentials ──────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** What a saved key is (plan D2). `openai-compatible` is a custom URL, Mac only. */
|
|
11
|
+
export type KeyKind = 'openrouter' | 'anthropic' | 'openai' | 'openai-compatible'
|
|
12
|
+
export const KEY_KINDS: readonly KeyKind[] = ['openrouter', 'anthropic', 'openai', 'openai-compatible']
|
|
13
|
+
|
|
14
|
+
/** A key's stable handle: `key_` plus 12 lowercase hex. Never derived from the secret. */
|
|
15
|
+
export type KeyRef = string
|
|
16
|
+
export const KEY_REF_RE = /^key_[0-9a-f]{12}$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What any route, log, health field, diagnostic or ledger may say about a key. The secret never
|
|
20
|
+
* leaves `key-store.ts` except through `readKeySecretForRelay`, which only P2 and P5 call.
|
|
21
|
+
*/
|
|
22
|
+
export interface PublicKeyRecord {
|
|
23
|
+
ref: KeyRef
|
|
24
|
+
kind: KeyKind
|
|
25
|
+
/** Last four characters of the secret, for "the key ending 1a2b". */
|
|
26
|
+
last4: string
|
|
27
|
+
/** Daily cap in USD for this key (plan 4.5, D11 default 1.00). */
|
|
28
|
+
capUsd: number
|
|
29
|
+
savedAt: string
|
|
30
|
+
validatedAt?: string
|
|
31
|
+
/** `openai-compatible` only: the base URL the key was saved for. */
|
|
32
|
+
baseUrl?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Lanes and answers ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Lane A is the user's subscription CLI; B runs the CLI harness on a key; C calls the key direct. */
|
|
38
|
+
export type AnswerLane = 'subscription' | 'key' | 'direct'
|
|
39
|
+
/** The lanes that spend a key. Token-audit `lane` takes only these. */
|
|
40
|
+
export type KeyLane = Exclude<AnswerLane, 'subscription'>
|
|
41
|
+
|
|
42
|
+
/** Additive answer fields (plan 3.1). Present only on keyed answers, omitted (never null) otherwise. */
|
|
43
|
+
export interface AnswerKeyFields {
|
|
44
|
+
lane: KeyLane
|
|
45
|
+
credential: { kind: KeyKind; ref: KeyRef; profileId: string }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Token-audit fields (plan 3.1, 6.3). Record keys are exactly `profile`, `lane`, `credentialKind`
|
|
50
|
+
* and `usd`; each is written only when set, so a Lane A row is byte-identical to 6.79.0.
|
|
51
|
+
*/
|
|
52
|
+
export interface TokenAuditKeyFields {
|
|
53
|
+
profile?: string
|
|
54
|
+
lane?: KeyLane
|
|
55
|
+
credentialKind?: KeyKind
|
|
56
|
+
usd?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Profiles ─────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Which harness a profile runs. `claude` and `codex` are Lane B (through the relay); `direct` is Lane C. */
|
|
62
|
+
export type KeyProfileEngine = 'claude' | 'codex' | 'direct'
|
|
63
|
+
/** Timeout tier for a Lane B Claude profile (plan 7.1): the subscription tier whose budgets it borrows. */
|
|
64
|
+
export type KeyProfileTimeoutTier = 'opus' | 'fable' | 'sonnet' | 'haiku'
|
|
65
|
+
|
|
66
|
+
export interface KeyProfile {
|
|
67
|
+
/** `^[a-z0-9][a-z0-9-]{0,31}$` (shared/key-preference.ts KEY_PROFILE_ID_RE). */
|
|
68
|
+
id: string
|
|
69
|
+
label: string
|
|
70
|
+
keyRef: KeyRef
|
|
71
|
+
engine: KeyProfileEngine
|
|
72
|
+
/** The provider model id or OpenRouter slug, e.g. `anthropic/claude-sonnet-5.5`. */
|
|
73
|
+
model: string
|
|
74
|
+
enabled: boolean
|
|
75
|
+
timeoutTier: KeyProfileTimeoutTier
|
|
76
|
+
/** Plan 9.4: offer this profile on `/v1` as `cos-key-<id>`. Default false. */
|
|
77
|
+
offerOnV1: boolean
|
|
78
|
+
/** Plan D7: Lane C tools for this profile. Default false; never on by default for a remote endpoint. */
|
|
79
|
+
directTools: boolean
|
|
80
|
+
createdAt: string
|
|
81
|
+
updatedAt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What `/api/models` publishes as `keyProfiles` while lanes are active (enabled profiles only). */
|
|
85
|
+
export interface PublicKeyProfileOption {
|
|
86
|
+
id: string
|
|
87
|
+
label: string
|
|
88
|
+
engine: KeyProfileEngine
|
|
89
|
+
model: string
|
|
90
|
+
keyKind: KeyKind
|
|
91
|
+
timeoutTier: KeyProfileTimeoutTier
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Relay (P2 implements; P3, P4 consume) ────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export type RelayEngine = 'claude' | 'codex'
|
|
97
|
+
|
|
98
|
+
/** Plan 4.2. The relay listens on `127.0.0.1:<ephemeral>` only; never 3141, 3143 or 0.0.0.0. */
|
|
99
|
+
export const RELAY_PATHS = {
|
|
100
|
+
anthropicMessages: '/relay/a/v1/messages',
|
|
101
|
+
anthropicCountTokens: '/relay/a/v1/messages/count_tokens',
|
|
102
|
+
openaiResponses: '/relay/o/v1/responses',
|
|
103
|
+
health: '/relay/health',
|
|
104
|
+
} as const
|
|
105
|
+
/** Base path a child is pointed at: Claude `ANTHROPIC_BASE_URL=<origin>/relay/a`, Codex `<origin>/relay/o/v1`. */
|
|
106
|
+
export const RELAY_BASE_PATHS = { claude: '/relay/a', codex: '/relay/o/v1' } as const
|
|
107
|
+
|
|
108
|
+
/** Plan 4.2 upstreams, by key kind. */
|
|
109
|
+
export const RELAY_UPSTREAMS = {
|
|
110
|
+
openrouterMessages: 'https://openrouter.ai/api/v1/messages',
|
|
111
|
+
anthropicMessages: 'https://api.anthropic.com/v1/messages',
|
|
112
|
+
openrouterResponses: 'https://openrouter.ai/api/v1/responses',
|
|
113
|
+
openaiResponses: 'https://api.openai.com/v1/responses',
|
|
114
|
+
} as const
|
|
115
|
+
|
|
116
|
+
/** Plan 4.3 nonce limits. */
|
|
117
|
+
export const RELAY_LIMITS = {
|
|
118
|
+
nonceBytes: 32,
|
|
119
|
+
/** Canary QA (2026-10-19): 12 parallel Task subagents were measured; 16 leaves headroom, and the relay queues past it. */
|
|
120
|
+
inFlightPerNonce: 16,
|
|
121
|
+
bodyCapBytes: 20 * 1024 * 1024,
|
|
122
|
+
/** Nonce lives until child close plus this. */
|
|
123
|
+
childCloseGraceMs: 5_000,
|
|
124
|
+
/** ...or the tier's wall max plus this, whichever comes first. */
|
|
125
|
+
wallMaxGraceMs: 60_000,
|
|
126
|
+
/** Logs show at most this many leading characters of a nonce. */
|
|
127
|
+
nonceLogChars: 4,
|
|
128
|
+
} as const
|
|
129
|
+
|
|
130
|
+
/** Plan 4.3 binding. One nonce per turn, memory only. */
|
|
131
|
+
export interface RelayNonceBinding {
|
|
132
|
+
keyRef: KeyRef
|
|
133
|
+
profileId: string
|
|
134
|
+
turnId: string
|
|
135
|
+
sessionId: string
|
|
136
|
+
engine: RelayEngine
|
|
137
|
+
/** The profile slug. Plan 4.4: equal forwards, a Claude id or alias is rewritten to it, anything else is 400. */
|
|
138
|
+
allowedModel: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type RelayRevokeReason =
|
|
142
|
+
| 'child_close'
|
|
143
|
+
| 'cancel'
|
|
144
|
+
| 'steer_abort'
|
|
145
|
+
| 'breaker_open'
|
|
146
|
+
| 'remove_all'
|
|
147
|
+
| 'lanes_inactive'
|
|
148
|
+
| 'expired'
|
|
149
|
+
|
|
150
|
+
export interface RelayTurnHandle {
|
|
151
|
+
/** The secret the child presents. Never logged beyond RELAY_LIMITS.nonceLogChars. */
|
|
152
|
+
nonce: string
|
|
153
|
+
/** `http://127.0.0.1:<port>` for this boot. */
|
|
154
|
+
origin: string
|
|
155
|
+
/** Called by the bridge on child close; the relay keeps the nonce for childCloseGraceMs. */
|
|
156
|
+
childClosed(): void
|
|
157
|
+
revoke(reason: RelayRevokeReason): void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RelayIssueOptions {
|
|
161
|
+
/** The tier's wall max for this turn (plan 4.3 lifetime). */
|
|
162
|
+
wallMaxMs: number
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** P2's relay. Started lazily by the first keyed turn; closed when keyLanesActive() turns false. */
|
|
166
|
+
export interface KeyRelay {
|
|
167
|
+
issueTurn(binding: RelayNonceBinding, options: RelayIssueOptions): Promise<RelayTurnHandle>
|
|
168
|
+
revokeTurn(turnId: string, reason: RelayRevokeReason): void
|
|
169
|
+
revokeAll(reason: RelayRevokeReason): void
|
|
170
|
+
/** The listener's port, or null when no listener is open. */
|
|
171
|
+
port(): number | null
|
|
172
|
+
close(): Promise<void>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Plan 4.4 model policy outcome. */
|
|
176
|
+
export type RelayModelAction = 'forward' | 'rewrite' | 'reject'
|
|
177
|
+
|
|
178
|
+
/** Plan 4.6 mapping, one value per row. */
|
|
179
|
+
export type RelayFailureKind =
|
|
180
|
+
| 'cap_refused'
|
|
181
|
+
| 'key_rejected'
|
|
182
|
+
| 'out_of_credit'
|
|
183
|
+
| 'model_gone'
|
|
184
|
+
| 'rate_limited'
|
|
185
|
+
| 'upstream_error'
|
|
186
|
+
| 'network_error'
|
|
187
|
+
| 'model_not_allowed'
|
|
188
|
+
|
|
189
|
+
/** Only these count toward the breaker (plan 4.6). */
|
|
190
|
+
export type BreakerCountedFailure = Extract<RelayFailureKind, 'key_rejected' | 'out_of_credit' | 'model_gone'>
|
|
191
|
+
export type BreakerScope = 'key' | 'profile'
|
|
192
|
+
export interface BreakerState {
|
|
193
|
+
open: boolean
|
|
194
|
+
failures: number
|
|
195
|
+
openedAt?: string
|
|
196
|
+
reopensAt?: string
|
|
197
|
+
}
|
|
198
|
+
export const BREAKER_LIMITS = { failures: 3, openMs: 60 * 60 * 1000 } as const
|
|
199
|
+
|
|
200
|
+
// ── Meter and ledger (P2) ────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
export type KeyLedgerKind = 'reserve' | 'settle' | 'reconcile'
|
|
203
|
+
|
|
204
|
+
/** One row of `data/key-ledger.jsonl` (0600, atomic append). Never a secret, never a nonce. */
|
|
205
|
+
export interface KeyLedgerRow {
|
|
206
|
+
ts: string
|
|
207
|
+
keyRef: KeyRef
|
|
208
|
+
profile: string
|
|
209
|
+
lane: KeyLane
|
|
210
|
+
turnId: string
|
|
211
|
+
generationId?: string
|
|
212
|
+
tokens: { input: number; output: number }
|
|
213
|
+
usd: number
|
|
214
|
+
kind: KeyLedgerKind
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface KeyMeter {
|
|
218
|
+
/** Reserve before forwarding; false means refuse with the cap line. */
|
|
219
|
+
reserve(input: { keyRef: KeyRef; turnId: string; estimateUsd: number }): { ok: true; reservationId: string } | { ok: false; spentTodayUsd: number; capUsd: number }
|
|
220
|
+
settle(input: { reservationId: string; usd: number; tokens: { input: number; output: number }; generationId?: string }): void
|
|
221
|
+
spentTodayUsd(keyRef: KeyRef): number
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Child environment names (P3, P4) ─────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export const KEY_LANE_CHILD_ENV = {
|
|
227
|
+
/** Claude: the nonce, as a Bearer. */
|
|
228
|
+
claudeToken: 'ANTHROPIC_AUTH_TOKEN',
|
|
229
|
+
claudeBaseUrl: 'ANTHROPIC_BASE_URL',
|
|
230
|
+
/** Codex: the nonce, named by `env_key` in the generated config. */
|
|
231
|
+
codexToken: 'COS_RELAY_TOKEN',
|
|
232
|
+
/** Marks a keyed child for hooks and the MU child_env() scrub (plan 6.2, 7.3). */
|
|
233
|
+
profileMarker: 'COS_KEY_PROFILE',
|
|
234
|
+
} as const
|
|
235
|
+
|
|
236
|
+
// ── Status and lifecycle seams (P1 calls; P2, P3, P6 register) ───────────────
|
|
237
|
+
|
|
238
|
+
/** P2 and P3 answer these for `GET /api/key-lanes/status`. P1 defaults: 0, closed, []. */
|
|
239
|
+
export interface KeyLanesStatusProvider {
|
|
240
|
+
spentTodayUsd?(keyRef: KeyRef): number
|
|
241
|
+
breaker?(keyRef: KeyRef): BreakerState
|
|
242
|
+
keyedSessionIds?(): string[]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Why key lanes stopped being active. Hooks revoke nonces, close the relay, clear bindings. */
|
|
246
|
+
export type KeyLanesInactiveReason = 'disabled' | 'remove_all' | 'no_enabled_profile'
|
|
247
|
+
export type KeyLanesInactiveHook = (reason: KeyLanesInactiveReason) => void | Promise<void>
|
|
248
|
+
|
|
249
|
+
/** `GET /api/key-lanes/status` body. */
|
|
250
|
+
export interface KeyLanesStatusBody {
|
|
251
|
+
enabled: boolean
|
|
252
|
+
active: boolean
|
|
253
|
+
keys: Array<{ ref: KeyRef; kind: KeyKind; last4: string; capUsd: number; spentTodayUsd: number; breaker: BreakerState }>
|
|
254
|
+
profiles: KeyProfile[]
|
|
255
|
+
keyedSessionIds: string[]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `details.keyLanes` on `/api/health` (public), present only while enabled. Booleans only. */
|
|
259
|
+
export interface KeyLanesHealthDetail {
|
|
260
|
+
enabled: true
|
|
261
|
+
active: boolean
|
|
262
|
+
}
|