@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read OpenCode's own message history.
|
|
7
|
+
*
|
|
8
|
+
* The router's ledger only ever saw traffic it routed. Everything before the
|
|
9
|
+
* first install is invisible to it, which is why an account that was already
|
|
10
|
+
* 42% used looked untouched. That history is not actually missing: OpenCode
|
|
11
|
+
* records every message with its provider, model, tokens, cost and timestamp in
|
|
12
|
+
* a local SQLite database. Reading it gives the estimate a real starting point
|
|
13
|
+
* on day one, with no paste and no guess.
|
|
14
|
+
*
|
|
15
|
+
* Message granularity matters, not session granularity. The Design Plan window is
|
|
16
|
+
* fixed at seven days from subscription start, so an event needs its own
|
|
17
|
+
* timestamp to be bucketed into the right window; a session summary carries only
|
|
18
|
+
* the session's last-updated time and smears a week of work across one instant.
|
|
19
|
+
*
|
|
20
|
+
* Deliberate limits:
|
|
21
|
+
*
|
|
22
|
+
* - **Read-only, always.** The database is opened with `readOnly: true`. This
|
|
23
|
+
* module must never be able to damage the user's history, and the cheapest
|
|
24
|
+
* way to guarantee that is to make a write impossible.
|
|
25
|
+
* - **Optional dependency.** `node:sqlite` is experimental and absent on older
|
|
26
|
+
* Node. Every failure path returns nothing plus a reason, because history is
|
|
27
|
+
* an enhancement and the router must route without it.
|
|
28
|
+
* - **Never guesses a schema.** Only the columns this needs are read, and a row
|
|
29
|
+
* that does not parse is skipped rather than defaulted.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface HistoryEntry {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly at: number
|
|
35
|
+
readonly providerID: string
|
|
36
|
+
readonly modelID: string
|
|
37
|
+
readonly cost: number
|
|
38
|
+
readonly input: number
|
|
39
|
+
readonly output: number
|
|
40
|
+
readonly cacheRead: number
|
|
41
|
+
readonly cacheWrite: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HistoryRead {
|
|
45
|
+
readonly entries: ReadonlyArray<HistoryEntry>
|
|
46
|
+
/** Why nothing was read, when nothing was. */
|
|
47
|
+
readonly reason?: string
|
|
48
|
+
readonly dbPath?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReadHistoryOptions {
|
|
52
|
+
/** Override the database location, for tests. */
|
|
53
|
+
readonly dbPath?: string
|
|
54
|
+
readonly home?: string
|
|
55
|
+
/** Ignore anything older than this. */
|
|
56
|
+
readonly sinceMs?: number
|
|
57
|
+
readonly limit?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const defaultHistoryPath = (home = homedir()): string =>
|
|
61
|
+
join(home, ".local", "share", "opencode", "opencode.db")
|
|
62
|
+
|
|
63
|
+
const numberOrZero = (value: unknown): number => {
|
|
64
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN
|
|
65
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Turn one stored message into a history entry, or `undefined` when the row
|
|
70
|
+
* cannot support one.
|
|
71
|
+
*
|
|
72
|
+
* Only assistant messages carry a cost, and only a row with a provider, a model
|
|
73
|
+
* and a timestamp is usable. Anything else is skipped: a defaulted model would
|
|
74
|
+
* attribute real spend to the wrong provider.
|
|
75
|
+
*/
|
|
76
|
+
export const toHistoryEntry = (row: { id?: unknown; data?: unknown }): HistoryEntry | undefined => {
|
|
77
|
+
let data: any
|
|
78
|
+
try {
|
|
79
|
+
data = typeof row.data === "string" ? JSON.parse(row.data) : row.data
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
if (!data || typeof data !== "object") return undefined
|
|
84
|
+
if (data.role !== "assistant") return undefined
|
|
85
|
+
|
|
86
|
+
const providerID = typeof data.providerID === "string" ? data.providerID : undefined
|
|
87
|
+
const modelID = typeof data.modelID === "string" ? data.modelID : undefined
|
|
88
|
+
const at = typeof data.time?.created === "number" ? data.time.created : undefined
|
|
89
|
+
if (!providerID || !modelID || at === undefined) return undefined
|
|
90
|
+
|
|
91
|
+
const cost = numberOrZero(data.cost)
|
|
92
|
+
const tokens = data.tokens ?? {}
|
|
93
|
+
const entry: HistoryEntry = {
|
|
94
|
+
id: String(row.id ?? `${providerID}:${modelID}:${at}`),
|
|
95
|
+
at,
|
|
96
|
+
providerID,
|
|
97
|
+
modelID,
|
|
98
|
+
cost,
|
|
99
|
+
input: numberOrZero(tokens.input),
|
|
100
|
+
output: numberOrZero(tokens.output),
|
|
101
|
+
cacheRead: numberOrZero(tokens.cache?.read),
|
|
102
|
+
cacheWrite: numberOrZero(tokens.cache?.write),
|
|
103
|
+
}
|
|
104
|
+
// A row with no cost and no tokens is not evidence of anything.
|
|
105
|
+
if (entry.cost === 0 && entry.input + entry.output + entry.cacheRead + entry.cacheWrite === 0) return undefined
|
|
106
|
+
return entry
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read recent history, newest first.
|
|
111
|
+
*
|
|
112
|
+
* Synchronous by design: `node:sqlite`'s API is synchronous, and wrapping it in a
|
|
113
|
+
* promise would suggest it is cheap when it is blocking. Callers run it once at
|
|
114
|
+
* startup, on a bounded number of rows.
|
|
115
|
+
*/
|
|
116
|
+
export const readOpenCodeHistory = (options: ReadHistoryOptions = {}): HistoryRead => {
|
|
117
|
+
const dbPath = options.dbPath ?? defaultHistoryPath(options.home)
|
|
118
|
+
if (!existsSync(dbPath)) {
|
|
119
|
+
return { entries: [], reason: "No OpenCode database found on this machine", dbPath }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let DatabaseSync: any
|
|
123
|
+
try {
|
|
124
|
+
// Resolved lazily so a Node without the module still loads this file.
|
|
125
|
+
;({ DatabaseSync } = require("node:sqlite"))
|
|
126
|
+
} catch {
|
|
127
|
+
return { entries: [], reason: "This Node build has no node:sqlite; history import is unavailable", dbPath }
|
|
128
|
+
}
|
|
129
|
+
if (!DatabaseSync) {
|
|
130
|
+
return { entries: [], reason: "node:sqlite is unavailable; history import is skipped", dbPath }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let db: any
|
|
134
|
+
try {
|
|
135
|
+
db = new DatabaseSync(dbPath, { readOnly: true })
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// A locked database is normal while a session is writing. Not an error worth
|
|
138
|
+
// surfacing as a failure; the next start will read it.
|
|
139
|
+
return {
|
|
140
|
+
entries: [],
|
|
141
|
+
reason: `Could not open the history database read-only (${error instanceof Error ? error.message : String(error)})`,
|
|
142
|
+
dbPath,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const limit = Math.max(1, Math.min(options.limit ?? 5_000, 50_000))
|
|
148
|
+
const since = options.sinceMs
|
|
149
|
+
const sql = since
|
|
150
|
+
? "SELECT id, data FROM message WHERE time_created >= ? ORDER BY time_created DESC LIMIT ?"
|
|
151
|
+
: "SELECT id, data FROM message ORDER BY time_created DESC LIMIT ?"
|
|
152
|
+
const statement = db.prepare(sql)
|
|
153
|
+
const rows = since ? statement.all(since, limit) : statement.all(limit)
|
|
154
|
+
const entries: HistoryEntry[] = []
|
|
155
|
+
for (const row of rows as Array<{ id?: unknown; data?: unknown }>) {
|
|
156
|
+
const entry = toHistoryEntry(row)
|
|
157
|
+
if (entry) entries.push(entry)
|
|
158
|
+
}
|
|
159
|
+
return { entries, dbPath }
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return {
|
|
162
|
+
entries: [],
|
|
163
|
+
reason: `Could not read history (${error instanceof Error ? error.message : String(error)})`,
|
|
164
|
+
dbPath,
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
try {
|
|
168
|
+
db.close()
|
|
169
|
+
} catch {
|
|
170
|
+
// Closing a read-only handle that is already gone is not a problem.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ModelRecord } from "../types.js"
|
|
2
|
+
|
|
3
|
+
export type ProviderProtocol = "anthropic" | "openai-compat"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which wire protocol the provider's serving path speaks, as far as prompt
|
|
7
|
+
* shaping is concerned. The Anthropic Messages protocol is the strict one:
|
|
8
|
+
* anything but `user | assistant` as a message role is a deterministic 400.
|
|
9
|
+
* OpenAI-compatible gateways tolerate more shapes, so no repair is needed.
|
|
10
|
+
*
|
|
11
|
+
* A seeded record may carry the answer explicitly. Everything else is
|
|
12
|
+
* inferred: `claude` model names are served over the Anthropic protocol by
|
|
13
|
+
* every provider observed so far, and the remaining gateways are
|
|
14
|
+
* OpenAI-compatible including their claude relays that translate for them.
|
|
15
|
+
*/
|
|
16
|
+
export const providerProtocol = (
|
|
17
|
+
record: Pick<ModelRecord, "protocol" | "providerID" | "modelID">,
|
|
18
|
+
): ProviderProtocol => {
|
|
19
|
+
if (record.protocol === "anthropic") return "anthropic"
|
|
20
|
+
if (record.protocol === "openai-compat") return "openai-compat"
|
|
21
|
+
return /claude/i.test(`${record.providerID}/${record.modelID}`) ? "anthropic" : "openai-compat"
|
|
22
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { CostTier, ModelRecord, RouterSettings } from "../types.js"
|
|
2
|
+
import { modelKey } from "../util.js"
|
|
3
|
+
import { modelMonthlyAllowanceUsd, resolvePlan } from "../plans/catalog.js"
|
|
4
|
+
import { SEED_MODELS, type SeedModel } from "./seed.js"
|
|
5
|
+
import { OFFLINE_PROVIDER_ID } from "../offline/mode.js"
|
|
6
|
+
|
|
7
|
+
export interface LiveModel {
|
|
8
|
+
readonly id: string
|
|
9
|
+
readonly providerID: string
|
|
10
|
+
readonly name?: string
|
|
11
|
+
readonly limit?: { readonly context?: number; readonly output?: number }
|
|
12
|
+
readonly capabilities?: {
|
|
13
|
+
readonly tools?: boolean
|
|
14
|
+
readonly input?: ReadonlyArray<string>
|
|
15
|
+
readonly output?: ReadonlyArray<string>
|
|
16
|
+
}
|
|
17
|
+
readonly cost?: ReadonlyArray<CostTier>
|
|
18
|
+
readonly enabled?: boolean
|
|
19
|
+
readonly disabled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegistryBuildInput {
|
|
23
|
+
readonly live: ReadonlyArray<LiveModel>
|
|
24
|
+
readonly settings: RouterSettings
|
|
25
|
+
readonly seed?: ReadonlyArray<SeedModel>
|
|
26
|
+
readonly definitions?: ReadonlyArray<SeedModel>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const normalizeCost = (cost: ReadonlyArray<CostTier> | undefined): CostTier[] => {
|
|
30
|
+
if (!cost || cost.length === 0) return [{ input: 0, output: 0, cache: { read: 0, write: 0 } }]
|
|
31
|
+
return cost.map((tier) => ({
|
|
32
|
+
input: Number.isFinite(tier.input) ? tier.input : 0,
|
|
33
|
+
output: Number.isFinite(tier.output) ? tier.output : 0,
|
|
34
|
+
cache: {
|
|
35
|
+
read: Number.isFinite(tier.cache?.read) ? tier.cache.read : 0,
|
|
36
|
+
write: Number.isFinite(tier.cache?.write) ? tier.cache.write : 0,
|
|
37
|
+
},
|
|
38
|
+
...(tier.tier ? { tier: { type: "context" as const, size: tier.tier.size } } : {}),
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fromSeed = (seed: SeedModel): ModelRecord => ({
|
|
43
|
+
key: seed.key,
|
|
44
|
+
providerID: seed.providerID,
|
|
45
|
+
modelID: seed.modelID,
|
|
46
|
+
name: seed.name,
|
|
47
|
+
context: seed.context,
|
|
48
|
+
maxOutput: seed.maxOutput,
|
|
49
|
+
inputModalities: seed.input,
|
|
50
|
+
outputModalities: ["text"],
|
|
51
|
+
tools: seed.tools ?? true,
|
|
52
|
+
cost: seed.cost,
|
|
53
|
+
...(seed.peakPricing ? { peakPricing: seed.peakPricing } : {}),
|
|
54
|
+
intelligence: seed.intelligence,
|
|
55
|
+
design: seed.design,
|
|
56
|
+
designEvidence: seed.designEvidence ?? "curated",
|
|
57
|
+
...(seed.creditMultiplier && seed.creditMultiplier > 0 ? { creditMultiplier: seed.creditMultiplier } : {}),
|
|
58
|
+
...(seed.protocol ? { protocol: seed.protocol } : {}),
|
|
59
|
+
...(seed.escalationOnly ? { escalationOnly: true } : {}),
|
|
60
|
+
premium: seed.premium ?? false,
|
|
61
|
+
training: seed.training ?? false,
|
|
62
|
+
free: seed.free ?? false,
|
|
63
|
+
tier: seed.tier,
|
|
64
|
+
approved: seed.approved ?? true,
|
|
65
|
+
approvalSource: "seed",
|
|
66
|
+
monthlyAllowanceUsd: seed.monthlyAllowanceUsd,
|
|
67
|
+
notes: seed.notes,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const mergeLive = (record: ModelRecord, live: LiveModel): ModelRecord => ({
|
|
71
|
+
...record,
|
|
72
|
+
name: live.name && live.name.trim().length > 0 ? live.name : record.name,
|
|
73
|
+
context: live.limit?.context && live.limit.context > 0 ? live.limit.context : record.context,
|
|
74
|
+
maxOutput: live.limit?.output && live.limit.output > 0 ? live.limit.output : record.maxOutput,
|
|
75
|
+
tools: live.capabilities?.tools ?? record.tools,
|
|
76
|
+
inputModalities:
|
|
77
|
+
live.capabilities?.input && live.capabilities.input.length > 0 ? [...live.capabilities.input] : record.inputModalities,
|
|
78
|
+
outputModalities:
|
|
79
|
+
live.capabilities?.output && live.capabilities.output.length > 0 ? [...live.capabilities.output] : record.outputModalities,
|
|
80
|
+
cost: normalizeCost(live.cost ?? record.cost),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const fromLive = (live: LiveModel): ModelRecord => {
|
|
84
|
+
const key = modelKey(live.providerID, live.id)
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
providerID: live.providerID,
|
|
88
|
+
modelID: live.id,
|
|
89
|
+
name: live.name?.trim() || live.id,
|
|
90
|
+
context: live.limit?.context && live.limit.context > 0 ? live.limit.context : 0,
|
|
91
|
+
maxOutput: live.limit?.output && live.limit.output > 0 ? live.limit.output : 0,
|
|
92
|
+
inputModalities: live.capabilities?.input ? [...live.capabilities.input] : ["text"],
|
|
93
|
+
outputModalities: live.capabilities?.output ? [...live.capabilities.output] : ["text"],
|
|
94
|
+
tools: live.capabilities?.tools ?? false,
|
|
95
|
+
cost: normalizeCost(live.cost),
|
|
96
|
+
intelligence: 0,
|
|
97
|
+
design: 0,
|
|
98
|
+
designEvidence: "unverified",
|
|
99
|
+
premium: false,
|
|
100
|
+
training: /contributor|-free$|iceman/i.test(live.id),
|
|
101
|
+
free: /(^|[-/])(free|beam-free|laguna-s-2\.1-free)$/i.test(live.id),
|
|
102
|
+
tier: 2,
|
|
103
|
+
approved: false,
|
|
104
|
+
approvalSource: "discovered",
|
|
105
|
+
monthlyAllowanceUsd: 0,
|
|
106
|
+
notes: "Discovered model. Not qualified for automatic routing until approved.",
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class ModelRegistry {
|
|
111
|
+
private readonly records = new Map<string, ModelRecord>()
|
|
112
|
+
private readonly liveCount: number
|
|
113
|
+
|
|
114
|
+
constructor(input: RegistryBuildInput) {
|
|
115
|
+
const seeds = [...(input.seed ?? SEED_MODELS), ...(input.definitions ?? [])]
|
|
116
|
+
const seedByKey = new Map(seeds.map((seed) => [seed.key, seed]))
|
|
117
|
+
const liveByKey = new Map(input.live.map((model) => [modelKey(model.providerID, model.id), model]))
|
|
118
|
+
|
|
119
|
+
this.liveCount = input.live.length
|
|
120
|
+
|
|
121
|
+
for (const seed of seeds) {
|
|
122
|
+
const live = liveByKey.get(seed.key)
|
|
123
|
+
this.records.set(seed.key, live ? mergeLive(fromSeed(seed), live) : fromSeed(seed))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const live of input.live) {
|
|
127
|
+
const key = modelKey(live.providerID, live.id)
|
|
128
|
+
if (this.records.has(key)) continue
|
|
129
|
+
this.records.set(key, fromLive(live))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const approved = new Set(input.settings.models.approved)
|
|
133
|
+
const excluded = new Set(input.settings.models.excluded)
|
|
134
|
+
const overrides = input.settings.models.tierOverrides
|
|
135
|
+
|
|
136
|
+
for (const [key, record] of this.records) {
|
|
137
|
+
const tierOverride = overrides[key]
|
|
138
|
+
const explicitlyApproved = approved.has(key)
|
|
139
|
+
const isExcluded = excluded.has(key)
|
|
140
|
+
const providerEnabled = input.settings.providers[record.providerID]?.enabled ?? true
|
|
141
|
+
const approvedBySeed = record.approvalSource === "seed" && record.approved
|
|
142
|
+
// The plan catalog is authoritative for a plan-metered allowance. A
|
|
143
|
+
// seed value is a fallback for a provider with no catalog entry, so a
|
|
144
|
+
// plan change is a configuration change rather than a code change.
|
|
145
|
+
const plan = resolvePlan(record.providerID, input.settings.providers[record.providerID]?.plan)
|
|
146
|
+
const catalogAllowance = plan ? modelMonthlyAllowanceUsd(plan, record.modelID) : 0
|
|
147
|
+
this.records.set(key, {
|
|
148
|
+
...record,
|
|
149
|
+
tier: tierOverride ?? record.tier,
|
|
150
|
+
monthlyAllowanceUsd: catalogAllowance > 0 ? catalogAllowance : record.monthlyAllowanceUsd,
|
|
151
|
+
approved: providerEnabled && !isExcluded && (explicitlyApproved || approvedBySeed),
|
|
152
|
+
approvalSource: explicitlyApproved ? "user" : record.approvalSource,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get count() {
|
|
158
|
+
return this.records.size
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get liveModelCount() {
|
|
162
|
+
return this.liveCount
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
all(): ModelRecord[] {
|
|
166
|
+
return [...this.records.values()]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get(providerID: string, modelID: string): ModelRecord | undefined {
|
|
170
|
+
return this.records.get(modelKey(providerID, modelID))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getByKey(key: string): ModelRecord | undefined {
|
|
174
|
+
return this.records.get(key)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
approved(): ModelRecord[] {
|
|
178
|
+
return this.all().filter((model) => model.approved)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
approvedForProvider(providerID: string): ModelRecord[] {
|
|
182
|
+
return this.approved().filter((model) => model.providerID === providerID)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Models available to an offline session.
|
|
187
|
+
*
|
|
188
|
+
* Offline mode deliberately bypasses the online `approved` list. Approval is
|
|
189
|
+
* an online-policy decision ("may this model serve paid work alongside the
|
|
190
|
+
* hosted floor"), and a local model is excluded from it on purpose. Requiring
|
|
191
|
+
* the owner to approve a local model for *online* routing in order to use it
|
|
192
|
+
* while offline would be exactly the kind of manual configuration step this
|
|
193
|
+
* feature is supposed to remove.
|
|
194
|
+
*
|
|
195
|
+
* The offline allow-list still applies on top of this, so the result can only
|
|
196
|
+
* ever contain local models.
|
|
197
|
+
*/
|
|
198
|
+
modelsForOffline(): ModelRecord[] {
|
|
199
|
+
return this.all().filter((model) => model.providerID === OFFLINE_PROVIDER_ID)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const buildRegistry = (input: RegistryBuildInput) => new ModelRegistry(input)
|