@gotcos/glasses-server 6.79.2 → 6.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- 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/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -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/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Key lanes K1 P2 (plan v4 4.6): how the loopback relay maps upstream and relay events to what the
|
|
2
|
+
// child sees, which failures count toward the breaker, and the breaker itself.
|
|
3
|
+
//
|
|
4
|
+
// Pure functions and one small class. Nothing here reads a file, opens a socket or starts a timer,
|
|
5
|
+
// so importing it changes nothing for a server with key lanes off.
|
|
6
|
+
//
|
|
7
|
+
// The relay never retries itself (no double billing). Children retry 429, 5xx and network errors
|
|
8
|
+
// on their own budget (CLAUDE_CODE_MAX_RETRIES=2, Codex request_max_retries=2). Only a rejected
|
|
9
|
+
// key, an empty credit balance or a model that is gone counts toward the breaker, because those
|
|
10
|
+
// are the failures a retry cannot fix.
|
|
11
|
+
import type {
|
|
12
|
+
BreakerCountedFailure,
|
|
13
|
+
BreakerScope,
|
|
14
|
+
BreakerState,
|
|
15
|
+
RelayEngine,
|
|
16
|
+
RelayFailureKind,
|
|
17
|
+
} from './key-relay-contract.js'
|
|
18
|
+
import { BREAKER_LIMITS } from './key-relay-contract.js'
|
|
19
|
+
|
|
20
|
+
// ── Classification (plan 4.6 table) ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const MODEL_GONE_RE = /model[^\n]{0,80}(not found|does not exist|not available|no longer|is not a valid|invalid model)|no endpoints found|unknown model|not a valid model/i
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The 4.6 row an upstream status lands in, or null when it is passed through with no special
|
|
26
|
+
* action (a 2xx, or a 4xx such as 403 moderation that is neither the key nor the model).
|
|
27
|
+
*/
|
|
28
|
+
export function classifyUpstreamStatus(status: number, bodyText = ''): RelayFailureKind | null {
|
|
29
|
+
if (status >= 200 && status < 300) return null
|
|
30
|
+
if (status === 401) return 'key_rejected'
|
|
31
|
+
if (status === 402) return 'out_of_credit'
|
|
32
|
+
if (status === 404) return 'model_gone'
|
|
33
|
+
if (status === 400 && MODEL_GONE_RE.test(bodyText)) return 'model_gone'
|
|
34
|
+
if (status === 429) return 'rate_limited'
|
|
35
|
+
if (status >= 500) return 'upstream_error'
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Plan 4.6: only 401, 402 and model-gone count. 429, 5xx, network and cap refusals never do. */
|
|
40
|
+
export function countsTowardBreaker(kind: RelayFailureKind | null): kind is BreakerCountedFailure {
|
|
41
|
+
return kind === 'key_rejected' || kind === 'out_of_credit' || kind === 'model_gone'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A rejected key or empty credit opens the KEY breaker; a gone model opens the PROFILE breaker. */
|
|
45
|
+
export function breakerScopeFor(kind: BreakerCountedFailure): BreakerScope {
|
|
46
|
+
return kind === 'model_gone' ? 'profile' : 'key'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The 4.6 rows whose server action is "ends the turn". */
|
|
50
|
+
export function endsTurn(kind: RelayFailureKind): boolean {
|
|
51
|
+
return kind === 'cap_refused' || kind === 'key_rejected' || kind === 'out_of_credit' || kind === 'model_gone'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Error bodies in the caller's own shape ───────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Relay-made failures (the ones not passed through from upstream). */
|
|
57
|
+
export type RelayLocalError =
|
|
58
|
+
| 'nonce_invalid'
|
|
59
|
+
| 'cap_refused'
|
|
60
|
+
| 'model_not_allowed'
|
|
61
|
+
| 'network_error'
|
|
62
|
+
| 'not_found'
|
|
63
|
+
| 'bad_request'
|
|
64
|
+
| 'too_large'
|
|
65
|
+
| 'too_many_in_flight'
|
|
66
|
+
| 'browser_refused'
|
|
67
|
+
|
|
68
|
+
interface LocalErrorSpec {
|
|
69
|
+
status: number
|
|
70
|
+
anthropicType: string
|
|
71
|
+
openaiType: string
|
|
72
|
+
message: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** User-facing copy. Plain sentences, no em dashes, no arrows, never a key or a nonce. */
|
|
76
|
+
export const RELAY_ERROR_SPECS: Record<RelayLocalError, LocalErrorSpec> = {
|
|
77
|
+
nonce_invalid: { status: 401, anthropicType: 'authentication_error', openaiType: 'invalid_api_key', message: 'This relay token is unknown or has expired.' },
|
|
78
|
+
cap_refused: { status: 402, anthropicType: 'billing_error', openaiType: 'insufficient_quota', message: 'Daily cap reached for this key.' },
|
|
79
|
+
model_not_allowed: { status: 400, anthropicType: 'invalid_request_error', openaiType: 'invalid_request_error', message: 'Model not allowed on this key profile' },
|
|
80
|
+
network_error: { status: 502, anthropicType: 'overloaded_error', openaiType: 'server_error', message: 'The provider could not be reached. Try again.' },
|
|
81
|
+
not_found: { status: 404, anthropicType: 'not_found_error', openaiType: 'invalid_request_error', message: 'Not found.' },
|
|
82
|
+
bad_request: { status: 400, anthropicType: 'invalid_request_error', openaiType: 'invalid_request_error', message: 'The request body is not valid JSON.' },
|
|
83
|
+
too_large: { status: 413, anthropicType: 'request_too_large', openaiType: 'invalid_request_error', message: 'The request is too large.' },
|
|
84
|
+
too_many_in_flight: { status: 429, anthropicType: 'rate_limit_error', openaiType: 'rate_limit_exceeded', message: 'Too many requests at once on this turn.' },
|
|
85
|
+
browser_refused: { status: 403, anthropicType: 'permission_error', openaiType: 'invalid_request_error', message: 'The relay does not answer browsers.' },
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The JSON body for a relay-made failure: Anthropic shape for Claude, OpenAI shape for Codex. */
|
|
89
|
+
export function relayErrorBody(engine: RelayEngine | 'unknown', error: RelayLocalError): { status: number; body: string } {
|
|
90
|
+
const spec = RELAY_ERROR_SPECS[error]
|
|
91
|
+
const body = engine === 'codex'
|
|
92
|
+
? { error: { message: spec.message, type: spec.openaiType, code: error } }
|
|
93
|
+
: { type: 'error', error: { type: spec.anthropicType, message: spec.message } }
|
|
94
|
+
return { status: spec.status, body: JSON.stringify(body) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Redaction ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export const REDACTED = '[redacted]'
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove every occurrence of each secret from text bound for a child, a log or a ledger. Upstream
|
|
103
|
+
* error bodies are passed through, and a provider or proxy that echoes the Authorization header
|
|
104
|
+
* would otherwise hand the real key to the child.
|
|
105
|
+
*/
|
|
106
|
+
export function redactSecrets(text: string, secrets: ReadonlyArray<string | null | undefined>): string {
|
|
107
|
+
let out = text
|
|
108
|
+
for (const s of secrets) {
|
|
109
|
+
if (!s || s.length < 8) continue
|
|
110
|
+
// QA N3: the plain form, the JSON-escaped form (a body quoting it), and the URL-encoded form.
|
|
111
|
+
const forms = new Set([s, JSON.stringify(s).slice(1, -1), encodeURIComponent(s)])
|
|
112
|
+
for (const f of [...forms].sort((a, b) => b.length - a.length)) out = out.split(f).join(REDACTED)
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Retry-After ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Seconds from a Retry-After header (delta seconds or an HTTP date), or null. Passed to the child as is. */
|
|
120
|
+
export function parseRetryAfterSeconds(value: string | null | undefined, now: () => number = Date.now): number | null {
|
|
121
|
+
if (!value) return null
|
|
122
|
+
const trimmed = value.trim()
|
|
123
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
|
|
124
|
+
const at = Date.parse(trimmed)
|
|
125
|
+
if (!Number.isFinite(at)) return null
|
|
126
|
+
return Math.max(0, Math.ceil((at - now()) / 1000))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Breaker (plan 4.6) ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
interface BreakerEntry {
|
|
132
|
+
failures: number
|
|
133
|
+
openedAtMs?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type BreakerOpenListener = (scope: BreakerScope, id: string) => void
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 3 consecutive counted failures open a breaker for 1 h. A success resets the count. A failure
|
|
140
|
+
* that does not count (429, 5xx, network, cap) neither counts nor resets. Reset on key change,
|
|
141
|
+
* successful validation or the Control "Reset" button (the relay calls reset()).
|
|
142
|
+
*/
|
|
143
|
+
export class RelayBreakers {
|
|
144
|
+
private readonly entries = new Map<string, BreakerEntry>()
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
private readonly now: () => number = Date.now,
|
|
148
|
+
private readonly onOpen: BreakerOpenListener = () => {},
|
|
149
|
+
) {}
|
|
150
|
+
|
|
151
|
+
private key(scope: BreakerScope, id: string): string {
|
|
152
|
+
return `${scope}:${id}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private live(scope: BreakerScope, id: string): BreakerEntry | undefined {
|
|
156
|
+
const k = this.key(scope, id)
|
|
157
|
+
const e = this.entries.get(k)
|
|
158
|
+
if (e?.openedAtMs !== undefined && this.now() - e.openedAtMs >= BREAKER_LIMITS.openMs) {
|
|
159
|
+
// Closed again after an hour, with a clean count.
|
|
160
|
+
this.entries.delete(k)
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
return e
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Record one upstream outcome. Returns true when this call opened the breaker. */
|
|
167
|
+
record(scope: BreakerScope, id: string, kind: RelayFailureKind | null, ok: boolean): boolean {
|
|
168
|
+
const k = this.key(scope, id)
|
|
169
|
+
if (ok) {
|
|
170
|
+
const e = this.live(scope, id)
|
|
171
|
+
if (e && e.openedAtMs === undefined) this.entries.delete(k)
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
if (!countsTowardBreaker(kind)) return false
|
|
175
|
+
const e = this.live(scope, id) ?? { failures: 0 }
|
|
176
|
+
if (e.openedAtMs !== undefined) return false
|
|
177
|
+
e.failures += 1
|
|
178
|
+
if (e.failures >= BREAKER_LIMITS.failures) {
|
|
179
|
+
e.openedAtMs = this.now()
|
|
180
|
+
this.entries.set(k, e)
|
|
181
|
+
this.onOpen(scope, id)
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
this.entries.set(k, e)
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
isOpen(scope: BreakerScope, id: string): boolean {
|
|
189
|
+
return this.live(scope, id)?.openedAtMs !== undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
state(scope: BreakerScope, id: string): BreakerState {
|
|
193
|
+
const e = this.live(scope, id)
|
|
194
|
+
if (!e) return { open: false, failures: 0 }
|
|
195
|
+
if (e.openedAtMs === undefined) return { open: false, failures: e.failures }
|
|
196
|
+
return {
|
|
197
|
+
open: true,
|
|
198
|
+
failures: e.failures,
|
|
199
|
+
openedAt: new Date(e.openedAtMs).toISOString(),
|
|
200
|
+
reopensAt: new Date(e.openedAtMs + BREAKER_LIMITS.openMs).toISOString(),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
reset(scope: BreakerScope, id: string): void {
|
|
205
|
+
this.entries.delete(this.key(scope, id))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
clear(): void {
|
|
209
|
+
this.entries.clear()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Key lanes K1 P2 (plan v4 4.5): the per-key spend counter and its reservations.
|
|
2
|
+
//
|
|
3
|
+
// ONE counter per key ref, shared by every profile on that key, every parallel turn, hooks, the
|
|
4
|
+
// in-process direct lane (P5) and K3. reserve() is synchronous, so two parallel requests can never
|
|
5
|
+
// both pass a check that only one of them fits under: JavaScript runs one reserve at a time.
|
|
6
|
+
//
|
|
7
|
+
// spent today = max(local settled, OpenRouter usage_daily) + open reservations
|
|
8
|
+
// reserve = body bytes / 4 at the input price + min(max_tokens, 4096) at the output price
|
|
9
|
+
// refuse when spent today + reserve > cap
|
|
10
|
+
// settle replaces the reservation with the measured cost (stream usage times price, or the
|
|
11
|
+
// provider's own `usage.cost` when it sends one)
|
|
12
|
+
//
|
|
13
|
+
// The day is the UTC day, matching OpenRouter usage_daily. Overshoot is bounded by responses
|
|
14
|
+
// already streaming when the cap is reached; the OpenRouter key's own credit limit is the hard
|
|
15
|
+
// backstop. Nothing here starts a timer or opens a socket, and nothing is written until the first
|
|
16
|
+
// reservation.
|
|
17
|
+
import { randomBytes } from 'node:crypto'
|
|
18
|
+
import { getPublicKey } from './key-store.js'
|
|
19
|
+
import type { KeyLane, KeyMeter, KeyRef } from './key-relay-contract.js'
|
|
20
|
+
import { appendKeyLedgerRow, ledgerSpentUsd, utcDay } from './key-ledger.js'
|
|
21
|
+
import { KEY_CATALOG_CURATED } from './key-catalog.js'
|
|
22
|
+
|
|
23
|
+
export interface ModelPrice {
|
|
24
|
+
inUsdPerM: number
|
|
25
|
+
outUsdPerM: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A model COS has no price for is metered as if it were the most expensive Claude tier, so an
|
|
30
|
+
* unknown price can only make the cap stricter, never looser.
|
|
31
|
+
*/
|
|
32
|
+
export const FALLBACK_PRICE: ModelPrice = { inUsdPerM: 15, outUsdPerM: 75 }
|
|
33
|
+
export const RESERVE_MAX_OUTPUT_TOKENS = 4096
|
|
34
|
+
export const BYTES_PER_TOKEN_ESTIMATE = 4
|
|
35
|
+
/** Turns whose settled spend the meter remembers (oldest dropped first). */
|
|
36
|
+
export const TURN_SPEND_MAX = 512
|
|
37
|
+
|
|
38
|
+
/** Curated prices (plan D10), then the fallback. P5's live catalog can be passed in instead. */
|
|
39
|
+
export function defaultPriceFor(model: string): ModelPrice {
|
|
40
|
+
const curated = KEY_CATALOG_CURATED.find(m => m.id === model)
|
|
41
|
+
return curated ? { inUsdPerM: curated.inUsdPerM, outUsdPerM: curated.outUsdPerM } : FALLBACK_PRICE
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tokensToUsd(tokens: { input: number; output: number }, price: ModelPrice): number {
|
|
45
|
+
return (Math.max(0, tokens.input) * price.inUsdPerM + Math.max(0, tokens.output) * price.outUsdPerM) / 1e6
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Plan 4.5 reserve: input estimate plus min(max_tokens, 4096) output. A missing max_tokens reserves 4096. */
|
|
49
|
+
export function estimateReserveUsd(input: { bodyBytes: number; maxTokens?: number | null; price: ModelPrice }): number {
|
|
50
|
+
const inTokens = Math.ceil(Math.max(0, input.bodyBytes) / BYTES_PER_TOKEN_ESTIMATE)
|
|
51
|
+
const mt = typeof input.maxTokens === 'number' && Number.isFinite(input.maxTokens) && input.maxTokens > 0 ? input.maxTokens : RESERVE_MAX_OUTPUT_TOKENS
|
|
52
|
+
const outTokens = Math.min(mt, RESERVE_MAX_OUTPUT_TOKENS)
|
|
53
|
+
return tokensToUsd({ input: inTokens, output: outTokens }, input.price)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** QA W2: the smallest reply worth starting; less headroom than this refuses the request. */
|
|
57
|
+
export const MIN_RESERVE_OUTPUT_TOKENS = 256
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* QA W2: reserve the request's own max_tokens (no 4096 clamp), but never more than the key has
|
|
61
|
+
* left today. Null when even the input plus a short reply does not fit: refuse before sending.
|
|
62
|
+
* A reply longer than what was reserved is cut by the relay when the spend reaches the cap.
|
|
63
|
+
*/
|
|
64
|
+
export function reserveWithinHeadroomUsd(input: { bodyBytes: number; maxTokens?: number | null; price: ModelPrice; headroomUsd: number }): number | null {
|
|
65
|
+
const inTokens = Math.ceil(Math.max(0, input.bodyBytes) / BYTES_PER_TOKEN_ESTIMATE)
|
|
66
|
+
const mt = typeof input.maxTokens === 'number' && Number.isFinite(input.maxTokens) && input.maxTokens > 0 ? input.maxTokens : RESERVE_MAX_OUTPUT_TOKENS
|
|
67
|
+
const inputUsd = tokensToUsd({ input: inTokens, output: 0 }, input.price)
|
|
68
|
+
const fullUsd = inputUsd + tokensToUsd({ input: 0, output: mt }, input.price)
|
|
69
|
+
const floorUsd = inputUsd + tokensToUsd({ input: 0, output: Math.min(mt, MIN_RESERVE_OUTPUT_TOKENS) }, input.price)
|
|
70
|
+
const headroom = Number.isFinite(input.headroomUsd) ? input.headroomUsd : 0
|
|
71
|
+
if (headroom < floorUsd) return null
|
|
72
|
+
return Math.min(fullUsd, headroom)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Reservation {
|
|
76
|
+
keyRef: KeyRef
|
|
77
|
+
turnId: string
|
|
78
|
+
profile: string
|
|
79
|
+
lane: KeyLane
|
|
80
|
+
usd: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DaySpend {
|
|
84
|
+
day: string
|
|
85
|
+
settledUsd: number
|
|
86
|
+
usageDailyUsd?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ReserveInput {
|
|
90
|
+
keyRef: KeyRef
|
|
91
|
+
turnId: string
|
|
92
|
+
estimateUsd: number
|
|
93
|
+
/** Ledger detail; the contract's KeyMeter.reserve leaves them out. */
|
|
94
|
+
profile?: string
|
|
95
|
+
lane?: KeyLane
|
|
96
|
+
tokens?: { input: number; output: number }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ReserveResult = { ok: true; reservationId: string } | { ok: false; spentTodayUsd: number; capUsd: number }
|
|
100
|
+
|
|
101
|
+
export interface KeyRelayMeterOptions {
|
|
102
|
+
now?: () => number
|
|
103
|
+
/** The key's daily cap, or null when the key is gone (refuse). Default: the key store. */
|
|
104
|
+
capUsd?: (keyRef: KeyRef) => number | null
|
|
105
|
+
/** Ledger writer. Default: data/key-ledger.jsonl. */
|
|
106
|
+
appendRow?: typeof appendKeyLedgerRow
|
|
107
|
+
/** Settled spend already on disk for a key and UTC day. Default: the ledger file. */
|
|
108
|
+
loadSettled?: (keyRef: KeyRef, day: string) => number
|
|
109
|
+
log?: (line: string) => void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class KeyRelayMeter implements KeyMeter {
|
|
113
|
+
private readonly now: () => number
|
|
114
|
+
private readonly capOf: (keyRef: KeyRef) => number | null
|
|
115
|
+
private readonly append: typeof appendKeyLedgerRow
|
|
116
|
+
private readonly loadSettled: (keyRef: KeyRef, day: string) => number
|
|
117
|
+
private readonly log: (line: string) => void
|
|
118
|
+
private readonly days = new Map<KeyRef, DaySpend>()
|
|
119
|
+
private readonly reservations = new Map<string, Reservation>()
|
|
120
|
+
/** P11: settled spend per turn id (newest TURN_SPEND_MAX), so a keyed answer can carry its cost. */
|
|
121
|
+
private readonly turnSpend = new Map<string, number>()
|
|
122
|
+
/** P11 (plan 4.5 "Cap value"): the cap Control last saved per key, this boot. */
|
|
123
|
+
private readonly savedCaps = new Map<KeyRef, number>()
|
|
124
|
+
private readonly capMismatchLogged = new Set<KeyRef>()
|
|
125
|
+
|
|
126
|
+
constructor(options: KeyRelayMeterOptions = {}) {
|
|
127
|
+
this.now = options.now ?? Date.now
|
|
128
|
+
this.capOf = options.capUsd ?? (ref => getPublicKey(ref)?.capUsd ?? null)
|
|
129
|
+
this.append = options.appendRow ?? appendKeyLedgerRow
|
|
130
|
+
this.loadSettled = options.loadSettled ?? ((ref, day) => ledgerSpentUsd(ref, day))
|
|
131
|
+
this.log = options.log ?? (line => console.warn(line))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private today(keyRef: KeyRef): DaySpend {
|
|
135
|
+
const day = utcDay(this.now())
|
|
136
|
+
let d = this.days.get(keyRef)
|
|
137
|
+
if (!d || d.day !== day) {
|
|
138
|
+
let settledUsd = 0
|
|
139
|
+
try { settledUsd = this.loadSettled(keyRef, day) } catch { settledUsd = 0 }
|
|
140
|
+
d = { day, settledUsd }
|
|
141
|
+
this.days.set(keyRef, d)
|
|
142
|
+
}
|
|
143
|
+
return d
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private write(row: Parameters<typeof appendKeyLedgerRow>[0]): void {
|
|
147
|
+
try { this.append(row) } catch (err) {
|
|
148
|
+
// The in-memory counter still holds the spend; only the audit row is lost.
|
|
149
|
+
this.log(`[key-relay] ledger write skipped: ${(err as Error)?.name ?? 'error'}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
capUsd(keyRef: KeyRef): number | null {
|
|
154
|
+
const raw = this.capOf(keyRef)
|
|
155
|
+
const cap = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
|
156
|
+
// A key that is gone always refuses, whatever was saved before.
|
|
157
|
+
if (cap === null) return null
|
|
158
|
+
// Plan 4.5: the server keeps Control's last saved value. A cap that changed in the file by any
|
|
159
|
+
// other path (a Lane A turn editing provider-keys.json) is ignored and logged once. The first
|
|
160
|
+
// value seen this boot is the baseline until Control saves one.
|
|
161
|
+
const saved = this.savedCaps.get(keyRef)
|
|
162
|
+
if (saved === undefined) {
|
|
163
|
+
this.savedCaps.set(keyRef, cap)
|
|
164
|
+
return cap
|
|
165
|
+
}
|
|
166
|
+
if (saved !== cap && !this.capMismatchLogged.has(keyRef)) {
|
|
167
|
+
this.capMismatchLogged.add(keyRef)
|
|
168
|
+
this.log(`[key-relay] cap for ${keyRef} changed outside COS Control; keeping the saved cap`)
|
|
169
|
+
}
|
|
170
|
+
return saved
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** P11: the key-lanes routes call this when COS Control saves a key or changes its cap. */
|
|
174
|
+
noteSavedCap(keyRef: KeyRef, capUsd: number): void {
|
|
175
|
+
if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) return
|
|
176
|
+
this.savedCaps.set(keyRef, capUsd)
|
|
177
|
+
this.capMismatchLogged.delete(keyRef)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** P11: what this turn has settled so far on any key (0 when nothing settled). */
|
|
181
|
+
turnSettledUsd(turnId: string): number {
|
|
182
|
+
return this.turnSpend.get(turnId) ?? 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private addTurnSpend(turnId: string, usd: number): void {
|
|
186
|
+
if (!turnId) return
|
|
187
|
+
const prior = this.turnSpend.get(turnId) ?? 0
|
|
188
|
+
this.turnSpend.delete(turnId)
|
|
189
|
+
this.turnSpend.set(turnId, prior + usd)
|
|
190
|
+
while (this.turnSpend.size > TURN_SPEND_MAX) {
|
|
191
|
+
const oldest = this.turnSpend.keys().next().value
|
|
192
|
+
if (oldest === undefined) break
|
|
193
|
+
this.turnSpend.delete(oldest)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
openReservationsUsd(keyRef: KeyRef): number {
|
|
198
|
+
let sum = 0
|
|
199
|
+
for (const r of this.reservations.values()) if (r.keyRef === keyRef) sum += r.usd
|
|
200
|
+
return sum
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Plan 4.5: max(local ledger, usage_daily) plus open reservations. */
|
|
204
|
+
spentTodayUsd(keyRef: KeyRef): number {
|
|
205
|
+
const d = this.today(keyRef)
|
|
206
|
+
return Math.max(d.settledUsd, d.usageDailyUsd ?? 0) + this.openReservationsUsd(keyRef)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** OpenRouter's usage_daily for this key (read at turn start and while a turn runs). */
|
|
210
|
+
observeUsageDaily(keyRef: KeyRef, usd: number): void {
|
|
211
|
+
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return
|
|
212
|
+
const d = this.today(keyRef)
|
|
213
|
+
d.usageDailyUsd = Math.max(d.usageDailyUsd ?? 0, usd)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** QA W2: what the key may still spend today (cap minus spent, open reservations included). */
|
|
217
|
+
headroomUsd(keyRef: KeyRef): number {
|
|
218
|
+
const cap = this.capUsd(keyRef)
|
|
219
|
+
return cap === null ? 0 : cap - this.spentTodayUsd(keyRef)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** True when the key has nothing left under its cap today (refuse a turn before spawn). */
|
|
223
|
+
atCap(keyRef: KeyRef): boolean {
|
|
224
|
+
const cap = this.capUsd(keyRef)
|
|
225
|
+
return cap === null || this.spentTodayUsd(keyRef) >= cap
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
reserve(input: ReserveInput): ReserveResult {
|
|
229
|
+
const cap = this.capUsd(input.keyRef)
|
|
230
|
+
const spent = this.spentTodayUsd(input.keyRef)
|
|
231
|
+
const estimate = Number.isFinite(input.estimateUsd) && input.estimateUsd > 0 ? input.estimateUsd : 0
|
|
232
|
+
if (cap === null || spent + estimate > cap) {
|
|
233
|
+
return { ok: false, spentTodayUsd: spent, capUsd: cap ?? 0 }
|
|
234
|
+
}
|
|
235
|
+
const reservationId = `res_${randomBytes(9).toString('base64url')}`
|
|
236
|
+
const r: Reservation = { keyRef: input.keyRef, turnId: input.turnId, profile: input.profile ?? '', lane: input.lane ?? 'key', usd: estimate }
|
|
237
|
+
this.reservations.set(reservationId, r)
|
|
238
|
+
this.write({
|
|
239
|
+
ts: new Date(this.now()).toISOString(), keyRef: r.keyRef, profile: r.profile, lane: r.lane, turnId: r.turnId,
|
|
240
|
+
tokens: input.tokens ?? { input: 0, output: 0 }, usd: estimate, kind: 'reserve',
|
|
241
|
+
})
|
|
242
|
+
return { ok: true, reservationId }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
settle(input: { reservationId: string; usd: number; tokens: { input: number; output: number }; generationId?: string }): void {
|
|
246
|
+
const r = this.reservations.get(input.reservationId)
|
|
247
|
+
if (!r) return
|
|
248
|
+
this.reservations.delete(input.reservationId)
|
|
249
|
+
const usd = Number.isFinite(input.usd) && input.usd > 0 ? input.usd : 0
|
|
250
|
+
this.today(r.keyRef).settledUsd += usd
|
|
251
|
+
this.addTurnSpend(r.turnId, usd)
|
|
252
|
+
this.write({
|
|
253
|
+
ts: new Date(this.now()).toISOString(), keyRef: r.keyRef, profile: r.profile, lane: r.lane, turnId: r.turnId,
|
|
254
|
+
...(input.generationId ? { generationId: input.generationId } : {}),
|
|
255
|
+
tokens: input.tokens, usd, kind: 'settle',
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Settle at the full reserved amount (no usage arrived). */
|
|
260
|
+
settleReserved(reservationId: string, tokens: { input: number; output: number } = { input: 0, output: 0 }, generationId?: string): void {
|
|
261
|
+
const r = this.reservations.get(reservationId)
|
|
262
|
+
if (!r) return
|
|
263
|
+
this.settle({ reservationId, usd: r.usd, tokens, ...(generationId ? { generationId } : {}) })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The reserved amount of an open reservation, or null. */
|
|
267
|
+
reservedUsd(reservationId: string): number | null {
|
|
268
|
+
return this.reservations.get(reservationId)?.usd ?? null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Later correction from the provider's own generation cost (plan 4.5 reconcile). */
|
|
272
|
+
reconcile(input: { keyRef: KeyRef; turnId: string; profile: string; lane: KeyLane; generationId: string; deltaUsd: number; tokens: { input: number; output: number } }): void {
|
|
273
|
+
if (!Number.isFinite(input.deltaUsd) || input.deltaUsd === 0) return
|
|
274
|
+
const d = this.today(input.keyRef)
|
|
275
|
+
d.settledUsd = Math.max(0, d.settledUsd + input.deltaUsd)
|
|
276
|
+
this.write({
|
|
277
|
+
ts: new Date(this.now()).toISOString(), keyRef: input.keyRef, profile: input.profile, lane: input.lane, turnId: input.turnId,
|
|
278
|
+
generationId: input.generationId, tokens: input.tokens, usd: input.deltaUsd, kind: 'reconcile',
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Drop every in-memory number (Remove all keys). The ledger file is the route's to delete. */
|
|
283
|
+
clear(): void {
|
|
284
|
+
this.days.clear()
|
|
285
|
+
this.reservations.clear()
|
|
286
|
+
this.turnSpend.clear()
|
|
287
|
+
this.savedCaps.clear()
|
|
288
|
+
this.capMismatchLogged.clear()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let shared: KeyRelayMeter | null = null
|
|
293
|
+
|
|
294
|
+
/** The one meter the relay, the direct lane (P5) and K3 share. Created on first use. */
|
|
295
|
+
export function getSharedKeyMeter(): KeyRelayMeter {
|
|
296
|
+
if (!shared) shared = new KeyRelayMeter()
|
|
297
|
+
return shared
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Tests only. */
|
|
301
|
+
export function __resetSharedKeyMeterForTests(): void {
|
|
302
|
+
shared = null
|
|
303
|
+
}
|