@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,168 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { SmartRouterRpc } from "../rpc.js"
|
|
3
|
+
import type { DecisionView, ProviderRow } from "../gui/viewmodel.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The terminal side of the router.
|
|
7
|
+
*
|
|
8
|
+
* Same view-model as the desktop panel, different renderer. The `main` command
|
|
9
|
+
* opens a decision inspector as a dialog; the footer slot shows the current state
|
|
10
|
+
* at a glance. Nothing here reimplements routing logic — it formats the same
|
|
11
|
+
* pure functions the panel uses, which is what keeps the two from disagreeing.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately not a dashboard. A terminal user wants "what is it doing" and
|
|
14
|
+
* "why", answerable without leaving the prompt.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface RouterStatus {
|
|
18
|
+
mode?: string
|
|
19
|
+
runtime?: string
|
|
20
|
+
current?: { providerID: string; id: string }
|
|
21
|
+
quota?: ReadonlyArray<unknown>
|
|
22
|
+
lastDecision?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tick = (ok: boolean) => (ok ? "✓" : "·")
|
|
26
|
+
|
|
27
|
+
/** A compact decision report, sized for a dialog rather than a panel. */
|
|
28
|
+
export const formatDecision = (status: RouterStatus | undefined, view: DecisionView | undefined): string => {
|
|
29
|
+
if (!status) return "Smart Router: no response from the router."
|
|
30
|
+
const head = `${status.runtime ?? "unknown"} · ${status.mode ?? "unknown"} · ${
|
|
31
|
+
status.current ? `${status.current.providerID}/${status.current.id}` : "no model yet"
|
|
32
|
+
}`
|
|
33
|
+
if (!view) return `${head}\n\nNo decision yet in this session.`
|
|
34
|
+
|
|
35
|
+
const lines = [head, "", `Chosen ${view.chosen ?? "nothing eligible"}`]
|
|
36
|
+
lines.push(
|
|
37
|
+
` ${view.taskKind} · tier ${view.taskTier} · ${Math.round(view.contextTokens / 1000)}k context`,
|
|
38
|
+
)
|
|
39
|
+
if (view.reasons.length > 0) {
|
|
40
|
+
lines.push("", "Why")
|
|
41
|
+
for (const reason of view.reasons.slice(0, 5)) lines.push(` • ${reason}`)
|
|
42
|
+
}
|
|
43
|
+
if (view.rows.length > 0) {
|
|
44
|
+
lines.push("", `Candidates (${view.eligibleCount} eligible, ${view.blockedCount} blocked)`)
|
|
45
|
+
for (const row of view.rows.slice(0, 10)) {
|
|
46
|
+
lines.push(` ${tick(row.verdict !== "blocked")} ${row.key}`)
|
|
47
|
+
lines.push(` ${row.reason}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (view.warnings.length > 0) {
|
|
51
|
+
lines.push("", "Warnings")
|
|
52
|
+
for (const warning of view.warnings.slice(0, 3)) lines.push(` ! ${warning}`)
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Providers, one line each, with the tightest window that binds. */
|
|
58
|
+
export const formatProviders = (providers: ReadonlyArray<ProviderRow>): string => {
|
|
59
|
+
if (providers.length === 0) return "No providers reported."
|
|
60
|
+
const lines: string[] = []
|
|
61
|
+
for (const provider of providers) {
|
|
62
|
+
const windows = provider.windows
|
|
63
|
+
.filter((window) => window.reported)
|
|
64
|
+
.map((window) => `${window.label} ${window.lowerBound ? "≥" : ""}${window.usedPct}%`)
|
|
65
|
+
lines.push(`${provider.label}${provider.exhausted ? " (exhausted)" : ""}`)
|
|
66
|
+
lines.push(` ${windows.length > 0 ? windows.join(" · ") : "no windows reported"} · ${provider.sourceLabel}`)
|
|
67
|
+
if (provider.caveat) lines.push(` ! ${provider.caveat}`)
|
|
68
|
+
}
|
|
69
|
+
return lines.join("\n")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default Plugin.define({
|
|
73
|
+
/**
|
|
74
|
+
* Deliberately NOT nested under the server part's id.
|
|
75
|
+
*
|
|
76
|
+
* The GUI docs are explicit: if a plugin's id has another id nested under it,
|
|
77
|
+
* or nests under one, its GUI does not load. The TUI previously used
|
|
78
|
+
* "opencode-smart-router.tui", which nests under the server part's
|
|
79
|
+
* "opencode-smart-router" — so adding a TUI silently disabled the desktop
|
|
80
|
+
* panel, with no error beyond a generic request failure.
|
|
81
|
+
*
|
|
82
|
+
* A sibling id avoids the namespace collision entirely.
|
|
83
|
+
*/
|
|
84
|
+
id: "opencode-smart-router-tui",
|
|
85
|
+
setup(context) {
|
|
86
|
+
// Footer: the state a user wants without opening anything.
|
|
87
|
+
const stop = context.data.on("session.execution.succeeded", () => {
|
|
88
|
+
void refresh()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const refresh = async () => {
|
|
92
|
+
try {
|
|
93
|
+
const result: any = await context.client.rpc(SmartRouterRpc).status({})
|
|
94
|
+
setFooter(`${result?.runtime ?? "?"} · ${result?.mode ?? "?"}`)
|
|
95
|
+
} catch {
|
|
96
|
+
setFooter("router unreachable")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let setFooter = (_text: string) => {}
|
|
101
|
+
context.ui.slot({
|
|
102
|
+
append: "prompt.footer.status",
|
|
103
|
+
render: () => {
|
|
104
|
+
const [text, set] = createTerminalSignal("router")
|
|
105
|
+
setFooter = set
|
|
106
|
+
void refresh()
|
|
107
|
+
return <text>{text()}</text>
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
context.keymap.layer(() => ({
|
|
112
|
+
mode: "global",
|
|
113
|
+
commands: [
|
|
114
|
+
{
|
|
115
|
+
id: "router.decision",
|
|
116
|
+
title: "Router: why this model",
|
|
117
|
+
slash: { name: "router-why" },
|
|
118
|
+
palette: true,
|
|
119
|
+
run: async () => {
|
|
120
|
+
try {
|
|
121
|
+
const result: any = await context.client.rpc(SmartRouterRpc).status({})
|
|
122
|
+
const { summariseDecision, toProviderRow } = await import("../gui/viewmodel.js")
|
|
123
|
+
const body = formatDecision(result, summariseDecision(result?.lastDecision))
|
|
124
|
+
await context.ui.dialog.alert({ title: "Smart Router", message: body })
|
|
125
|
+
} catch (cause) {
|
|
126
|
+
await context.ui.dialog.alert({
|
|
127
|
+
title: "Smart Router",
|
|
128
|
+
message: `Could not read router status: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "router.providers",
|
|
135
|
+
title: "Router: providers and allowances",
|
|
136
|
+
slash: { name: "router-quota" },
|
|
137
|
+
palette: true,
|
|
138
|
+
run: async () => {
|
|
139
|
+
try {
|
|
140
|
+
const result: any = await context.client.rpc(SmartRouterRpc).status({})
|
|
141
|
+
const { toProviderRow } = await import("../gui/viewmodel.js")
|
|
142
|
+
const rows = ((result?.quota ?? []) as unknown[]).map((snapshot) => toProviderRow(snapshot as never))
|
|
143
|
+
await context.ui.dialog.alert({ title: "Providers", message: formatProviders(rows) })
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
await context.ui.dialog.alert({
|
|
146
|
+
title: "Providers",
|
|
147
|
+
message: `Could not read provider status: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
bindings: ["router.decision", "router.providers"],
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
return () => stop()
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/** A minimal reactive cell, so this file needs no Solid runtime. */
|
|
161
|
+
function createTerminalSignal(initial: string): [() => string, (next: string) => void] {
|
|
162
|
+
let value = initial
|
|
163
|
+
const listeners = new Set<(next: string) => void>()
|
|
164
|
+
return [() => value, (next: string) => {
|
|
165
|
+
value = next
|
|
166
|
+
for (const listener of listeners) listener(next)
|
|
167
|
+
}]
|
|
168
|
+
}
|