opencode-ufr 0.2.9 → 0.2.11
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/README.md +55 -62
- package/dist/tui.js +155 -0
- package/package.json +20 -7
- package/skills/pdf2md/SKILL.md +32 -0
- package/src/client/gateway.ts +101 -0
- package/src/client/pdf2md.ts +747 -0
- package/src/daemon/catalog-source.ts +9 -0
- package/src/daemon/daemon.ts +70 -18
- package/src/daemon/keypool.ts +12 -7
- package/src/daemon/main.ts +7 -1
- package/src/daemon/probe-all.ts +60 -38
- package/src/daemon/router.ts +215 -17
- package/src/daemon/server.ts +33 -1
- package/src/daemon/stats.ts +13 -1
- package/src/daemon/upstream.ts +14 -3
- package/src/daemon/vpn/fortinet.ts +42 -29
- package/src/daemon/vpn/manager.ts +50 -16
- package/src/daemon/vpn/proxy.ts +3 -1
- package/src/plugin/connect.ts +4 -4
- package/src/plugin/ensure-daemon.ts +48 -19
- package/src/plugin/index.ts +121 -46
- package/src/plugin/tui.tsx +77 -0
- package/src/shared/config.ts +53 -4
- package/src/shared/connect.ts +3 -4
- package/src/shared/daemon-client.ts +29 -0
- package/src/shared/errors.ts +1 -1
- package/src/shared/fs.ts +8 -3
- package/src/shared/keys.ts +65 -0
- package/src/shared/models-file.ts +1 -1
- package/src/shared/secrets.ts +2 -6
- package/bin/ufr.ts +0 -4
- package/src/cli/catalog.ts +0 -44
- package/src/cli/connect.ts +0 -146
- package/src/cli/context-probe.ts +0 -96
- package/src/cli/daemon-client.ts +0 -19
- package/src/cli/index.ts +0 -105
- package/src/cli/io.ts +0 -98
- package/src/cli/keys.ts +0 -130
- package/src/cli/stats.ts +0 -37
- package/src/cli/status.ts +0 -48
- package/src/cli/stop.ts +0 -8
- package/src/cli/ufr-check.ts +0 -43
package/src/shared/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readText, writeFileAtomic } from "./fs"
|
|
2
|
+
import { VPN_PASS, VPN_USER } from "./secrets"
|
|
2
3
|
|
|
3
4
|
export type Config = {
|
|
4
5
|
schema: 1
|
|
@@ -32,10 +33,10 @@ export const DEFAULTS: Config = {
|
|
|
32
33
|
upstream: { baseUrl: "https://openwebui.uni-freiburg.de/api", requestTimeoutS: 600 },
|
|
33
34
|
keys: [],
|
|
34
35
|
limits: {
|
|
35
|
-
keyRpm:
|
|
36
|
+
keyRpm: 19,
|
|
36
37
|
keyWindowS: 60,
|
|
37
38
|
keyMaxWaitS: 60,
|
|
38
|
-
poolPerHour:
|
|
39
|
+
poolPerHour: 0, // 0 = pool limiter off. The old ~900/h model-group wall is gone (verified 2026-10-06); set a number to re-enable.
|
|
39
40
|
poolWindowS: 3600,
|
|
40
41
|
poolMaxWaitS: 20,
|
|
41
42
|
maxUpstreamAttempts: 4,
|
|
@@ -48,6 +49,26 @@ export const DEFAULTS: Config = {
|
|
|
48
49
|
|
|
49
50
|
export class ConfigError extends Error {}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Keys previous versions accepted and silently ignored. A legacy config must
|
|
54
|
+
* not brick startup over them: they are dropped (with a warning) instead of
|
|
55
|
+
* rejected, so the next saveConfig writes them out of the file for good.
|
|
56
|
+
*/
|
|
57
|
+
const REMOVED: Record<string, string> = {
|
|
58
|
+
catalog: "obsolete — models.json ships with the package; remove this key",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deleteKey(obj: Record<string, unknown>, path: string): void {
|
|
62
|
+
const parts = path.split(".")
|
|
63
|
+
const last = parts.pop()!
|
|
64
|
+
let o: unknown = obj
|
|
65
|
+
for (const k of parts) {
|
|
66
|
+
if (!isObj(o)) return
|
|
67
|
+
o = o[k]
|
|
68
|
+
}
|
|
69
|
+
if (isObj(o)) delete o[last]
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
type Rule = "int>=0" | "int>=1" | "num>=0" | "bool" | "str" | "port|null" | "aliases" | "int>=1[]" | "transport" | "vpnMode"
|
|
52
73
|
|
|
53
74
|
const RULES: Record<string, Rule> = {
|
|
@@ -128,12 +149,19 @@ function valid(v: unknown, rule: Rule): boolean {
|
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
131
|
-
export function mergeConfig(raw: unknown): Config {
|
|
152
|
+
export function mergeConfig(raw: unknown, warn?: (msg: string) => void): Config {
|
|
132
153
|
if (raw !== undefined && !isObj(raw)) throw new ConfigError("config: the top level must be a JSON object")
|
|
133
154
|
if (isObj(raw) && raw.schema !== undefined && raw.schema !== 1) {
|
|
134
155
|
throw new ConfigError(`config: unsupported schema ${JSON.stringify(raw.schema)} (this version reads schema 1)`)
|
|
135
156
|
}
|
|
136
157
|
const cfg = deepMerge(structuredClone(DEFAULTS), raw ?? {}) as Config
|
|
158
|
+
for (const [key, why] of Object.entries(REMOVED)) {
|
|
159
|
+
if (get(cfg, key) === undefined) continue
|
|
160
|
+
deleteKey(cfg as unknown as Record<string, unknown>, key)
|
|
161
|
+
warn?.(`config: dropping removed key "${key}" — ${why}`)
|
|
162
|
+
}
|
|
163
|
+
// Type validation first: a present-but-mistyped section (e.g. "limits": null)
|
|
164
|
+
// must be reported as a type error, not as an unknown key.
|
|
137
165
|
for (const [path, rule] of Object.entries(RULES)) {
|
|
138
166
|
const v = get(cfg, path)
|
|
139
167
|
if (!valid(v, rule)) throw new ConfigError(`config: ${path} is invalid (${JSON.stringify(v)}), expected ${rule}`)
|
|
@@ -143,10 +171,31 @@ export function mergeConfig(raw: unknown): Config {
|
|
|
143
171
|
throw new ConfigError(`config: ${path} is too large (${JSON.stringify(v)}), ${what} at most ${max}`)
|
|
144
172
|
}
|
|
145
173
|
}
|
|
174
|
+
const unknown = unknownKeys(cfg as unknown as Record<string, unknown>)
|
|
175
|
+
if (unknown.length > 0) {
|
|
176
|
+
throw new ConfigError(`config: unknown key ${unknown.map((k) => JSON.stringify(k)).join(", ")} (typo?)`)
|
|
177
|
+
}
|
|
146
178
|
if (new Set(cfg.keys).size !== cfg.keys.length) throw new ConfigError("config: keys contains duplicate aliases")
|
|
179
|
+
if (cfg.keys.includes(VPN_USER) || cfg.keys.includes(VPN_PASS)) {
|
|
180
|
+
throw new ConfigError(
|
|
181
|
+
`config: keys must not use the reserved names "${VPN_USER}" and "${VPN_PASS}" — they hold the uni login in the same keyring`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
147
184
|
return { ...cfg, schema: 1 }
|
|
148
185
|
}
|
|
149
186
|
|
|
187
|
+
/** Paths in the merged config that no rule covers — almost always a typo (e.g. "keyrpm" for "keyRpm"). */
|
|
188
|
+
function unknownKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
|
189
|
+
const out: string[] = []
|
|
190
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
191
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
192
|
+
if (path === "schema" || Object.hasOwn(RULES, path)) continue // schema is checked above
|
|
193
|
+
if (isObj(v) && Object.keys(RULES).some((p) => p.startsWith(`${path}.`))) out.push(...unknownKeys(v, path))
|
|
194
|
+
else out.push(path)
|
|
195
|
+
}
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
150
199
|
export async function loadConfig(file: string): Promise<Config> {
|
|
151
200
|
const text = await readText(file)
|
|
152
201
|
if (text === null) return structuredClone(DEFAULTS)
|
|
@@ -156,7 +205,7 @@ export async function loadConfig(file: string): Promise<Config> {
|
|
|
156
205
|
} catch (e) {
|
|
157
206
|
throw new ConfigError(`config: ${file} is not valid JSON (${(e as Error).message})`)
|
|
158
207
|
}
|
|
159
|
-
return mergeConfig(raw)
|
|
208
|
+
return mergeConfig(raw, (m) => console.warn(`[opencode-ufr] ${m}`))
|
|
160
209
|
}
|
|
161
210
|
|
|
162
211
|
export async function saveConfig(file: string, cfg: Config): Promise<void> {
|
package/src/shared/connect.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared connect logic: parsing key lists and applying credentials to the
|
|
3
|
-
* keyring + config. Used by the
|
|
4
|
-
* /connect integration watcher.
|
|
3
|
+
* keyring + config. Used by the plugin's /connect integration watcher.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import { loadConfig, saveConfig } from "./config"
|
|
@@ -65,7 +64,7 @@ export async function applyConnect(
|
|
|
65
64
|
return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
|
|
66
65
|
}
|
|
67
66
|
if (input.login && !EMAIL_RE.test(input.login.trim())) {
|
|
68
|
-
errors.push(`"${input.login}" does not look like a uni login`)
|
|
67
|
+
errors.push(`"${input.login.trim()}" does not look like a uni login`)
|
|
69
68
|
return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -83,7 +82,7 @@ export async function applyConnect(
|
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
const aliases: { alias: string; key: string }[] = []
|
|
86
|
-
let changed = beforeKeys !== [...custom, ...wanted].join("\n")
|
|
85
|
+
let changed = beforeKeys !== [...custom, ...wanted].join("\n")
|
|
87
86
|
for (let i = 0; i < keys.length; i++) {
|
|
88
87
|
const alias = wanted[i]!
|
|
89
88
|
if ((await o.secrets.get(alias)) !== keys[i]!) changed = true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FetchLike } from "../daemon/catalog-source"
|
|
2
|
+
import { readJson, readText } from "./fs"
|
|
3
|
+
import type { Paths } from "./paths"
|
|
4
|
+
|
|
5
|
+
/** What daemon.json holds (written by the daemon at startup). */
|
|
6
|
+
export type DaemonInfo = { port: number; pid: number; version: string }
|
|
7
|
+
|
|
8
|
+
/** Runtime-checked read of daemon.json — the one parser for that file (used by the plugin and the daemon itself). */
|
|
9
|
+
export async function readDaemonInfo(paths: Paths): Promise<DaemonInfo | null> {
|
|
10
|
+
const j = (await readJson(paths.daemonFile)) as Record<string, unknown> | null
|
|
11
|
+
if (j === null || typeof j.port !== "number" || typeof j.version !== "string") return null
|
|
12
|
+
return { port: j.port, pid: typeof j.pid === "number" ? j.pid : 0, version: j.version }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A request to the running daemon, or null if none answers. */
|
|
16
|
+
export async function daemonRequest(paths: Paths, f: FetchLike, path: string, method = "GET"): Promise<Response | null> {
|
|
17
|
+
const info = await readDaemonInfo(paths)
|
|
18
|
+
const token = (await readText(paths.tokenFile))?.trim()
|
|
19
|
+
if (!info?.port || !token) return null
|
|
20
|
+
try {
|
|
21
|
+
return await f(`http://127.0.0.1:${info.port}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
24
|
+
signal: AbortSignal.timeout(5_000),
|
|
25
|
+
})
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
export function errorResponse(status: number, type: string, message: string, retryAfterMs?: number): Response {
|
|
3
3
|
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
4
4
|
if (retryAfterMs !== undefined && retryAfterMs > 0) headers["retry-after"] = String(Math.max(1, Math.ceil(retryAfterMs / 1000)))
|
|
5
|
-
return new Response(JSON.stringify({ error: { message, type
|
|
5
|
+
return new Response(JSON.stringify({ error: { message, type } }), { status, headers })
|
|
6
6
|
}
|
package/src/shared/fs.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
|
|
4
4
|
/** Write via a temp file + rename, so readers never see half a file. */
|
|
5
5
|
export async function writeFileAtomic(file: string, data: string, mode?: number): Promise<void> {
|
|
6
6
|
await mkdir(dirname(file), { recursive: true })
|
|
7
7
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
try {
|
|
9
|
+
await writeFile(tmp, data, mode === undefined ? {} : { mode })
|
|
10
|
+
await rename(tmp, file)
|
|
11
|
+
} catch (e) {
|
|
12
|
+
await rm(tmp, { force: true }) // don't leak the temp file when the write or rename fails
|
|
13
|
+
throw e
|
|
14
|
+
}
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/** File contents, or null if it does not exist. Other errors propagate. */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key lifecycle used by the plugin's /connect watcher: applying a key change
|
|
3
|
+
* to a running gateway and wiping everything on provider removal.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FetchLike } from "../daemon/catalog-source"
|
|
7
|
+
import { daemonRequest } from "./daemon-client"
|
|
8
|
+
import { loadConfig, saveConfig } from "./config"
|
|
9
|
+
import type { Paths } from "./paths"
|
|
10
|
+
import { VPN_PASS, VPN_USER, type SecretStore } from "./secrets"
|
|
11
|
+
|
|
12
|
+
/** A running gateway read its keys at start: stop it while idle so opencode starts it with the new set. */
|
|
13
|
+
export async function applyKeyChange(d: { paths: Paths; fetch: FetchLike; log: (m: string) => void }): Promise<void> {
|
|
14
|
+
const res = await daemonRequest(d.paths, d.fetch, "/v1/_status")
|
|
15
|
+
if (!res?.ok) return // not running — it reads the keys when opencode next starts it
|
|
16
|
+
const j = (await res.json().catch(() => null)) as { inFlight?: unknown } | null
|
|
17
|
+
const inFlight = j !== null && typeof j.inFlight === "number" ? j.inFlight : null
|
|
18
|
+
if (inFlight === null) {
|
|
19
|
+
d.log("couldn't read gateway state — not stopping")
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (inFlight !== 0) {
|
|
23
|
+
d.log("the gateway is answering a request right now — it picks up the new keys on its next restart")
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
const stopped = await daemonRequest(d.paths, d.fetch, "/v1/_shutdown", "POST")
|
|
27
|
+
d.log(stopped?.ok ? "gateway stopped — it restarts with the new keys when opencode next needs it"
|
|
28
|
+
: "could not stop the gateway — it picks up the new keys on its next restart")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DisconnectResult = { removed: string[]; loginRemoved: boolean }
|
|
32
|
+
|
|
33
|
+
/** Managed aliases key1…keyN are swept up to this n: a crash between secrets.set and saveConfig can orphan higher numbers than the current config lists. */
|
|
34
|
+
const MANAGED_SWEEP = 32
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Provider-removal cleanup: delete every stored key alias and the uni login
|
|
38
|
+
* from the keyring, clear config.keys, and stop an idle gateway (it reads its
|
|
39
|
+
* keys at start, so a later start serves nothing). Idempotent.
|
|
40
|
+
*/
|
|
41
|
+
export async function disconnectAll(d: {
|
|
42
|
+
paths: Paths
|
|
43
|
+
secrets: SecretStore
|
|
44
|
+
fetch: FetchLike
|
|
45
|
+
log: (m: string) => void
|
|
46
|
+
}): Promise<DisconnectResult> {
|
|
47
|
+
const cfg = await loadConfig(d.paths.configFile)
|
|
48
|
+
const removed: string[] = []
|
|
49
|
+
for (const alias of cfg.keys) {
|
|
50
|
+
if (await d.secrets.delete(alias)) removed.push(alias)
|
|
51
|
+
}
|
|
52
|
+
// Also sweep managed names the config may no longer list (a crash between
|
|
53
|
+
// secrets.set and saveConfig) — deletions of absent names report false.
|
|
54
|
+
for (let n = 1; n <= MANAGED_SWEEP; n++) {
|
|
55
|
+
const alias = `key${n}`
|
|
56
|
+
if (!cfg.keys.includes(alias) && (await d.secrets.delete(alias))) removed.push(alias)
|
|
57
|
+
}
|
|
58
|
+
const userRemoved = await d.secrets.delete(VPN_USER)
|
|
59
|
+
const passRemoved = await d.secrets.delete(VPN_PASS)
|
|
60
|
+
const loginRemoved = userRemoved || passRemoved
|
|
61
|
+
cfg.keys = []
|
|
62
|
+
await saveConfig(d.paths.configFile, cfg)
|
|
63
|
+
await applyKeyChange(d)
|
|
64
|
+
return { removed, loginRemoved }
|
|
65
|
+
}
|
|
@@ -34,7 +34,7 @@ function fail(path: string, why: string): never {
|
|
|
34
34
|
export function validateModelsFile(x: unknown): ModelsFile {
|
|
35
35
|
if (!isObj(x)) fail("(root)", "must be an object")
|
|
36
36
|
if (x.schema !== 1) fail("schema", `must be 1 (got ${JSON.stringify(x.schema)})`)
|
|
37
|
-
if (typeof x.updated !== "string") fail("updated", "must be a date string")
|
|
37
|
+
if (typeof x.updated !== "string" || Number.isNaN(Date.parse(x.updated))) fail("updated", "must be a date string")
|
|
38
38
|
const d = x.defaults
|
|
39
39
|
if (!isObj(d) || !posInt(d.context) || !posInt(d.max_output)) fail("defaults", "needs positive integers context and max_output")
|
|
40
40
|
const fb = x.fallbacks
|
package/src/shared/secrets.ts
CHANGED
|
@@ -12,15 +12,11 @@ export const VPN_PASS = "vpn-pass"
|
|
|
12
12
|
|
|
13
13
|
export class SecretStoreError extends Error {}
|
|
14
14
|
|
|
15
|
-
type Backend =
|
|
16
|
-
get(o: { service: string; name: string }): Promise<string | null>
|
|
17
|
-
set(o: { service: string; name: string; value: string }): Promise<void>
|
|
18
|
-
delete(o: { service: string; name: string }): Promise<boolean>
|
|
19
|
-
}
|
|
15
|
+
type Backend = Pick<typeof Bun.secrets, "get" | "set" | "delete">
|
|
20
16
|
|
|
21
17
|
/** UFR keys in the OS keyring: macOS Keychain, Windows Credential Manager, libsecret on Linux. */
|
|
22
18
|
export class KeyringStore implements SecretStore {
|
|
23
|
-
constructor(private readonly backend: Backend = Bun.secrets
|
|
19
|
+
constructor(private readonly backend: Backend = Bun.secrets) {}
|
|
24
20
|
|
|
25
21
|
private async run<T>(what: string, fn: () => Promise<T>): Promise<T> {
|
|
26
22
|
try {
|
package/bin/ufr.ts
DELETED
package/src/cli/catalog.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { UfrModel } from "../daemon/catalog"
|
|
2
|
-
import { BUNDLED_MODELS, loadBundledModels, loadUfrModels } from "../daemon/catalog-source"
|
|
3
|
-
import { loadConfig } from "../shared/config"
|
|
4
|
-
import type { ModelsFile } from "../shared/models-file"
|
|
5
|
-
import type { CliDeps } from "./index"
|
|
6
|
-
|
|
7
|
-
export function diffCatalog(ufr: UfrModel[], file: ModelsFile) {
|
|
8
|
-
const served = new Set(ufr.map((u) => u.id))
|
|
9
|
-
const aliasSpellings = new Set(Object.keys(file.aliases))
|
|
10
|
-
const canonical = ufr.filter((u) => !aliasSpellings.has(u.id))
|
|
11
|
-
return {
|
|
12
|
-
added: canonical.filter((u) => !file.models[u.id]).map((u) => u.id),
|
|
13
|
-
gone: Object.keys(file.models).filter((id) => !served.has(id)),
|
|
14
|
-
vision: canonical
|
|
15
|
-
.filter((u) => file.models[u.id]?.vision !== undefined && file.models[u.id]!.vision !== u.vision)
|
|
16
|
-
.map((u) => ({ id: u.id, ufr: u.vision, ours: file.models[u.id]!.vision! })),
|
|
17
|
-
unpriced: canonical.filter((u) => !file.models[u.id]?.price).map((u) => u.id),
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function cmdCatalogDiff(d: CliDeps): Promise<number> {
|
|
22
|
-
const cfg = await loadConfig(d.paths.configFile)
|
|
23
|
-
const alias = cfg.keys[0]
|
|
24
|
-
const key = alias ? await d.secrets.get(alias) : null
|
|
25
|
-
if (!key) {
|
|
26
|
-
d.io.err("catalog diff needs a key — run `ufr keys add <alias>`\n")
|
|
27
|
-
return 1
|
|
28
|
-
}
|
|
29
|
-
const log = (m: string) => d.io.err(`${m}\n`)
|
|
30
|
-
const mf = await loadBundledModels({ bundledPath: BUNDLED_MODELS })
|
|
31
|
-
const ufr = await loadUfrModels({ baseUrl: cfg.upstream.baseUrl, key, cachePath: d.paths.ufrModelsCache, fetch: d.fetch, log })
|
|
32
|
-
if (ufr.source !== "remote") {
|
|
33
|
-
d.io.err(`cannot read UFR's live model list: ${ufr.error}\n`)
|
|
34
|
-
return 1
|
|
35
|
-
}
|
|
36
|
-
const diff = diffCatalog(ufr.models, mf.file)
|
|
37
|
-
d.io.out(`models.json from ${mf.source}, ${ufr.models.length} models at UFR\n`)
|
|
38
|
-
for (const id of diff.added) d.io.out(`+ ${id} (new at UFR, no models.json entry)\n`)
|
|
39
|
-
for (const id of diff.gone) d.io.out(`- ${id} (in models.json, no longer at UFR)\n`)
|
|
40
|
-
for (const v of diff.vision) d.io.out(`~ ${v.id} vision: UFR says ${v.ufr}, models.json says ${v.ours}\n`)
|
|
41
|
-
for (const id of diff.unpriced) d.io.out(`$ ${id} (no price — cost unknown)\n`)
|
|
42
|
-
if (!diff.added.length && !diff.gone.length) d.io.out("no models added or removed\n")
|
|
43
|
-
return diff.added.length || diff.gone.length ? 1 : 0
|
|
44
|
-
}
|
package/src/cli/connect.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { parseArgs } from "node:util"
|
|
2
|
-
import { ALIAS_RE, loadConfig, saveConfig } from "../shared/config"
|
|
3
|
-
import { VPN_PASS, VPN_USER } from "../shared/secrets"
|
|
4
|
-
import { checkKey, type Check } from "./ufr-check"
|
|
5
|
-
import { applyKeyChange } from "./keys"
|
|
6
|
-
import type { CliDeps } from "./index"
|
|
7
|
-
|
|
8
|
-
const LABEL: Record<Check | "missing", string> = {
|
|
9
|
-
ok: "ok",
|
|
10
|
-
invalid: "INVALID (UFR says 401)",
|
|
11
|
-
vpn: "not verified — not on the uni VPN",
|
|
12
|
-
unreachable: "not verified — UFR unreachable",
|
|
13
|
-
rate_limited: "ok (currently rate-limited)",
|
|
14
|
-
missing: "MISSING in keyring",
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const PLUGIN_SPEC = "github:FinleyLaempe/opencode-ufr"
|
|
18
|
-
|
|
19
|
-
/** Splits a key list on commas AND newlines, trims whitespace, drops empties. */
|
|
20
|
-
export function splitKeys(spec: string): string[] {
|
|
21
|
-
return spec
|
|
22
|
-
.split(/[,\n\r]+/)
|
|
23
|
-
.map((k) => k.trim())
|
|
24
|
-
.filter((k) => k.length > 0)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Auto-aliases key1…keyN, skipping numbers already in use. */
|
|
28
|
-
export function nextAliases(existing: string[], count: number): string[] {
|
|
29
|
-
const used = new Set(existing)
|
|
30
|
-
const out: string[] = []
|
|
31
|
-
for (let n = 1; out.length < count; n++) {
|
|
32
|
-
const alias = `key${n}`
|
|
33
|
-
if (!used.has(alias)) {
|
|
34
|
-
used.add(alias)
|
|
35
|
-
out.push(alias)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return out
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function storeKey(d: CliDeps, alias: string, key: string): Promise<{ stored: boolean; check: Check }> {
|
|
42
|
-
const cfg = await loadConfig(d.paths.configFile)
|
|
43
|
-
const check = await checkKey(d.fetch, cfg.upstream.baseUrl, key)
|
|
44
|
-
if (check === "invalid") return { stored: false, check }
|
|
45
|
-
if (check !== "ok" && check !== "rate_limited") {
|
|
46
|
-
const why = check === "vpn" ? "not on the uni VPN" : "UFR unreachable"
|
|
47
|
-
if (!(await d.io.confirm(`could not verify this key (${why}). Store it anyway?`))) return { stored: false, check }
|
|
48
|
-
}
|
|
49
|
-
await d.secrets.set(alias, key)
|
|
50
|
-
if (!cfg.keys.includes(alias)) {
|
|
51
|
-
cfg.keys.push(alias)
|
|
52
|
-
await saveConfig(d.paths.configFile, cfg)
|
|
53
|
-
}
|
|
54
|
-
return { stored: true, check }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function stopIdleGateway(d: CliDeps): Promise<void> {
|
|
58
|
-
await applyKeyChange({ paths: d.paths, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* ufr connect — one command for everything: uni login (optional) + API keys.
|
|
63
|
-
* Interactive when arguments are missing; fully non-interactive with flags
|
|
64
|
-
* (what the /connect slash command uses).
|
|
65
|
-
*/
|
|
66
|
-
export async function cmdConnect(d: CliDeps, args: string[]): Promise<number> {
|
|
67
|
-
const { values } = parseArgs({
|
|
68
|
-
args,
|
|
69
|
-
options: {
|
|
70
|
-
login: { type: "string" },
|
|
71
|
-
password: { type: "string" },
|
|
72
|
-
keys: { type: "string" },
|
|
73
|
-
},
|
|
74
|
-
allowPositionals: true,
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const io = d.io
|
|
78
|
-
const cfg = await loadConfig(d.paths.configFile)
|
|
79
|
-
|
|
80
|
-
// keys -------------------------------------------------------------------
|
|
81
|
-
let keySpec = values.keys
|
|
82
|
-
if (keySpec === undefined && d.io.isTTY) {
|
|
83
|
-
keySpec = await io.prompt("UFR API keys (comma-separated or one per line; Open WebUI → Settings → Account → API keys): ")
|
|
84
|
-
}
|
|
85
|
-
const keys = splitKeys(keySpec ?? "")
|
|
86
|
-
if (keys.length === 0) {
|
|
87
|
-
io.err("no keys given — usage: ufr connect [--login <user> --password <pass>] --keys \"<key1>,<key2>\"\n")
|
|
88
|
-
return 2
|
|
89
|
-
}
|
|
90
|
-
const aliases = nextAliases(cfg.keys, keys.length)
|
|
91
|
-
|
|
92
|
-
// --- login (optional) ------------------------------------------------------
|
|
93
|
-
let user = values.login
|
|
94
|
-
let pass = values.password
|
|
95
|
-
if (user === undefined && d.io.isTTY) {
|
|
96
|
-
user = (await io.prompt("Uni login for the built-in VPN, e.g. xx0000@uni-freiburg.de (empty to skip): ")).trim()
|
|
97
|
-
}
|
|
98
|
-
if (user) {
|
|
99
|
-
if (!/^[^\s@]+@[^\s@]+$/.test(user)) {
|
|
100
|
-
io.err(`"${user}" does not look like a uni login (expected e.g. xx0000@uni-freiburg.de)\n`)
|
|
101
|
-
return 2
|
|
102
|
-
}
|
|
103
|
-
if (pass === undefined) {
|
|
104
|
-
if (d.io.isTTY) pass = await io.prompt(`Uni password for ${user}: `, { secret: true })
|
|
105
|
-
else {
|
|
106
|
-
io.err("--password is required when --login is given non-interactively\n")
|
|
107
|
-
return 2
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (!pass) {
|
|
111
|
-
io.err("no password entered\n")
|
|
112
|
-
return 2
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// --- store everything -------------------------------------------------------
|
|
117
|
-
let bad = 0
|
|
118
|
-
for (let i = 0; i < keys.length; i++) {
|
|
119
|
-
const alias = aliases[i]!
|
|
120
|
-
const r = await storeKey(d, alias, keys[i]!)
|
|
121
|
-
if (r.stored) io.out(`${alias}\t${LABEL[r.check]}\n`)
|
|
122
|
-
else {
|
|
123
|
-
bad++
|
|
124
|
-
io.err(`${alias}\t${LABEL[r.check]} — not stored\n`)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (bad === keys.length) return 1
|
|
128
|
-
|
|
129
|
-
if (user && pass) {
|
|
130
|
-
await d.secrets.set(VPN_USER, user)
|
|
131
|
-
await d.secrets.set(VPN_PASS, pass)
|
|
132
|
-
io.out(`login\tstored for ${user} (used by the built-in VPN)\n`)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
await stopIdleGateway(d)
|
|
136
|
-
if (d.io.isTTY) {
|
|
137
|
-
if (await io.confirm(`register the plugin with opencode now (\`opencode plugin add ${PLUGIN_SPEC}\`)?`)) {
|
|
138
|
-
const code = await d.runOpencode(["plugin", "add", PLUGIN_SPEC])
|
|
139
|
-
if (code !== 0) io.err(`opencode plugin add exited with ${code} — add "${PLUGIN_SPEC}" to "plugins" in opencode.json yourself\n`)
|
|
140
|
-
}
|
|
141
|
-
} else {
|
|
142
|
-
io.out(`register the provider in opencode if not done yet: opencode plugin add ${PLUGIN_SPEC}\n`)
|
|
143
|
-
}
|
|
144
|
-
io.out(`done — ${keys.length - bad}/${keys.length} key(s) stored${user ? ", uni login stored" : ""}\n`)
|
|
145
|
-
return bad > 0 ? 1 : 0
|
|
146
|
-
}
|
package/src/cli/context-probe.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { BUNDLED_MODELS, loadBundledModels } from "../daemon/catalog-source"
|
|
2
|
-
import { applyProbeResults, formatProbeReport, probeAllContexts } from "../daemon/probe-all"
|
|
3
|
-
import { VpnManager } from "../daemon/vpn/manager"
|
|
4
|
-
import { loadConfig } from "../shared/config"
|
|
5
|
-
import { VPN_PASS, VPN_USER } from "../shared/secrets"
|
|
6
|
-
import type { CliDeps } from "./index"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* ufr context-probe — measure every UFR model's context limit with one of the
|
|
10
|
-
* stored keys. Rejected probes are free and don't consume the key's bucket;
|
|
11
|
-
* the report flags values that drifted from models.json. `--write` patches
|
|
12
|
-
* the local models.json copy (the repo copy when run from a checkout).
|
|
13
|
-
*
|
|
14
|
-
* `--vpn` routes the probes through the built-in Fortinet tunnel with the
|
|
15
|
-
* stored uni login (vpn-login/vpn-pass) — for off campus machines without
|
|
16
|
-
* their own VPN connection.
|
|
17
|
-
*/
|
|
18
|
-
export async function cmdContextProbe(d: CliDeps, args: string[]): Promise<number> {
|
|
19
|
-
const write = args.includes("--write")
|
|
20
|
-
const viaVpn = args.includes("--vpn")
|
|
21
|
-
const cfg = await loadConfig(d.paths.configFile)
|
|
22
|
-
const alias = cfg.keys[0]
|
|
23
|
-
if (!alias) {
|
|
24
|
-
d.io.err("no stored key — run `ufr connect` first\n")
|
|
25
|
-
return 2
|
|
26
|
-
}
|
|
27
|
-
const key = await d.secrets.get(alias)
|
|
28
|
-
if (!key) {
|
|
29
|
-
d.io.err(`key "${alias}" is configured but missing from the keyring\n`)
|
|
30
|
-
return 2
|
|
31
|
-
}
|
|
32
|
-
let fetch = d.fetch
|
|
33
|
-
let vpn: { stop(): Promise<void> } | null = null
|
|
34
|
-
if (viaVpn) {
|
|
35
|
-
const [user, pass] = await Promise.all([d.secrets.get(VPN_USER), d.secrets.get(VPN_PASS)])
|
|
36
|
-
if (!user || !pass) {
|
|
37
|
-
d.io.err("no stored uni login — run `ufr login` or `ufr connect --login …` first\n")
|
|
38
|
-
return 2
|
|
39
|
-
}
|
|
40
|
-
const manager = new VpnManager({
|
|
41
|
-
gateway: cfg.vpn.gateway,
|
|
42
|
-
upstreamHost: new URL(cfg.upstream.baseUrl).hostname,
|
|
43
|
-
baseUrl: cfg.upstream.baseUrl,
|
|
44
|
-
credentials: { user, pass },
|
|
45
|
-
mode: "always",
|
|
46
|
-
log: (m) => d.io.out(`${m}\n`),
|
|
47
|
-
fetch: d.fetch,
|
|
48
|
-
})
|
|
49
|
-
vpn = manager
|
|
50
|
-
fetch = manager.transport().fetch
|
|
51
|
-
}
|
|
52
|
-
const { file } = await loadBundledModels({})
|
|
53
|
-
d.io.out(`probing ${cfg.keys.length} key(s) via "${alias}"${viaVpn ? " through the built-in VPN" : ""} — rejected probes are free, this takes a few minutes\n`)
|
|
54
|
-
try {
|
|
55
|
-
const rows = await probeAllContexts({
|
|
56
|
-
baseUrl: cfg.upstream.baseUrl,
|
|
57
|
-
key,
|
|
58
|
-
fetch,
|
|
59
|
-
file,
|
|
60
|
-
log: (m) => d.io.out(`${m}\n`),
|
|
61
|
-
})
|
|
62
|
-
d.io.out("\n" + formatProbeReport(rows) + "\n")
|
|
63
|
-
return await report(d, file, rows, write)
|
|
64
|
-
} finally {
|
|
65
|
-
if (vpn) await vpn.stop()
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function report(
|
|
70
|
-
d: CliDeps,
|
|
71
|
-
file: Awaited<ReturnType<typeof loadBundledModels>>["file"],
|
|
72
|
-
rows: Awaited<ReturnType<typeof probeAllContexts>>,
|
|
73
|
-
write: boolean,
|
|
74
|
-
): Promise<number> {
|
|
75
|
-
if (write) {
|
|
76
|
-
const date = new Date().toISOString().slice(0, 10)
|
|
77
|
-
const { file: updated, changed } = applyProbeResults(file, rows, date)
|
|
78
|
-
if (changed.length === 0) {
|
|
79
|
-
d.io.out("\nmodels.json is already current — nothing written\n")
|
|
80
|
-
} else {
|
|
81
|
-
await Bun.write(BUNDLED_MODELS, JSON.stringify(updated, null, 2) + "\n")
|
|
82
|
-
d.io.out(`\n${BUNDLED_MODELS} updated (${changed.length}):\n`)
|
|
83
|
-
for (const c of changed) d.io.out(` ${c.id}: ${c.from ?? "—"} -> ${c.to}\n`)
|
|
84
|
-
d.io.out("\nIf this is a repo checkout: commit models.json (PR welcome per README).\n")
|
|
85
|
-
d.io.out("On an installed copy this patches your local file until the next plugin/CLI update;\n")
|
|
86
|
-
d.io.out("upstream fixes reach everyone through models.json PRs and releases.\n")
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
const needsUpdate = rows.filter((r) => r.how === "error-named" && r.probed !== null && r.probed !== r.known)
|
|
90
|
-
if (needsUpdate.length > 0) {
|
|
91
|
-
d.io.out("\nrun `ufr context-probe --write` to patch your local models.json, or PR the values upstream\n")
|
|
92
|
-
return 1
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return 0
|
|
96
|
-
}
|
package/src/cli/daemon-client.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { FetchLike } from "../daemon/catalog-source"
|
|
2
|
-
import { readJson, readText } from "../shared/fs"
|
|
3
|
-
import type { Paths } from "../shared/paths"
|
|
4
|
-
|
|
5
|
-
/** A request to the running daemon, or null if none answers. */
|
|
6
|
-
export async function daemonRequest(paths: Paths, f: FetchLike, path: string, method = "GET"): Promise<Response | null> {
|
|
7
|
-
const info = (await readJson(paths.daemonFile)) as { port?: number } | null
|
|
8
|
-
const token = (await readText(paths.tokenFile))?.trim()
|
|
9
|
-
if (!info?.port || !token) return null
|
|
10
|
-
try {
|
|
11
|
-
return await f(`http://127.0.0.1:${info.port}${path}`, {
|
|
12
|
-
method,
|
|
13
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
14
|
-
signal: AbortSignal.timeout(5_000),
|
|
15
|
-
})
|
|
16
|
-
} catch {
|
|
17
|
-
return null
|
|
18
|
-
}
|
|
19
|
-
}
|