@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/CONTRIBUTING.md +103 -0
  3. package/LICENSE +21 -0
  4. package/README.md +425 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +107 -0
  7. package/dist/npm/index.js +40 -0
  8. package/docs/ARCHITECTURE.md +104 -0
  9. package/docs/DEBUGGING-GUI.md +70 -0
  10. package/docs/INSTALL.md +151 -0
  11. package/docs/LIMITATIONS.md +230 -0
  12. package/docs/MODELS.md +124 -0
  13. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  14. package/docs/QA.md +142 -0
  15. package/docs/SHARED-CORE.md +114 -0
  16. package/install +75 -0
  17. package/package.json +94 -4
  18. package/scripts/install.sh +324 -0
  19. package/scripts/uninstall.sh +91 -0
  20. package/src/allowance.ts +135 -0
  21. package/src/core/classify/classifier.ts +305 -0
  22. package/src/core/config-writer.ts +167 -0
  23. package/src/core/config.ts +1047 -0
  24. package/src/core/context/strategy.ts +188 -0
  25. package/src/core/context-health.ts +39 -0
  26. package/src/core/failures.ts +398 -0
  27. package/src/core/history/opencode.ts +173 -0
  28. package/src/core/models/protocol.ts +22 -0
  29. package/src/core/models/registry.ts +204 -0
  30. package/src/core/models/seed.ts +781 -0
  31. package/src/core/offline/mode.ts +321 -0
  32. package/src/core/plan-page.ts +390 -0
  33. package/src/core/plans/catalog.ts +487 -0
  34. package/src/core/policy/calibration.ts +259 -0
  35. package/src/core/policy/eligibility.ts +630 -0
  36. package/src/core/providers/enablement.ts +143 -0
  37. package/src/core/providers/opendesign-credits.ts +206 -0
  38. package/src/core/providers/opendesign-models.ts +654 -0
  39. package/src/core/providers/opendesign.ts +474 -0
  40. package/src/core/quota/calibration.ts +166 -0
  41. package/src/core/quota/commandcode.ts +471 -0
  42. package/src/core/quota/credentials.ts +490 -0
  43. package/src/core/quota/ledger.ts +1304 -0
  44. package/src/core/quota/opencode-go.ts +148 -0
  45. package/src/core/quota/opendesign.ts +1043 -0
  46. package/src/core/quota/reconcile.ts +155 -0
  47. package/src/core/quota/report.ts +261 -0
  48. package/src/core/quota/service.ts +273 -0
  49. package/src/core/reprompt.ts +48 -0
  50. package/src/core/routing/engine.ts +978 -0
  51. package/src/core/types.ts +866 -0
  52. package/src/core/ui.ts +236 -0
  53. package/src/core/util.ts +114 -0
  54. package/src/gui/index.tsx +1675 -0
  55. package/src/gui/viewmodel.ts +456 -0
  56. package/src/index.ts +33 -0
  57. package/src/rpc.ts +204 -0
  58. package/src/server/containment.ts +131 -0
  59. package/src/server/controller.ts +3405 -0
  60. package/src/server/route-tracker.ts +60 -0
  61. package/src/server/shared-core.ts +701 -0
  62. 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