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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/ufr.ts +4 -0
- package/models.json +206 -0
- package/package.json +16 -0
- package/src/cli/catalog.ts +45 -0
- package/src/cli/connect.ts +146 -0
- package/src/cli/daemon-client.ts +19 -0
- package/src/cli/index.ts +93 -0
- package/src/cli/io.ts +98 -0
- package/src/cli/keys.ts +95 -0
- package/src/cli/stats.ts +37 -0
- package/src/cli/status.ts +48 -0
- package/src/cli/stop.ts +8 -0
- package/src/cli/ufr-check.ts +43 -0
- package/src/daemon/breaker.ts +146 -0
- package/src/daemon/catalog-source.ts +85 -0
- package/src/daemon/catalog.ts +127 -0
- package/src/daemon/daemon.ts +397 -0
- package/src/daemon/fallbacks.ts +41 -0
- package/src/daemon/keypool.ts +109 -0
- package/src/daemon/log.ts +15 -0
- package/src/daemon/main.ts +18 -0
- package/src/daemon/model.ts +15 -0
- package/src/daemon/router.ts +392 -0
- package/src/daemon/server.ts +54 -0
- package/src/daemon/stats.ts +105 -0
- package/src/daemon/transport.ts +21 -0
- package/src/daemon/upstream.ts +68 -0
- package/src/daemon/vpn/fortinet.ts +466 -0
- package/src/daemon/vpn/ip.ts +176 -0
- package/src/daemon/vpn/manager.ts +258 -0
- package/src/daemon/vpn/ppp.ts +450 -0
- package/src/daemon/vpn/proxy.ts +102 -0
- package/src/daemon/vpn/stack.ts +140 -0
- package/src/daemon/vpn/tcp.ts +356 -0
- package/src/daemon/window.ts +126 -0
- package/src/plugin/connect.ts +113 -0
- package/src/plugin/ensure-daemon.ts +101 -0
- package/src/plugin/index.ts +93 -0
- package/src/plugin/model-info.ts +35 -0
- package/src/shared/config.ts +170 -0
- package/src/shared/connect.ts +100 -0
- package/src/shared/errors.ts +6 -0
- package/src/shared/fs.ts +31 -0
- package/src/shared/models-file.ts +68 -0
- package/src/shared/paths.ts +62 -0
- package/src/shared/secrets.ts +62 -0
- package/src/shared/sleep.ts +15 -0
- package/src/shared/time.ts +6 -0
- package/src/shared/version.ts +3 -0
- 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
|
+
}
|