@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1
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 +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -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 +114 -0
- package/install +75 -0
- package/package.json +94 -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 +1047 -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 +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -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 +654 -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 +1304 -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 +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline development mode.
|
|
3
|
+
*
|
|
4
|
+
* The router's job is normally to choose among hosted models by cost, quota and
|
|
5
|
+
* capability. Offline mode is the opposite problem: there is exactly one
|
|
6
|
+
* permitted endpoint, and every other route must be impossible rather than
|
|
7
|
+
* merely unlikely.
|
|
8
|
+
*
|
|
9
|
+
* Three properties matter more than anything else here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Fail closed.** If the local server is unreachable, the answer is a
|
|
12
|
+
* refusal that names the fix, not a quiet fallback to a hosted model. A
|
|
13
|
+
* silent fallback while on an airplane is the worst possible outcome,
|
|
14
|
+
* because the failure appears as a hang or a charge rather than an error.
|
|
15
|
+
* 2. **No network by accident.** Offline mode has to be explicit and visible,
|
|
16
|
+
* never inferred. A dropped Wi-Fi connection must not change behavior,
|
|
17
|
+
* because inference that "works" would be indistinguishable from inference
|
|
18
|
+
* that silently used a hosted provider.
|
|
19
|
+
* 3. **The online router is untouched.** Offline is a separate module and a
|
|
20
|
+
* separate settings block. Enabling it must not reorder, re-price, or
|
|
21
|
+
* re-approve a single online model.
|
|
22
|
+
*
|
|
23
|
+
* This module holds the policy and the arithmetic. It deliberately does not
|
|
24
|
+
* perform I/O against the host, so it can be tested exhaustively.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How the router should treat the current session.
|
|
29
|
+
*
|
|
30
|
+
* `online` is the existing behavior and the default. `offline` restricts
|
|
31
|
+
* inference to the configured local endpoint.
|
|
32
|
+
*/
|
|
33
|
+
export type InferenceMode = "online" | "offline"
|
|
34
|
+
|
|
35
|
+
/** Residency policy for the local model, expressed as an idle unload. */
|
|
36
|
+
export interface OfflineModelSettings {
|
|
37
|
+
/** Ollama model tag, e.g. `qwen3.5:4b-q4_K_M`. */
|
|
38
|
+
readonly model: string
|
|
39
|
+
/** Local inference base URL. Loopback only unless deliberately overridden. */
|
|
40
|
+
readonly baseURL: string
|
|
41
|
+
/**
|
|
42
|
+
* Context window in tokens.
|
|
43
|
+
*
|
|
44
|
+
* This is the KV-cache size, and it is the single largest lever on memory.
|
|
45
|
+
* It is bounded below by the smallest useful coding task and above by what
|
|
46
|
+
* the machine can hold alongside a simulator.
|
|
47
|
+
*/
|
|
48
|
+
readonly contextTokens: number
|
|
49
|
+
/** Upper bound on generated tokens per reply. */
|
|
50
|
+
readonly maxOutputTokens: number
|
|
51
|
+
/**
|
|
52
|
+
* Requests the local server may run at once. One keeps a 16 GB machine from
|
|
53
|
+
* holding two KV caches and thrashing.
|
|
54
|
+
*/
|
|
55
|
+
readonly concurrency: number
|
|
56
|
+
/** Minutes an idle model may stay resident before the server unloads it. */
|
|
57
|
+
readonly keepAliveMinutes: number
|
|
58
|
+
/** True when the model accepts image input. Verified, not assumed. */
|
|
59
|
+
readonly vision: boolean
|
|
60
|
+
/** True when the model supports tool calling. Verified, not assumed. */
|
|
61
|
+
readonly tools: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OfflineSettings {
|
|
65
|
+
readonly enabled: boolean
|
|
66
|
+
readonly model: OfflineModelSettings
|
|
67
|
+
/**
|
|
68
|
+
* Where the local model store lives. `undefined` means the runtime default.
|
|
69
|
+
* A path here is treated as mandatory: if it is missing, that is an error,
|
|
70
|
+
* never a reason to fall back to the internal disk.
|
|
71
|
+
*/
|
|
72
|
+
readonly modelStorePath?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_OFFLINE_SETTINGS: OfflineSettings = {
|
|
76
|
+
// Off by default. Offline mode is chosen, never inferred.
|
|
77
|
+
enabled: false,
|
|
78
|
+
model: {
|
|
79
|
+
model: "qwen3.5:4b-q4_K_M",
|
|
80
|
+
baseURL: "http://127.0.0.1:11434",
|
|
81
|
+
// 8K is the starting point agreed for this machine. It holds a component,
|
|
82
|
+
// its styles, a focused diff and a screenshot's worth of description while
|
|
83
|
+
// leaving room for the reply.
|
|
84
|
+
contextTokens: 8_192,
|
|
85
|
+
maxOutputTokens: 2_048,
|
|
86
|
+
concurrency: 1,
|
|
87
|
+
keepAliveMinutes: 5,
|
|
88
|
+
vision: true,
|
|
89
|
+
tools: true,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A reason offline mode cannot serve a request, with the fix stated. */
|
|
94
|
+
export interface OfflineBlocker {
|
|
95
|
+
readonly code:
|
|
96
|
+
| "mode-disabled"
|
|
97
|
+
| "endpoint-unreachable"
|
|
98
|
+
| "model-missing"
|
|
99
|
+
| "model-store-missing"
|
|
100
|
+
| "vision-unsupported"
|
|
101
|
+
| "tools-unsupported"
|
|
102
|
+
| "context-too-small"
|
|
103
|
+
/** One line a user can act on. */
|
|
104
|
+
readonly message: string
|
|
105
|
+
/** The exact command or setting that resolves it, when one exists. */
|
|
106
|
+
readonly remedy?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Loopback check.
|
|
111
|
+
*
|
|
112
|
+
* The server must not listen on a routable interface: on a shared or café
|
|
113
|
+
* network that exposes prompt data and model control to anyone on the link.
|
|
114
|
+
* `localhost`, `127.0.0.1` and `::1` are accepted; everything else is not.
|
|
115
|
+
*/
|
|
116
|
+
export const isLoopbackURL = (raw: string): boolean => {
|
|
117
|
+
let url: URL
|
|
118
|
+
try {
|
|
119
|
+
url = new URL(raw)
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
const host = url.hostname.toLowerCase()
|
|
124
|
+
if (host === "localhost" || host === "::1" || host === "[::1]") return true
|
|
125
|
+
// 127.0.0.0/8, the whole IPv4 loopback block.
|
|
126
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The model context has to hold a screenshot's description plus the code a
|
|
132
|
+
* vision edit needs. Below this, the model spends its window on the image and
|
|
133
|
+
* has nothing left for the change.
|
|
134
|
+
*/
|
|
135
|
+
export const MINIMUM_OFFLINE_CONTEXT = 4_096
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether offline mode may serve a request.
|
|
139
|
+
*
|
|
140
|
+
* Returns the first blocker rather than a list, because a user fixes one thing
|
|
141
|
+
* at a time and a wall of errors hides the actionable one. The caller is
|
|
142
|
+
* expected to refuse the request, never to fall through to a hosted model.
|
|
143
|
+
*/
|
|
144
|
+
export const checkOfflineReadiness = (input: {
|
|
145
|
+
readonly settings: OfflineSettings
|
|
146
|
+
/** True when the local endpoint answered a health probe. */
|
|
147
|
+
readonly endpointReachable: boolean
|
|
148
|
+
/** True when the configured model appears in the local server's model list. */
|
|
149
|
+
readonly modelPresent: boolean
|
|
150
|
+
/** True when `modelStorePath` is set and that path does not exist. */
|
|
151
|
+
readonly modelStoreMissing?: boolean
|
|
152
|
+
/** True when the current request carries an image. */
|
|
153
|
+
readonly needsVision?: boolean
|
|
154
|
+
/** True when the current request needs tool calling. */
|
|
155
|
+
readonly needsTools?: boolean
|
|
156
|
+
}): OfflineBlocker | undefined => {
|
|
157
|
+
const { settings } = input
|
|
158
|
+
if (!settings.enabled) {
|
|
159
|
+
return {
|
|
160
|
+
code: "mode-disabled",
|
|
161
|
+
message: "Offline mode is not enabled for this session.",
|
|
162
|
+
remedy: "Run `opencode-offline` to start a local session.",
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (input.modelStoreMissing) {
|
|
166
|
+
return {
|
|
167
|
+
code: "model-store-missing",
|
|
168
|
+
message: `The configured model store is not available at ${settings.modelStorePath}.`,
|
|
169
|
+
remedy:
|
|
170
|
+
"Reconnect the drive that holds the model store, or unset offline.modelStorePath to use the default location. The router will not download a model to the internal disk while a store is configured.",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!input.endpointReachable) {
|
|
174
|
+
return {
|
|
175
|
+
code: "endpoint-unreachable",
|
|
176
|
+
message: `No local inference server answered at ${settings.model.baseURL}.`,
|
|
177
|
+
remedy: `Start it with \`ollama serve\`, then confirm with \`curl ${settings.model.baseURL}/api/version\`.`,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!input.modelPresent) {
|
|
181
|
+
return {
|
|
182
|
+
code: "model-missing",
|
|
183
|
+
message: `The local server does not have ${settings.model.model} installed.`,
|
|
184
|
+
remedy: `Install it with \`ollama pull ${settings.model.model}\` while connected.`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (settings.model.contextTokens < MINIMUM_OFFLINE_CONTEXT) {
|
|
188
|
+
return {
|
|
189
|
+
code: "context-too-small",
|
|
190
|
+
message: `Offline context is ${settings.model.contextTokens} tokens, below the ${MINIMUM_OFFLINE_CONTEXT}-token minimum for a screenshot-driven edit.`,
|
|
191
|
+
remedy: "Raise `offline.model.contextTokens`, or accept text-only work.",
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (input.needsVision && !settings.model.vision) {
|
|
195
|
+
return {
|
|
196
|
+
code: "vision-unsupported",
|
|
197
|
+
message: "This request includes an image, but the configured offline model has no image input.",
|
|
198
|
+
remedy: "Configure a vision-capable offline model, or send the request without an image.",
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (input.needsTools && !settings.model.tools) {
|
|
202
|
+
return {
|
|
203
|
+
code: "tools-unsupported",
|
|
204
|
+
message: "This request needs file or shell tools, but the configured offline model cannot call tools.",
|
|
205
|
+
remedy: "Configure a tool-calling offline model.",
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Providers that must never be used while offline.
|
|
213
|
+
*
|
|
214
|
+
* Kept as an explicit list for refusal messages and for auditing the registry,
|
|
215
|
+
* not as the enforcement mechanism. Enforcement is an allow-list of exactly one
|
|
216
|
+
* provider, so a hosted provider added to the registry later cannot silently
|
|
217
|
+
* become reachable offline.
|
|
218
|
+
*/
|
|
219
|
+
export const HOSTED_PROVIDERS: ReadonlySet<string> = new Set([
|
|
220
|
+
"opendesign",
|
|
221
|
+
"opencode-go",
|
|
222
|
+
"commandcode",
|
|
223
|
+
"openrouter",
|
|
224
|
+
"groq",
|
|
225
|
+
"cerebras",
|
|
226
|
+
"google",
|
|
227
|
+
"anthropic",
|
|
228
|
+
"openai",
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
export const OFFLINE_PROVIDER_ID = "ollama"
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True when an inference request may be sent in the current mode.
|
|
235
|
+
*
|
|
236
|
+
* Offline is an allow-list of one. Anything not named is refused, including
|
|
237
|
+
* providers this module has never heard of, because the failure mode of
|
|
238
|
+
* guessing wrong is a silent hosted request on an airplane.
|
|
239
|
+
*/
|
|
240
|
+
export const mayUseProvider = (providerID: string, mode: InferenceMode): boolean => {
|
|
241
|
+
if (mode === "online") return true
|
|
242
|
+
return providerID === OFFLINE_PROVIDER_ID
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether a route is permitted, with the reason when it is not.
|
|
247
|
+
*
|
|
248
|
+
* Separate from `mayUseProvider` so callers can log or surface a precise
|
|
249
|
+
* explanation instead of a bare boolean, and so a known hosted provider gets a
|
|
250
|
+
* better message than an unknown one.
|
|
251
|
+
*/
|
|
252
|
+
export const offlineRouteRefusal = (
|
|
253
|
+
providerID: string,
|
|
254
|
+
mode: InferenceMode,
|
|
255
|
+
): string | undefined => {
|
|
256
|
+
if (mode === "online") return undefined
|
|
257
|
+
if (providerID === OFFLINE_PROVIDER_ID) return undefined
|
|
258
|
+
if (HOSTED_PROVIDERS.has(providerID)) {
|
|
259
|
+
return `Offline mode refuses ${providerID}: it is a hosted provider, and only ${OFFLINE_PROVIDER_ID} may serve an offline session.`
|
|
260
|
+
}
|
|
261
|
+
return `Offline mode refuses ${providerID}: it is not the configured local provider, and offline mode permits only ${OFFLINE_PROVIDER_ID}.`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Translate the mode into an Ollama model options block.
|
|
266
|
+
*
|
|
267
|
+
* These values are passed on every request rather than being baked into the
|
|
268
|
+
* server, so an online session sharing the same daemon is unaffected.
|
|
269
|
+
*/
|
|
270
|
+
export const offlineModelOptions = (
|
|
271
|
+
settings: OfflineModelSettings,
|
|
272
|
+
): Record<string, string | number> => ({
|
|
273
|
+
// KV cache and prompt ceiling. Lowering this is the cheapest memory win.
|
|
274
|
+
num_ctx: settings.contextTokens,
|
|
275
|
+
// Per-reply generation cap, so one runaway generation cannot fill the window.
|
|
276
|
+
num_predict: settings.maxOutputTokens,
|
|
277
|
+
// Deterministic-ish sampling for code edits. High temperature makes a 4B
|
|
278
|
+
// model rewrite working code that did not need touching.
|
|
279
|
+
temperature: 0.2,
|
|
280
|
+
top_p: 0.9,
|
|
281
|
+
repeat_penalty: 1.05,
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
/** Ollama's idle-unload duration, expressed the way its API expects. */
|
|
285
|
+
export const keepAlive = (settings: OfflineModelSettings): string => `${settings.keepAliveMinutes}m`
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Estimate the resident memory a quantized model needs, given its file size.
|
|
289
|
+
*
|
|
290
|
+
* The weights are the file. On top of that the runtime holds the KV cache,
|
|
291
|
+
* which scales with context, layers and hidden size; rather than model that
|
|
292
|
+
* precisely for one architecture, this uses a conservative per-token figure
|
|
293
|
+
* measured for this model class and documents it as an estimate.
|
|
294
|
+
*
|
|
295
|
+
* The caller must treat this as a planning number, not a measurement.
|
|
296
|
+
*/
|
|
297
|
+
export const estimateResidentBytes = (input: {
|
|
298
|
+
readonly weightsBytes: number
|
|
299
|
+
readonly contextTokens: number
|
|
300
|
+
/** Measured or estimated KV bytes per token. */
|
|
301
|
+
readonly kvBytesPerToken: number
|
|
302
|
+
/** Runtime overhead: graph, tokenizer, buffers. */
|
|
303
|
+
readonly overheadBytes: number
|
|
304
|
+
}): number =>
|
|
305
|
+
input.weightsBytes + input.contextTokens * input.kvBytesPerToken + input.overheadBytes
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Decide whether a model may be loaded given currently available memory.
|
|
309
|
+
*
|
|
310
|
+
* The headroom is deliberate: macOS needs room to page, the simulator holds a
|
|
311
|
+
* few hundred megabytes of its own, and a machine that is already swapping
|
|
312
|
+
* before the model loads will not recover. Returning false is not a failure of
|
|
313
|
+
* the model; it is the scheduler refusing to make the machine unusable.
|
|
314
|
+
*/
|
|
315
|
+
export const fitsInMemory = (input: {
|
|
316
|
+
readonly estimatedBytes: number
|
|
317
|
+
/** Memory the OS could hand out without heavy paging. */
|
|
318
|
+
readonly availableBytes: number
|
|
319
|
+
/** Fraction of available memory this workload may claim. */
|
|
320
|
+
readonly maxShare: number
|
|
321
|
+
}): boolean => input.estimatedBytes <= input.availableBytes * input.maxShare
|