@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,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
|
+
}
|