opencode-ufr 0.2.9 → 0.2.10

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.
@@ -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: 18,
36
+ keyRpm: 19,
36
37
  keyWindowS: 60,
37
38
  keyMaxWaitS: 60,
38
- poolPerHour: 800,
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> {
@@ -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 CLI (`ufr connect`) and by the plugin's
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") || removedAliases.length > 0
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
+ }
@@ -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, code: status } }), { status, headers })
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
- await writeFile(tmp, data, mode === undefined ? {} : { mode })
9
- await rename(tmp, file)
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
@@ -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 as unknown as Backend) {}
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
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { main } from "../src/cli/index"
3
-
4
- process.exit(await main(process.argv.slice(2)))
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }