opencode-ufr 0.2.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/ufr.ts +4 -0
  4. package/models.json +206 -0
  5. package/package.json +16 -0
  6. package/src/cli/catalog.ts +45 -0
  7. package/src/cli/connect.ts +146 -0
  8. package/src/cli/daemon-client.ts +19 -0
  9. package/src/cli/index.ts +93 -0
  10. package/src/cli/io.ts +98 -0
  11. package/src/cli/keys.ts +95 -0
  12. package/src/cli/stats.ts +37 -0
  13. package/src/cli/status.ts +48 -0
  14. package/src/cli/stop.ts +8 -0
  15. package/src/cli/ufr-check.ts +43 -0
  16. package/src/daemon/breaker.ts +146 -0
  17. package/src/daemon/catalog-source.ts +85 -0
  18. package/src/daemon/catalog.ts +127 -0
  19. package/src/daemon/daemon.ts +397 -0
  20. package/src/daemon/fallbacks.ts +41 -0
  21. package/src/daemon/keypool.ts +109 -0
  22. package/src/daemon/log.ts +15 -0
  23. package/src/daemon/main.ts +18 -0
  24. package/src/daemon/model.ts +15 -0
  25. package/src/daemon/router.ts +392 -0
  26. package/src/daemon/server.ts +54 -0
  27. package/src/daemon/stats.ts +105 -0
  28. package/src/daemon/transport.ts +21 -0
  29. package/src/daemon/upstream.ts +68 -0
  30. package/src/daemon/vpn/fortinet.ts +466 -0
  31. package/src/daemon/vpn/ip.ts +176 -0
  32. package/src/daemon/vpn/manager.ts +258 -0
  33. package/src/daemon/vpn/ppp.ts +450 -0
  34. package/src/daemon/vpn/proxy.ts +102 -0
  35. package/src/daemon/vpn/stack.ts +140 -0
  36. package/src/daemon/vpn/tcp.ts +356 -0
  37. package/src/daemon/window.ts +126 -0
  38. package/src/plugin/connect.ts +113 -0
  39. package/src/plugin/ensure-daemon.ts +101 -0
  40. package/src/plugin/index.ts +93 -0
  41. package/src/plugin/model-info.ts +35 -0
  42. package/src/shared/config.ts +170 -0
  43. package/src/shared/connect.ts +100 -0
  44. package/src/shared/errors.ts +6 -0
  45. package/src/shared/fs.ts +31 -0
  46. package/src/shared/models-file.ts +68 -0
  47. package/src/shared/paths.ts +62 -0
  48. package/src/shared/secrets.ts +62 -0
  49. package/src/shared/sleep.ts +15 -0
  50. package/src/shared/time.ts +6 -0
  51. package/src/shared/version.ts +3 -0
  52. package/src/shared/vpn.ts +8 -0
