@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,143 @@
1
+ import type { RouterSettings } from "../types.js"
2
+
3
+ /**
4
+ * Credential-aware provider defaults.
5
+ *
6
+ * The old behaviour enabled every provider the project shipped, whether or not
7
+ * the user had an account. A stranger installing the router got "Command Code:
8
+ * authentication is failing" on first run, for an account they never claimed,
9
+ * and a producer of warnings they could not act on.
10
+ *
11
+ * The rule now: a provider is enabled when this machine has a credential for it,
12
+ * and disabled when it does not — unless the user said otherwise. Explicit
13
+ * configuration always wins, in both directions, because detection is a
14
+ * convenience and a config file is an instruction.
15
+ *
16
+ * Everything here is pure. The caller gathers evidence (filesystem, credentials,
17
+ * network probes) and this decides. That split is what lets a user see the list
18
+ * before anything changes, and what makes the rule testable without a machine.
19
+ */
20
+
21
+ export interface ProviderEvidence {
22
+ readonly providerID: string
23
+ readonly hasCredential: boolean
24
+ /**
25
+ * Where the credential was found, for display. Never the value itself — the
26
+ * router reports provenance, not secrets.
27
+ */
28
+ readonly source?: string
29
+ }
30
+
31
+ export interface EnablementChange {
32
+ readonly providerID: string
33
+ readonly from: boolean
34
+ readonly to: boolean
35
+ readonly reason: string
36
+ }
37
+
38
+ export interface EnablementResult {
39
+ readonly settings: RouterSettings
40
+ readonly changes: ReadonlyArray<EnablementChange>
41
+ /** Providers that stayed on despite no credential, because the user asked. */
42
+ readonly keptOnByChoice: ReadonlyArray<string>
43
+ /** Providers that stayed off despite a credential, because the user asked. */
44
+ readonly keptOffByChoice: ReadonlyArray<string>
45
+ }
46
+
47
+ export const applyCredentialDefaults = (
48
+ settings: RouterSettings,
49
+ explicit: ReadonlySet<string>,
50
+ evidence: ReadonlyArray<ProviderEvidence>,
51
+ ): EnablementResult => {
52
+ const byProvider = new Map(evidence.map((entry) => [entry.providerID, entry]))
53
+ const changes: EnablementChange[] = []
54
+ const keptOnByChoice: string[] = []
55
+ const keptOffByChoice: string[] = []
56
+ const providers: Record<string, (typeof settings.providers)[string]> = { ...settings.providers }
57
+
58
+ for (const [providerID, provider] of Object.entries(settings.providers)) {
59
+ const found = byProvider.get(providerID)
60
+ // No evidence gathered for this provider: leave it exactly as configured.
61
+ // Silence is not evidence of absence, and guessing here is how a working
62
+ // install gets broken by an upgrade.
63
+ if (!found) continue
64
+
65
+ const wanted = found.hasCredential
66
+ const is = provider.enabled
67
+
68
+ if (explicit.has(providerID)) {
69
+ // The user set this. Record the disagreement for the panel, change nothing.
70
+ if (wanted && !is) keptOffByChoice.push(providerID)
71
+ if (!wanted && is) keptOnByChoice.push(providerID)
72
+ continue
73
+ }
74
+
75
+ if (wanted === is) continue
76
+
77
+ providers[providerID] = { ...provider, enabled: wanted }
78
+ changes.push({
79
+ providerID,
80
+ from: is,
81
+ to: wanted,
82
+ reason: wanted
83
+ ? `credential found${found.source ? ` (${found.source})` : ""}`
84
+ : "no credential found on this machine",
85
+ })
86
+ }
87
+
88
+ return {
89
+ settings: changes.length > 0 ? { ...settings, providers } : settings,
90
+ changes,
91
+ keptOnByChoice,
92
+ keptOffByChoice,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * One sentence per change, for the settings warnings the panel and
98
+ * `/router-status` both read. Written so a user can tell what happened and what
99
+ * to do about it, and so nothing changes silently.
100
+ */
101
+ export const describeEnablement = (result: EnablementResult): ReadonlyArray<string> =>
102
+ result.changes.map((change) =>
103
+ change.to
104
+ ? `providers.${change.providerID} enabled: ${change.reason}`
105
+ : `providers.${change.providerID} disabled: ${change.reason}. Set providers.${change.providerID}.enabled to true to force it on.`,
106
+ )
107
+
108
+ /**
109
+ * Where a credential for this provider may live, without reading it.
110
+ *
111
+ * Existence is enough to answer "does this machine have an account?", and
112
+ * checking existence is deliberately weaker than reading the value: detection
113
+ * must never need to see a secret to decide whether a provider is usable.
114
+ */
115
+ export const credentialFileCandidates = (providerID: string, home: string): ReadonlyArray<string> => {
116
+ const at = (path: string) => `${home}/${path}`
117
+ switch (providerID) {
118
+ case "commandcode":
119
+ return [
120
+ at(".commandcode/auth.json"),
121
+ at(".config/opencode/commandcode.key"),
122
+ at(".config/opencode/commandcode.env"),
123
+ at(".config/opencode/commandcode.json"),
124
+ ]
125
+ case "opendesign":
126
+ return [at(".config/opencode/opendesign.key"), at(".config/opencode/opendesign.json")]
127
+ default:
128
+ return []
129
+ }
130
+ }
131
+
132
+ export const credentialEnvVars = (providerID: string): ReadonlyArray<string> => {
133
+ switch (providerID) {
134
+ case "commandcode":
135
+ return ["COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"]
136
+ case "opendesign":
137
+ return ["OPENDESIGN_API_KEY", "OPEN_DESIGN_API_KEY"]
138
+ case "opencode-go":
139
+ return ["OPENCODE_API_KEY"]
140
+ default:
141
+ return []
142
+ }
143
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * OpenDesign Design Plan credit semantics.
3
+ *
4
+ * OpenDesign bills a Go subscription through a shared "Design Plan" credit
5
+ * pool. A per-model multiplier converts the dollar value of model usage into
6
+ * Design Plan credits consumed. The five-hour and seven-day allowances are
7
+ * nested limits inside one monthly maximum; they are not additional credits
8
+ * and must never be added together.
9
+ *
10
+ * Source: https://open-design.ai/model-credits/ and
11
+ * https://open-design.ai/help/billing/ (retrieved 2026-10-28).
12
+ *
13
+ * Nothing in this file is a provider charge. Every number produced here is an
14
+ * estimate of credits, and the caller must label it as such.
15
+ */
16
+
17
+ /**
18
+ * Credit multiplier published for each Design Plan model.
19
+ *
20
+ * Multipliers apply to Design Plan credits only. A request funded from plan
21
+ * model credits is billed at the plain usage value, so callers must pass
22
+ * `multiplierApplies: false` for that pool. See the plan catalog.
23
+ */
24
+ export const OPENDESIGN_CREDIT_MULTIPLIERS: Readonly<Record<string, number>> = {
25
+ "deepseek-v4.1-flash": 0.5,
26
+ "mimo-v2.6-flash": 1,
27
+ "mimo-v2.6-pro": 1,
28
+ "hy4-preview": 1,
29
+ "deepseek-v4-flash": 1,
30
+ "deepseek-v4-flash-vision-exp": 1,
31
+ "deepseek-v4-pro": 1,
32
+ "glm-5.3-flash": 1,
33
+ "kimi-k2.7-code": 1,
34
+ "claude-haiku-5.5": 2,
35
+ "glm-5.3-flashx": 2,
36
+ "gpt-6-luna": 4,
37
+ }
38
+
39
+ /**
40
+ * Models the gateway serves inside the Design Plan group whose multiplier is
41
+ * not published. The gateway is authoritative for entitlement, not for price.
42
+ */
43
+ export const OPENDESIGN_UNPUBLISHED_MULTIPLIER_MODELS: ReadonlySet<string> = new Set([
44
+ "minimax-m3.1-flash",
45
+ ])
46
+
47
+ /** Neutral multiplier used when a model has no published rate. */
48
+ export const OPENDESIGN_DEFAULT_MULTIPLIER = 1
49
+
50
+ export interface OpenDesignPromotion {
51
+ readonly id: string
52
+ readonly modelIDs: readonly string[]
53
+ readonly multiplier: number
54
+ /** Epoch milliseconds, inclusive. */
55
+ readonly startsAt: number
56
+ /** Epoch milliseconds, exclusive. */
57
+ readonly endsAt: number
58
+ /**
59
+ * False when the offer is an in-app benefit. OpenDesign states that
60
+ * included free-use and limited-time offers do not automatically apply
61
+ * through MCP, CLI, or API channels, so an app-only promotion must never
62
+ * change the API credit rate.
63
+ */
64
+ readonly appliesToApi: boolean
65
+ readonly note: string
66
+ }
67
+
68
+ const utc = (year: number, month: number, day: number, hour: number) =>
69
+ Date.UTC(year, month - 1, day, hour, 0, 0)
70
+
71
+ /**
72
+ * The DeepSeek V4.1 Flash off-peak offer documented for 2026-10-20 18:00 to
73
+ * 2026-10-27 18:00 (UTC+8). It is recorded so an expired rate can never be
74
+ * applied by accident, and it is marked as not applicable to API usage.
75
+ */
76
+ export const OPENDESIGN_PROMOTIONS: ReadonlyArray<OpenDesignPromotion> = [
77
+ {
78
+ id: "deepseek-v4.1-flash-offpeak-2026-10",
79
+ modelIDs: ["deepseek-v4.1-flash"],
80
+ multiplier: 0.25,
81
+ startsAt: utc(2026, 10, 20, 10),
82
+ endsAt: utc(2026, 10, 27, 10),
83
+ appliesToApi: false,
84
+ note: "Expired and app-only; never applied to API usage.",
85
+ },
86
+ ]
87
+
88
+ export type MultiplierEvidence = "published" | "promotion" | "configured" | "unverified"
89
+
90
+ export interface CreditMultiplier {
91
+ readonly multiplier: number
92
+ readonly evidence: MultiplierEvidence
93
+ readonly note?: string
94
+ }
95
+
96
+ export interface CreditMultiplierOptions {
97
+ readonly now?: number
98
+ /** Explicit per-model overrides. Always wins over published values. */
99
+ readonly overrides?: Readonly<Record<string, number>>
100
+ }
101
+
102
+ const activePromotion = (modelID: string, now: number): OpenDesignPromotion | undefined =>
103
+ OPENDESIGN_PROMOTIONS.find(
104
+ (promotion) =>
105
+ promotion.appliesToApi &&
106
+ promotion.modelIDs.includes(modelID) &&
107
+ now >= promotion.startsAt &&
108
+ now < promotion.endsAt,
109
+ )
110
+
111
+ /**
112
+ * Resolve the Design Plan credit multiplier for a model.
113
+ *
114
+ * `unverified` means the gateway serves the model inside the plan but no
115
+ * multiplier is published. The router treats that as the neutral 1x rate and
116
+ * surfaces the uncertainty instead of pretending it is exact.
117
+ */
118
+ export const creditMultiplierFor = (modelID: string, options: CreditMultiplierOptions = {}): CreditMultiplier => {
119
+ const override = options.overrides?.[modelID]
120
+ if (typeof override === "number" && Number.isFinite(override) && override > 0) {
121
+ return { multiplier: override, evidence: "configured" }
122
+ }
123
+ const now = options.now ?? Date.now()
124
+ const promotion = activePromotion(modelID, now)
125
+ if (promotion) {
126
+ return { multiplier: promotion.multiplier, evidence: "promotion", note: promotion.note }
127
+ }
128
+ const published = OPENDESIGN_CREDIT_MULTIPLIERS[modelID]
129
+ if (typeof published === "number" && Number.isFinite(published) && published > 0) {
130
+ return { multiplier: published, evidence: "published" }
131
+ }
132
+ return {
133
+ multiplier: OPENDESIGN_DEFAULT_MULTIPLIER,
134
+ evidence: "unverified",
135
+ note: OPENDESIGN_UNPUBLISHED_MULTIPLIER_MODELS.has(modelID)
136
+ ? "Multiplier is not published for this model; the neutral 1x rate is used as an estimate."
137
+ : "No published Design Plan multiplier; the neutral 1x rate is used as an estimate.",
138
+ }
139
+ }
140
+
141
+ export interface OpenDesignPlanCaps {
142
+ readonly plan: string
143
+ /** Regular monthly subscription price, in USD. */
144
+ readonly monthlyPriceUsd: number
145
+ /** Maximum Design Plan credits per billing month, in USD-equivalent credits. */
146
+ readonly designPlanMonthlyUsd: number
147
+ /** Plan-model credits outside the Design Plan group. Zero on Go. */
148
+ readonly planModelCreditsUsd: number
149
+ /**
150
+ * Maximum Design Plan credits per five-hour window, or undefined when the
151
+ * plan has no such window. Plus, Pro and Max have none.
152
+ */
153
+ readonly fiveHourCapUsd?: number
154
+ /** Maximum Design Plan credits per seven-day window. Nested, never additional. */
155
+ readonly weeklyCapUsd?: number
156
+ }
157
+
158
+ /**
159
+ * Published Go plan shape, retained so the Go tier stays describable.
160
+ *
161
+ * The catalog in `plans/catalog.ts` is authoritative for routing; this
162
+ * constant exists for callers that need the Go numbers without importing the
163
+ * catalog, and for documentation of the tier the router used to target.
164
+ */
165
+ export const OPENDESIGN_GO_PLAN: OpenDesignPlanCaps = {
166
+ plan: "go",
167
+ monthlyPriceUsd: 10,
168
+ designPlanMonthlyUsd: 120,
169
+ planModelCreditsUsd: 0,
170
+ fiveHourCapUsd: 10,
171
+ weeklyCapUsd: 30,
172
+ }
173
+
174
+ /**
175
+ * Published Pro plan shape.
176
+ *
177
+ * Pro has a seven-day Design Plan window and deliberately no five-hour
178
+ * window. It also carries a separate plan model pool whose credits have no
179
+ * usage windows at all and reset per billing cycle.
180
+ */
181
+ export const OPENDESIGN_PRO_PLAN: OpenDesignPlanCaps = {
182
+ plan: "pro",
183
+ monthlyPriceUsd: 100,
184
+ designPlanMonthlyUsd: 360,
185
+ planModelCreditsUsd: 120,
186
+ weeklyCapUsd: 90,
187
+ }
188
+
189
+ /**
190
+ * Convert an estimated dollar value of model usage into estimated Design Plan
191
+ * credits. The result is an estimate, never an actual provider charge.
192
+ */
193
+ export const designPlanCreditsFor = (
194
+ usageUsd: number,
195
+ modelID: string,
196
+ options: CreditMultiplierOptions = {},
197
+ ): { creditsUsd: number; multiplier: number; evidence: MultiplierEvidence; note?: string } => {
198
+ const resolved = creditMultiplierFor(modelID, options)
199
+ const creditsUsd = Math.max(0, usageUsd) * resolved.multiplier
200
+ return {
201
+ creditsUsd,
202
+ multiplier: resolved.multiplier,
203
+ evidence: resolved.evidence,
204
+ ...(resolved.note ? { note: resolved.note } : {}),
205
+ }
206
+ }