@@ -0,0 +1,127 @@
1
+ import type { ModelsFile, Price } from "../shared/models-file"
2
+ import { deriveChains, deriveContextChains } from "./fallbacks"
3
+ import type { Model, ModelPrice } from "./model"
4
+
5
+ export type UfrModel = { id: string; name: string; tier: "free" | "paid"; vision: boolean }
6
+
7
+ export type Catalog = {
8
+ models: Map<string, Model>
9
+ aliases: Map<string, string>
10
+ chains: Map<string, string[]>
11
+ contextChains: Map<string, string[]>
12
+ warnings: string[]
13
+ }
14
+
15
+ export const EMPTY_CATALOG: Catalog = {
16
+ models: new Map(),
17
+ aliases: new Map(),
18
+ chains: new Map(),
19
+ contextChains: new Map(),
20
+ warnings: [],
21
+ }
22
+
23
+ type RawUfrModel = {
24
+ id?: unknown
25
+ name?: unknown
26
+ connection_type?: unknown
27
+ info?: { meta?: { capabilities?: { vision?: unknown } } }
28
+ }
29
+
30
+ /** UFR's /api/models: `external` = UFR pays a vendor per token (OpenAI, Mistral). */
31
+ export function parseUfrModels(raw: unknown): UfrModel[] {
32
+ const list = Array.isArray(raw) ? raw : (raw as { data?: unknown } | null)?.data
33
+ if (!Array.isArray(list)) throw new Error("UFR /api/models: unexpected response shape")
34
+ return (list as RawUfrModel[])
35
+ .filter((m) => m && typeof m.id === "string")
36
+ .map((m) => ({
37
+ id: m.id as string,
38
+ name: typeof m.name === "string" && m.name.trim() ? m.name.trim() : (m.id as string),
39
+ tier: m.connection_type === "external" ? "paid" : "free",
40
+ vision: Boolean(m.info?.meta?.capabilities?.vision),
41
+ }))
42
+ }
43
+
44
+ function toPrice(p: Price | undefined): ModelPrice | null {
45
+ if (!p) return null
46
+ return { input: p.input, output: p.output, cacheRead: p.cache_read ?? p.input, cacheWrite: p.cache_write ?? p.input }
47
+ }
48
+
49
+ export function buildCatalog(ufr: UfrModel[], file: ModelsFile, opts: { allowPaid: boolean }): Catalog {
50
+ const warnings: string[] = []
51
+ const exclude = new Set(file.exclude)
52
+ const models = new Map<string, Model>()
53
+ for (const u of ufr) {
54
+ const e = file.models[u.id]
55
+ models.set(u.id, {
56
+ id: u.id,
57
+ name: u.name,
58
+ tier: u.tier,
59
+ vision: e?.vision ?? u.vision,
60
+ tools: e?.tools ?? true,
61
+ context: e?.context ?? file.defaults.context,
62
+ maxOutput: e?.max_output ?? file.defaults.max_output,
63
+ price: toPrice(e?.price),
64
+ hasEntry: e !== undefined,
65
+ hidden: exclude.has(u.id),
66
+ })
67
+ }
68
+ const aliases = new Map<string, string>()
69
+ for (const [alias, target] of Object.entries(file.aliases)) {
70
+ if (!models.has(target)) continue
71
+ aliases.set(alias, target)
72
+ models.delete(alias) // UFR may list the alias spelling too; one entry per model
73
+ }
74
+ for (const m of models.values()) {
75
+ if (!m.hasEntry) {
76
+ warnings.push(`${m.id}: new at UFR, no models.json entry — using defaults (context ${m.context}, output ${m.maxOutput}), no fallbacks`)
77
+ } else if (!m.price && !m.hidden) {
78
+ warnings.push(`${m.id}: no price in models.json — cost shown as unknown`)
79
+ }
80
+ }
81
+ const hub = file.fallbacks.context_hub
82
+ if (hub && !models.has(hub)) warnings.push(`context hub ${hub} is not served by UFR — context fallback disabled`)
83
+ return {
84
+ models,
85
+ aliases,
86
+ chains: deriveChains(models, file.fallbacks, opts),
87
+ contextChains: deriveContextChains(models, hub, opts),
88
+ warnings,
89
+ }
90
+ }
91
+
92
+ export function resolveModel(cat: Catalog, name: string): string {
93
+ return cat.aliases.get(name) ?? name
94
+ }
95
+
96
+ export type OpencodeStamp = {
97
+ name: string
98
+ limit: { context: number; output: number }
99
+ cost?: { input: number; output: number; cache_read: number; cache_write: number }
100
+ tool_call: boolean
101
+ attachment: boolean
102
+ reasoning: boolean
103
+ temperature: boolean
104
+ }
105
+
106
+ /**
107
+ * What the plugin needs per model. reasoning=false for every UFR route
108
+ * (measured reasoning_effort inert upstream); temperature is accepted everywhere.
109
+ */
110
+ export function opencodeStamp(m: Model): OpencodeStamp {
111
+ const s: OpencodeStamp = {
112
+ name: m.name,
113
+ limit: { context: m.context, output: m.maxOutput },
114
+ tool_call: m.tools,
115
+ attachment: m.vision,
116
+ reasoning: false,
117
+ temperature: true,
118
+ }
119
+ if (m.price) s.cost = { input: m.price.input, output: m.price.output, cache_read: m.price.cacheRead, cache_write: m.price.cacheWrite }
120
+ return s
121
+ }
122
+
123
+ export function listModels(cat: Catalog) {
124
+ return [...cat.models.values()]
125
+ .filter((m) => !m.hidden)
126
+ .map((m) => ({ id: m.id, object: "model" as const, owned_by: "ufr" as const, opencode: opencodeStamp(m) }))
127
+ }
@@ -0,0 +1,397 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { mkdir, open, rm, stat } from "node:fs/promises"
3
+ import { dirname } from "node:path"
4
+ import { loadConfig, saveConfig } from "../shared/config"
5
+ import { readJson, readText, writeFileAtomic } from "../shared/fs"
6
+ import type { Paths } from "../shared/paths"
7
+ import type { SecretStore } from "../shared/secrets"
8
+ import { VPN_PASS, VPN_USER } from "../shared/secrets"
9
+ import { sleep } from "../shared/sleep"
10
+ import { startOfLocalDay } from "../shared/time"
11
+ import { VERSION } from "../shared/version"
12
+ import { type BreakerState, BreakerRegistry } from "./breaker"
13
+ import { type Catalog, EMPTY_CATALOG, buildCatalog, listModels } from "./catalog"
14
+ import { type FetchLike, type ModelsSource, loadModelsFile, loadUfrModels } from "./catalog-source"
15
+ import { type KeyInfo, KeyPool, type KeySnapshot } from "./keypool"
16
+ import { Router } from "./router"
17
+ import { startServer } from "./server"
18
+ import { Stats } from "./stats"
19
+ import { createTransport } from "./transport"
20
+ import { VpnManager } from "./vpn/manager"
21
+ import { SlidingWindow } from "./window"
22
+
23
+ export class AlreadyRunningError extends Error {
24
+ constructor() {
25
+ super("another opencode-ufr daemon is already running")
26
+ }
27
+ }
28
+
29
+ export type StatusJson = {
30
+ version: string
31
+ pid: number
32
+ port: number
33
+ uptimeMs: number
34
+ inFlight: number
35
+ now: number
36
+ upstream: { ok: boolean | null; message: string; at: number }
37
+ keys: KeySnapshot[]
38
+ pool: ReturnType<SlidingWindow["snapshot"]>
39
+ breakers: Record<string, { state: BreakerState; level: number; retryAfterMs: number }>
40
+ catalog: { source: ModelsSource | "none"; ufrSource: "remote" | "cache" | "none"; loadedAt: number; models: number; warnings: string[] }
41
+ vpn: { mode: string; detail: string } | null
42
+ spendToday: Record<string, number>
43
+ dailyBudgetUsd: number
44
+ }
45
+
46
+ export type DaemonOptions = {
47
+ paths: Paths
48
+ secrets: SecretStore
49
+ fetch?: FetchLike
50
+ bundledModelsPath?: string
51
+ now?: () => number
52
+ log?: (m: string) => void
53
+ /** Fixed port (0 = ephemeral, tests). Default: config.port, else the first free port from preferredPort. */
54
+ port?: number
55
+ preferredPort?: number
56
+ exitOnIdle?: boolean
57
+ idleMs?: number
58
+ idleCheckMs?: number
59
+ /** Backoff after UFR's model list failed to load (VPN down, UFR unreachable); the last delay repeats. */
60
+ catalogRetryMs?: number[]
61
+ onStopped?: () => void
62
+ }
63
+
64
+ export type RunningDaemon = {
65
+ port: number
66
+ token: string
67
+ router: Router
68
+ status: () => StatusJson
69
+ refreshCatalog: () => Promise<void>
70
+ stop: () => Promise<void>
71
+ }
72
+
73
+ export function pidAlive(pid: number): boolean {
74
+ try {
75
+ process.kill(pid, 0)
76
+ return true
77
+ } catch (e) {
78
+ return (e as NodeJS.ErrnoException).code === "EPERM"
79
+ }
80
+ }
81
+
82
+ /** Nonces this process has minted for locks it currently holds, keyed by lock file
83
+ * path so releaseLockNonce can forget the right one. Reserved before the lock
84
+ * file is even created, so a concurrent reader in this same process never sees
85
+ * committed lock content before its nonce is already known here. */
86
+ const heldNonces = new Set<string>()
87
+ const nonceByLock = new Map<string, string>()
88
+
89
+ export type AcquireLockOptions = {
90
+ isAlive?: (pid: number) => boolean
91
+ /** Asked only once a foreign live pid's lock is older than graceMs. */
92
+ confirmDaemon?: (pid: number) => Promise<boolean>
93
+ /** How long a foreign live pid's lock is trusted without confirmation (default 60 s). */
94
+ graceMs?: number
95
+ }
96
+
97
+ /**
98
+ * Exclusive-create lock. A lock is stale — and taken over — when: its content
99
+ * is empty/unparseable and past the just-created window; it names our own pid
100
+ * but a nonce we never issued (the pid was reused since we last held it); its
101
+ * pid is dead; or its pid is foreign, live, past the grace period, and
102
+ * confirmDaemon (when given) says no daemon answers there. A foreign live pid
103
+ * we cannot confirm (no confirmDaemon given) is left alone, as before.
104
+ */
105
+ export async function acquireLock(lockFile: string, o: AcquireLockOptions = {}): Promise<boolean> {
106
+ const isAlive = o.isAlive ?? pidAlive
107
+ const graceMs = o.graceMs ?? 60_000
108
+ await mkdir(dirname(lockFile), { recursive: true })
109
+ for (let attempt = 0; attempt < 2; attempt++) {
110
+ const nonce = randomBytes(8).toString("hex")
111
+ heldNonces.add(nonce)
112
+ try {
113
+ const fh = await open(lockFile, "wx")
114
+ await fh.writeFile(`${process.pid} ${nonce}`)
115
+ await fh.close()
116
+ nonceByLock.set(lockFile, nonce)
117
+ return true
118
+ } catch (e) {
119
+ heldNonces.delete(nonce)
120
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e
121
+ const content = (await readText(lockFile))?.trim() ?? ""
122
+ const [pidStr, existingNonce] = content.split(/\s+/)
123
+ const pid = Number(pidStr)
124
+ const st = await stat(lockFile).catch(() => null)
125
+ const ageMs = st ? Date.now() - st.mtimeMs : Number.POSITIVE_INFINITY
126
+ if (!Number.isInteger(pid) || pid <= 0) {
127
+ if (ageMs < 10_000) return false // being created right now
128
+ } else if (pid === process.pid) {
129
+ if (existingNonce && heldNonces.has(existingNonce)) return false // held by this process
130
+ // else: our own pid, but a nonce we never issued — the pid was reused
131
+ } else if (!isAlive(pid)) {
132
+ // dead pid — stale
133
+ } else if (ageMs < graceMs) {
134
+ return false // a daemon looks to be starting; give it the grace period
135
+ } else if (o.confirmDaemon) {
136
+ if (await o.confirmDaemon(pid)) return false
137
+ // confirmDaemon found no daemon there — stale, take over
138
+ } else {
139
+ return false // no way to verify a foreign live pid — the old, conservative default
140
+ }
141
+ await rm(lockFile, { force: true })
142
+ }
143
+ }
144
+ return false
145
+ }
146
+
147
+ /** Forgets the nonce for a lock this process released, so a later reuse of our
148
+ * own pid (containers, Windows) is never mistaken for a lock we still hold. */
149
+ export function releaseLockNonce(lockFile: string): void {
150
+ const nonce = nonceByLock.get(lockFile)
151
+ if (nonce) heldNonces.delete(nonce)
152
+ nonceByLock.delete(lockFile)
153
+ }
154
+
155
+ /** The local token survives restarts so the provider opencode registered keeps working. */
156
+ export async function loadOrCreateToken(file: string): Promise<string> {
157
+ const t = (await readText(file))?.trim()
158
+ if (t && /^[0-9a-f]{64}$/.test(t)) return t
159
+ const token = randomBytes(32).toString("hex")
160
+ await writeFileAtomic(file, token + "\n", 0o600)
161
+ return token
162
+ }
163
+
164
+ export async function startDaemon(o: DaemonOptions): Promise<RunningDaemon> {
165
+ const now = o.now ?? Date.now
166
+ const log = o.log ?? ((m: string) => console.error(`[opencode-ufr] ${m}`))
167
+ const f: FetchLike = o.fetch ?? ((u, i) => fetch(u, i))
168
+ const p = o.paths
169
+ const config = await loadConfig(p.configFile)
170
+ const confirmDaemon = async (pid: number): Promise<boolean> => {
171
+ const saved = (await readJson(p.daemonFile)) as { pid?: number; port?: number } | null
172
+ if (!saved || saved.pid !== pid || typeof saved.port !== "number") return false
173
+ try {
174
+ const res = await fetch(`http://127.0.0.1:${saved.port}/health`, { signal: AbortSignal.timeout(2_000) })
175
+ if (!res.ok) return false
176
+ const body = (await res.json()) as { pid?: number }
177
+ return body.pid === pid
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+ if (!(await acquireLock(p.lockFile, { confirmDaemon }))) throw new AlreadyRunningError()
183
+
184
+ let stats: Stats | null = null
185
+ let abortStartup = () => {}
186
+ try {
187
+ const token = await loadOrCreateToken(p.tokenFile)
188
+
189
+ const keyInfos: KeyInfo[] = []
190
+ for (const alias of config.keys) {
191
+ const secret = await o.secrets.get(alias)
192
+ if (secret) keyInfos.push({ alias, secret })
193
+ else log(`key "${alias}" is in config.json but not in the keyring — skipped`)
194
+ }
195
+
196
+ // Built-in VPN ("auto" transport): logs in to the uni Fortinet gateway and
197
+ // tunnels UFR calls through a userspace TCP stack when direct fails.
198
+ let vpn: VpnManager | null = null
199
+ if (config.transport.type === "auto") {
200
+ const [user, pass] = await Promise.all([o.secrets.get(VPN_USER), o.secrets.get(VPN_PASS)])
201
+ vpn = new VpnManager({
202
+ gateway: config.vpn.gateway,
203
+ upstreamHost: new URL(config.upstream.baseUrl).hostname,
204
+ baseUrl: config.upstream.baseUrl,
205
+ credentials: user && pass ? { user, pass } : null,
206
+ mode: config.vpn.mode,
207
+ log,
208
+ fetch: f,
209
+ })
210
+ }
211
+ const transport = createTransport(config.transport, { vpn })
212
+ const keys = new KeyPool(keyInfos, {
213
+ cap: config.limits.keyRpm,
214
+ windowMs: config.limits.keyWindowS * 1000,
215
+ maxWaitMs: config.limits.keyMaxWaitS * 1000,
216
+ now,
217
+ })
218
+ const pool = new SlidingWindow({
219
+ cap: config.limits.poolPerHour,
220
+ windowMs: config.limits.poolWindowS * 1000,
221
+ maxWaitMs: config.limits.poolMaxWaitS * 1000,
222
+ now,
223
+ })
224
+ const breakers = new BreakerRegistry({
225
+ tripThreshold: config.breaker.tripThreshold,
226
+ ladderMs: config.breaker.ladderS.map((s) => s * 1000),
227
+ probeTimeoutMs: config.breaker.probeTimeoutS * 1000,
228
+ now,
229
+ })
230
+ const db = new Stats(p.statsDb)
231
+ stats = db
232
+ db.prune(now() - 90 * 86_400_000)
233
+ pool.seed(db.poolAdmissionsSince(now() - config.limits.poolWindowS * 1000))
234
+ const saved = db.getKv("breakers")
235
+ if (saved) {
236
+ try {
237
+ breakers.restore(JSON.parse(saved))
238
+ } catch {
239
+ log("stored breaker state unreadable — starting closed")
240
+ }
241
+ }
242
+
243
+ const timers: ReturnType<typeof setInterval>[] = []
244
+ let stopping = false
245
+ // UFR's model list failed (VPN not up yet, UFR unreachable): retry with backoff
246
+ // (60 s → 120 s → 300 s → 900 s, then every 900 s; no step longer than refreshHours)
247
+ // so connecting the VPN later needs no daemon restart.
248
+ const retryMs = o.catalogRetryMs ?? [60_000, 120_000, 300_000, 900_000]
249
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
250
+ let retries = 0
251
+ const clearCatalogRetry = () => {
252
+ if (retryTimer) clearTimeout(retryTimer)
253
+ retryTimer = null
254
+ retries = 0
255
+ }
256
+ abortStartup = () => {
257
+ stopping = true // a failed start must not leave a retry timer behind
258
+ clearCatalogRetry()
259
+ }
260
+ const scheduleCatalogRetry = () => {
261
+ if (stopping || retryTimer) return
262
+ const delay = Math.min(retryMs[Math.min(retries, retryMs.length - 1)]!, config.catalog.refreshHours * 3_600_000)
263
+ retries++
264
+ retryTimer = setTimeout(() => {
265
+ retryTimer = null
266
+ refreshCatalog().catch((e) => {
267
+ log(`catalog refresh failed: ${(e as Error).message}`)
268
+ scheduleCatalogRetry()
269
+ })
270
+ }, delay)
271
+ }
272
+
273
+ const reach: StatusJson["upstream"] = { ok: null, message: "", at: 0 }
274
+ let catalog: Catalog = EMPTY_CATALOG
275
+ let catalogInfo: Omit<StatusJson["catalog"], "models" | "warnings"> = { source: "none", ufrSource: "none", loadedAt: 0 }
276
+ const refreshCatalog = async () => {
277
+ const mf = await loadModelsFile({ url: config.catalog.url, cachePath: p.modelsCache, etagPath: p.modelsEtag,
278
+ bundledPath: o.bundledModelsPath, fetch: f, log })
279
+ const ufr = await loadUfrModels({ baseUrl: config.upstream.baseUrl, key: keyInfos[0]?.secret ?? null,
280
+ cachePath: p.ufrModelsCache, fetch: (u, i) => transport.fetch(u, i), log })
281
+ catalog = buildCatalog(ufr.models, mf.file, { allowPaid: config.allowPaid })
282
+ catalogInfo = { source: mf.source, ufrSource: ufr.source, loadedAt: now() }
283
+ if (ufr.error) Object.assign(reach, { ok: false, message: ufr.error, at: now() })
284
+ else if (reach.ok !== true) Object.assign(reach, { ok: true, message: "", at: now() })
285
+ for (const w of catalog.warnings) log(w)
286
+ // Keys are read once at start: without one a retry can never succeed.
287
+ if (ufr.error && keyInfos.length > 0) scheduleCatalogRetry()
288
+ else clearCatalogRetry()
289
+ }
290
+ await refreshCatalog()
291
+
292
+ const router = new Router({
293
+ config,
294
+ catalog: () => catalog,
295
+ keys,
296
+ pool,
297
+ breakers,
298
+ stats: db,
299
+ transport,
300
+ now,
301
+ sleep,
302
+ onUpstream: (ok, message) => Object.assign(reach, { ok, message, at: now() }),
303
+ })
304
+
305
+ const startedAt = now()
306
+ let lastActivity = now()
307
+ let port = 0
308
+ const status = (): StatusJson => ({
309
+ version: VERSION,
310
+ pid: process.pid,
311
+ port,
312
+ uptimeMs: now() - startedAt,
313
+ inFlight: router.inFlight,
314
+ now: now(),
315
+ upstream: { ...reach },
316
+ keys: keys.snapshot(),
317
+ pool: pool.snapshot(),
318
+ breakers: breakers.states(),
319
+ catalog: { ...catalogInfo, models: catalog.models.size, warnings: catalog.warnings },
320
+ vpn: vpn ? { mode: vpn.status.mode, detail: vpn.status.detail } : null,
321
+ spendToday: db.spendByKeySince(startOfLocalDay(now())),
322
+ dailyBudgetUsd: config.dailyBudgetUsd,
323
+ })
324
+
325
+ let server: ReturnType<typeof startServer> | null = null
326
+ const stop = async () => {
327
+ if (stopping) return
328
+ stopping = true
329
+ for (const t of timers) clearInterval(t)
330
+ clearCatalogRetry()
331
+ try {
332
+ db.setKv("breakers", JSON.stringify(breakers.snapshot()))
333
+ } catch {
334
+ // db already closed
335
+ }
336
+ server?.stop()
337
+ db.close()
338
+ await vpn?.stop()
339
+ await rm(p.daemonFile, { force: true })
340
+ await rm(p.lockFile, { force: true })
341
+ releaseLockNonce(p.lockFile)
342
+ log("stopped")
343
+ o.onStopped?.()
344
+ }
345
+
346
+ const fixed = o.port !== undefined || config.port !== null
347
+ const first = o.port ?? config.port ?? o.preferredPort ?? 47300
348
+ for (let candidate = first, i = 0; i < (fixed ? 1 : 20); i++, candidate++) {
349
+ try {
350
+ server = startServer({
351
+ port: candidate,
352
+ token,
353
+ version: VERSION,
354
+ router,
355
+ models: () => listModels(catalog),
356
+ status,
357
+ onActivity: () => {
358
+ lastActivity = now()
359
+ },
360
+ onShutdown: () => void stop(),
361
+ })
362
+ break
363
+ } catch (e) {
364
+ const inUse = /EADDRINUSE|in use/i.test(`${(e as { code?: string }).code ?? ""} ${(e as Error).message}`)
365
+ if (!inUse) throw e
366
+ if (fixed) throw new Error(`port ${candidate} is in use by another program — change "port" in ${p.configFile}`)
367
+ }
368
+ }
369
+ if (!server) throw new Error(`no free port between ${first} and ${first + 19}`)
370
+ port = server.port
371
+ if (!fixed) {
372
+ config.port = port
373
+ await saveConfig(p.configFile, config)
374
+ }
375
+ await writeFileAtomic(p.daemonFile, JSON.stringify({ port, pid: process.pid, version: VERSION, startedAt }) + "\n", 0o600)
376
+
377
+ timers.push(setInterval(() => {
378
+ refreshCatalog().catch((e) => log(`catalog refresh failed: ${(e as Error).message}`))
379
+ }, config.catalog.refreshHours * 3_600_000))
380
+ timers.push(setInterval(() => db.setKv("breakers", JSON.stringify(breakers.snapshot())), 30_000))
381
+ timers.push(setInterval(() => db.prune(now() - 90 * 86_400_000), 86_400_000))
382
+ if (o.exitOnIdle !== false) {
383
+ const idleMs = o.idleMs ?? config.idleShutdownMin * 60_000
384
+ timers.push(setInterval(() => {
385
+ if (now() - lastActivity > idleMs && router.inFlight === 0) void stop()
386
+ }, o.idleCheckMs ?? 30_000))
387
+ }
388
+ log(`listening on 127.0.0.1:${port} with ${keyInfos.length} key(s), ${catalog.models.size} models`)
389
+ return { port, token, router, status, refreshCatalog, stop }
390
+ } catch (e) {
391
+ abortStartup()
392
+ stats?.close()
393
+ await rm(p.lockFile, { force: true })
394
+ releaseLockNonce(p.lockFile)
395
+ throw e
396
+ }
397
+ }
@@ -0,0 +1,41 @@
1
+ import type { ModelsFile } from "../shared/models-file"
2
+ import type { Model } from "./model"
3
+
4
+ /**
5
+ * Rate-limit fallbacks from one total order: a model may only fall back to
6
+ * models strictly later in `free_escape_order`. That gives, for free: no
7
+ * cycles, bounded length, and only maintainer-chosen targets. Hand-written
8
+ * lists caused the author's former proxy's 2026-08-20 crash-loop and 2026-08-21 cascade.
9
+ */
10
+ export function deriveChains(
11
+ models: Map<string, Model>,
12
+ fb: ModelsFile["fallbacks"],
13
+ opts: { allowPaid: boolean },
14
+ ): Map<string, string[]> {
15
+ const order = fb.free_escape_order.filter((id) => {
16
+ const t = models.get(id)
17
+ return t !== undefined && (opts.allowPaid || t.tier === "free")
18
+ })
19
+ const chains = new Map<string, string[]>()
20
+ for (const [id, m] of models) {
21
+ if (id === fb.context_hub || !m.tools || !m.hasEntry) continue
22
+ let targets = order.includes(id) ? order.slice(order.indexOf(id) + 1) : order.filter((t) => t !== id)
23
+ targets = targets.filter((t) => !m.vision || models.get(t)!.vision).slice(0, fb.max_targets)
24
+ if (targets.length > 0) chains.set(id, targets)
25
+ }
26
+ return chains
27
+ }
28
+
29
+ /** Context-overflow escapes: anything smaller than the hub may retry on it (one hop). */
30
+ export function deriveContextChains(
31
+ models: Map<string, Model>,
32
+ hub: string | null,
33
+ opts: { allowPaid: boolean },
34
+ ): Map<string, string[]> {
35
+ const out = new Map<string, string[]>()
36
+ if (!hub) return out
37
+ const h = models.get(hub)
38
+ if (!h || (h.tier === "paid" && !opts.allowPaid)) return out
39
+ for (const [id, m] of models) if (id !== hub && m.tools && m.context < h.context) out.set(id, [hub])
40
+ return out
41
+ }
@@ -0,0 +1,109 @@
1
+ import { SlidingWindow } from "./window"
2
+
3
+ export type KeyInfo = { alias: string; secret: string }
4
+ export type Acquired = { kind: "ok"; alias: string; secret: string; waitMs: number; at: number }
5
+ export type NotAcquired = { kind: "none"; reason: "no_keys" | "all_tried" | "exhausted"; retryAfterMs: number }
6
+ export type KeySnapshot = { alias: string; used: number; cap: number; blockedForMs: number; invalid: boolean }
7
+
8
+ type Slot = KeyInfo & {
9
+ window: SlidingWindow
10
+ blockedUntil: number
11
+ invalid: boolean
12
+ recent429: { at: number; model: string }[]
13
+ }
14
+
15
+ export class KeyPool {
16
+ private readonly slots: Slot[]
17
+ private rr = 0
18
+
19
+ constructor(
20
+ keys: KeyInfo[],
21
+ private readonly o: { cap: number; windowMs: number; maxWaitMs: number; now: () => number },
22
+ ) {
23
+ this.slots = keys.map((k) => ({
24
+ ...k,
25
+ window: new SlidingWindow({ cap: o.cap, windowMs: o.windowMs, maxWaitMs: o.maxWaitMs, now: o.now }),
26
+ blockedUntil: 0,
27
+ invalid: false,
28
+ recent429: [],
29
+ }))
30
+ }
31
+
32
+ /** Number of keys not marked invalid. */
33
+ get size(): number {
34
+ return this.slots.filter((s) => !s.invalid).length
35
+ }
36
+
37
+ private find(alias: string): Slot | undefined {
38
+ return this.slots.find((s) => s.alias === alias)
39
+ }
40
+
41
+ private availableAt(s: Slot): number {
42
+ return Math.max(s.blockedUntil, this.o.now() + s.window.nextFreeInMs())
43
+ }
44
+
45
+ acquire(exclude: ReadonlySet<string> = new Set()): Acquired | NotAcquired {
46
+ const now = this.o.now()
47
+ const valid = this.slots.filter((s) => !s.invalid)
48
+ if (valid.length === 0) return { kind: "none", reason: "no_keys", retryAfterMs: 0 }
49
+ const usable = valid.filter((s) => !exclude.has(s.alias))
50
+ if (usable.length === 0) return { kind: "none", reason: "all_tried", retryAfterMs: 0 }
51
+
52
+ const ready = usable.filter((s) => this.availableAt(s) <= now)
53
+ if (ready.length > 0) {
54
+ const best = Math.max(...ready.map((s) => s.window.headroom()))
55
+ const tied = ready.filter((s) => s.window.headroom() === best)
56
+ const pick = tied[this.rr++ % tied.length]!
57
+ const r = pick.window.reserve()
58
+ return { kind: "ok", alias: pick.alias, secret: pick.secret, waitMs: 0, at: r.at }
59
+ }
60
+
61
+ let soonest = usable[0]!
62
+ for (const s of usable) if (this.availableAt(s) < this.availableAt(soonest)) soonest = s
63
+ const at = this.availableAt(soonest)
64
+ const waitMs = at - now
65
+ if (waitMs <= this.o.maxWaitMs) {
66
+ soonest.window.reserveAt(at)
67
+ return { kind: "ok", alias: soonest.alias, secret: soonest.secret, waitMs, at }
68
+ }
69
+ return { kind: "none", reason: "exhausted", retryAfterMs: waitMs }
70
+ }
71
+
72
+ release(alias: string, at: number): void {
73
+ this.find(alias)?.window.release(at)
74
+ }
75
+
76
+ /**
77
+ * UFR said 429 on this key. Bucket, model wall and daily $ cap look identical,
78
+ * so only cool the key down when the bucket is the plausible cause.
79
+ */
80
+ onRateLimited(alias: string, model: string): void {
81
+ const s = this.find(alias)
82
+ if (!s) return
83
+ const now = this.o.now()
84
+ s.recent429 = s.recent429.filter((e) => e.at > now - this.o.windowMs)
85
+ s.recent429.push({ at: now, model })
86
+ const usedHalf = s.window.inWindow() >= Math.max(1, Math.floor(this.o.cap / 2))
87
+ const acrossModels = new Set(s.recent429.map((e) => e.model)).size >= 2
88
+ if (usedHalf || acrossModels) {
89
+ // UFR frees a slot at first admission + window; rejected calls do not count.
90
+ s.blockedUntil = Math.max(s.blockedUntil, (s.window.oldest() ?? now) + this.o.windowMs, now + 1_000)
91
+ }
92
+ }
93
+
94
+ onInvalid(alias: string): void {
95
+ const s = this.find(alias)
96
+ if (s) s.invalid = true
97
+ }
98
+
99
+ snapshot(): KeySnapshot[] {
100
+ const now = this.o.now()
101
+ return this.slots.map((s) => ({
102
+ alias: s.alias,
103
+ used: s.window.inWindow(),
104
+ cap: this.o.cap,
105
+ blockedForMs: Math.max(0, s.blockedUntil - now),
106
+ invalid: s.invalid,
107
+ }))
108
+ }
109
+ }
@@ -0,0 +1,15 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+
4
+ /** Append-only log, rotated to <file>.1 past maxBytes. Callers never pass key values. */
5
+ export function createLogger(file: string, maxBytes = 1_000_000): (msg: string) => void {
6
+ mkdirSync(dirname(file), { recursive: true })
7
+ return (msg) => {
8
+ try {
9
+ if (statSync(file).size > maxBytes) renameSync(file, `${file}.1`)
10
+ } catch {
11
+ // no log file yet
12
+ }
13
+ appendFileSync(file, `${new Date().toISOString()} ${msg}\n`)
14
+ }
15
+ }