@nebulesstech/opencode-9router 0.1.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/README.md +211 -0
- package/index.ts +256 -0
- package/package.json +39 -0
- package/thinking.ts +284 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# 9Router for OpenCode
|
|
2
|
+
|
|
3
|
+
`@nebulesstech/opencode-9router` version **0.1.1** registers
|
|
4
|
+
[9Router](https://github.com/decolua/9router) as an OpenCode provider, discovers
|
|
5
|
+
models, and generates model-specific thinking variants. It does not run the gateway.
|
|
6
|
+
|
|
7
|
+
## For Users
|
|
8
|
+
|
|
9
|
+
### Install and Configure
|
|
10
|
+
|
|
11
|
+
Run a trusted 9Router gateway and obtain its API key. The default API base URL is
|
|
12
|
+
`http://localhost:20128/v1`.
|
|
13
|
+
|
|
14
|
+
Add this to your project `opencode.json` or global
|
|
15
|
+
`~/.config/opencode/opencode.json`, merging with existing settings:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"$schema": "https://opencode.ai/config.json",
|
|
20
|
+
"plugin": ["@nebulesstech/opencode-9router@0.1.1"],
|
|
21
|
+
"provider": {
|
|
22
|
+
"9router": {
|
|
23
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
24
|
+
"name": "9Router",
|
|
25
|
+
"options": {
|
|
26
|
+
"baseURL": "http://localhost:20128/v1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
OpenCode auto-installs npm plugins at startup. No separate `npm install`, clone,
|
|
34
|
+
or build is required for users. This package ships ESM TypeScript for OpenCode's
|
|
35
|
+
plugin loader, not a compiled JavaScript library for plain Node.js imports.
|
|
36
|
+
The explicit provider block makes the endpoint visible; the plugin can register
|
|
37
|
+
it without that block. No hand-written `models` block is needed for discovery.
|
|
38
|
+
|
|
39
|
+
These instructions target the published `0.1.1` release. Package metadata alone
|
|
40
|
+
does not establish that a release exists. A registry 404 can mean unpublished or
|
|
41
|
+
inaccessible; confirm release availability with the maintainer.
|
|
42
|
+
|
|
43
|
+
### Authenticate
|
|
44
|
+
|
|
45
|
+
Start OpenCode, run `/connect`, select **9Router**, and provide the gateway API
|
|
46
|
+
key. Restart OpenCode afterward so startup discovery sees the stored credential.
|
|
47
|
+
The plugin reads the `9router` entry in
|
|
48
|
+
`~/.local/share/opencode/auth.json` before OpenCode's auth loader runs.
|
|
49
|
+
|
|
50
|
+
For environment-based setup, inject `NINE_ROUTER_API_KEY` securely before starting
|
|
51
|
+
OpenCode (`ROUTER_API_KEY` is the fallback). Also add this to the provider's
|
|
52
|
+
`options` for inference authentication:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"baseURL": "http://localhost:20128/v1",
|
|
57
|
+
"apiKey": "{env:NINE_ROUTER_API_KEY}"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `{env:ROUTER_API_KEY}` instead if using that fallback. Discovery resolves keys
|
|
62
|
+
in this order: nonempty plugin tuple `apiKey`, `NINE_ROUTER_API_KEY`,
|
|
63
|
+
`ROUTER_API_KEY`, stored credential. Provider `options.apiKey` is not read by the
|
|
64
|
+
discovery resolver. The auth loader returns an empty object and relies on
|
|
65
|
+
OpenCode to supply stored credentials for inference; discovery-only keys are
|
|
66
|
+
not copied into provider options. Unset unused environment variables rather
|
|
67
|
+
than leaving them empty, since empty values can mask lower-priority credentials.
|
|
68
|
+
|
|
69
|
+
The `/connect` base URL prompt saves metadata, but this version does not read
|
|
70
|
+
that metadata when resolving endpoints. Configure the URL explicitly below.
|
|
71
|
+
|
|
72
|
+
### Base URL Precedence
|
|
73
|
+
|
|
74
|
+
Startup discovery and the registered provider use the first configured value:
|
|
75
|
+
|
|
76
|
+
1. `provider.9router.options.baseURL`.
|
|
77
|
+
2. Nonempty plugin tuple option `baseURL`.
|
|
78
|
+
3. `NINE_ROUTER_BASE_URL`, otherwise `ROUTER_BASE_URL`.
|
|
79
|
+
4. `http://localhost:20128/v1`.
|
|
80
|
+
|
|
81
|
+
The explicit provider URL in the installation example therefore overrides
|
|
82
|
+
environment URLs. Remove that field if you want environment selection. Keep
|
|
83
|
+
environment values unset when unused. For tuple-capable OpenCode versions:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"plugin": [
|
|
88
|
+
["@nebulesstech/opencode-9router@0.1.1", {"baseURL": "https://router.example.com/v1"}]
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The separate `provider.models` hook uses tuple/environment/default precedence,
|
|
94
|
+
not the provider option. Startup discovery uses the `config` hook for this
|
|
95
|
+
config-only provider. Keep endpoint settings consistent if using both hooks.
|
|
96
|
+
|
|
97
|
+
### Discovery and Thinking
|
|
98
|
+
|
|
99
|
+
Discovery requests `GET <baseURL>/models`, for example
|
|
100
|
+
`http://localhost:20128/v1/models`, with a bearer key. Do not append another `/v1`.
|
|
101
|
+
Without a resolved key, discovery is skipped even if the gateway allows public
|
|
102
|
+
model listing. Successful results are cached in memory for five minutes per
|
|
103
|
+
base URL; this is not a background refresh timer. Restart after credential or
|
|
104
|
+
configuration changes. Existing user-defined model entries win by model ID.
|
|
105
|
+
Failures leave the provider registered with any manually configured models.
|
|
106
|
+
|
|
107
|
+
Discovery maps reasoning, tools, vision, and context/output limits. Missing
|
|
108
|
+
limits default to 200,000 context tokens and at most 32,000 output tokens.
|
|
109
|
+
Reported costs are zero placeholders, not a promise of free usage. Combo models
|
|
110
|
+
appear when returned by the gateway's model list.
|
|
111
|
+
|
|
112
|
+
Thinking variants depend on capability metadata, format, and model-ID heuristics:
|
|
113
|
+
|
|
114
|
+
| Format | Current request fields |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| OpenAI | `reasoning_effort`; `max` maps to `xhigh` |
|
|
117
|
+
| Z.ai / GLM | `reasoning_effort: low` or `high`; higher levels clamp to `high` |
|
|
118
|
+
| DeepSeek | Enabled `thinking` plus `reasoning_effort`; current mapping sends `high` for minimal/low/medium and `max` for high/xhigh/max |
|
|
119
|
+
| Kimi | Enabled `thinking` plus low/medium/high `reasoning_effort` |
|
|
120
|
+
| MiniMax | Adaptive `thinking`; some models have no selectable variants |
|
|
121
|
+
| Qwen | `enable_thinking` and `thinking_budget` |
|
|
122
|
+
| Budget-style | Enabled `thinking` with `budget_tokens` |
|
|
123
|
+
|
|
124
|
+
Budget map: none 0, minimal 512, low 1,024, medium 8,192, high 24,576,
|
|
125
|
+
xhigh 32,768, max 128,000. The resolver filters or clamps against advertised
|
|
126
|
+
`thinkingRange`; it does not expose every named level for every model.
|
|
127
|
+
`thinkingCanDisable: false` removes `none`. Identical bodies are deduplicated,
|
|
128
|
+
keeping the first generated level. Missing format metadata uses reasoning and
|
|
129
|
+
model-name heuristics; unknown formats may yield no variants. Variant labels
|
|
130
|
+
are not guarantees of distinct upstream effort or quality.
|
|
131
|
+
|
|
132
|
+
### Verify and Troubleshoot
|
|
133
|
+
|
|
134
|
+
Restart OpenCode, open `/models`, and choose a discovered 9Router model. Run a
|
|
135
|
+
small prompt using its exact ID, preserving any gateway prefix:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
opencode run --model '9router/<model-id>' 'Say OK'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Replace `<model-id>` with an actual discovered ID. Check available variants in
|
|
142
|
+
OpenCode and select one supported by that model. Successful listing verifies
|
|
143
|
+
discovery; a successful response separately verifies inference authentication.
|
|
144
|
+
|
|
145
|
+
| Symptom | Check |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| npm 404 / plugin not installed | Confirm package/version publication and registry access; do not substitute a raw source URL |
|
|
148
|
+
| No models | Confirm resolved key, endpoint reachability, and a JSON `data` array from `/models`; startup discovery failures are swallowed |
|
|
149
|
+
| HTTP 401/403 | Check gateway key and separate discovery/inference key configuration |
|
|
150
|
+
| HTTP 404 from gateway | Use API base URL ending in `/v1`, not `/v1/models` or `/v1/v1` |
|
|
151
|
+
| Wrong endpoint | Check provider URL first; `/connect` URL metadata is not used |
|
|
152
|
+
| Missing thinking variants | Check capabilities, range filtering, deduplication, and adaptive models with nothing to vary |
|
|
153
|
+
| Old models or changed key ignored | Restart; cache is keyed by URL, not credential |
|
|
154
|
+
|
|
155
|
+
### Security
|
|
156
|
+
|
|
157
|
+
API keys MUST NOT enter source files or committed configuration. Use `/connect`
|
|
158
|
+
or environment substitution, protect the auth file, and redact keys from logs
|
|
159
|
+
and issue reports. Rotate any exposed credential. Only configure trusted
|
|
160
|
+
gateways: discovery sends the bearer key to the resolved URL, and inference
|
|
161
|
+
sends prompts and model traffic to the provider endpoint. Use HTTPS for remote
|
|
162
|
+
gateways; keep plain HTTP limited to trusted local development. Review plugins
|
|
163
|
+
before installing because they execute inside OpenCode.
|
|
164
|
+
|
|
165
|
+
## For Maintainers
|
|
166
|
+
|
|
167
|
+
Work from `plugins/9router`. `package.json` defines public scoped package
|
|
168
|
+
`@nebulesstech/opencode-9router@0.1.1`, Node.js `>=20`, and zero-build ESM export
|
|
169
|
+
`./index.ts`. OpenCode supplies the TypeScript-capable loader; the Node engine
|
|
170
|
+
does not imply native Node can resolve the current extensionless TS imports.
|
|
171
|
+
|
|
172
|
+
Published files are `index.ts`, `thinking.ts`, `README.md`, and npm's mandatory
|
|
173
|
+
`package.json`. `selfcheck.ts` and local `opencode.json` stay in the repository.
|
|
174
|
+
`@opencode-ai/plugin` is development-only because its source import is type-only;
|
|
175
|
+
its pinned version matches this repository's SDK dependency. No build tooling
|
|
176
|
+
or runtime dependencies are bundled. No license is asserted by this manifest.
|
|
177
|
+
|
|
178
|
+
### Checks and Local Loading
|
|
179
|
+
|
|
180
|
+
With Bun installed and a suitable live gateway, run:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
npm run selfcheck -- http://localhost:20128/v1
|
|
184
|
+
npm run pack:check
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The self-check makes a live `/models` request and checks resolver invariants;
|
|
188
|
+
it is not an authenticated end-to-end inference test. It sends no authorization
|
|
189
|
+
header and does not read API-key variables. Use a trusted test endpoint that
|
|
190
|
+
permits model listing without authentication; do not disable production auth.
|
|
191
|
+
Always pass the URL: its fallback is `NINE_ROUTER_BASE_URL`, then the existing
|
|
192
|
+
development address `http://zo-computer:20128/v1`, not the plugin default.
|
|
193
|
+
`pack:check` runs `npm pack --dry-run`; inspect its file list for unexpected files
|
|
194
|
+
or secrets. It does not publish or prove OpenCode compatibility.
|
|
195
|
+
|
|
196
|
+
For a local smoke test, replace the npm plugin entry in a separate OpenCode
|
|
197
|
+
configuration with `file:///absolute/path/to/Opencode-Workflows/plugins/9router/index.ts`.
|
|
198
|
+
Keep `thinking.ts` alongside it. Repeat the user verification flow against a test
|
|
199
|
+
gateway. Do not load local and npm copies together.
|
|
200
|
+
|
|
201
|
+
### Release Flow
|
|
202
|
+
|
|
203
|
+
1. Review runtime changes and publication rights. Resolve licensing deliberately; do not invent a license field.
|
|
204
|
+
2. Run the live self-check and OpenCode smoke test above. Record actual results and any limitations.
|
|
205
|
+
3. Confirm npm account `nebulesstech` has scope access. Check registry state; a 404 alone does not prove name availability.
|
|
206
|
+
4. Set the intended version in `package.json` and update README installation pins. Run `npm run pack:check` and review the exact payload.
|
|
207
|
+
5. Only after explicit release approval, an authorized maintainer publishes from this directory with public access. `publishConfig.access` is already `public`.
|
|
208
|
+
6. Verify the published name/version and clean OpenCode startup installation, discovery, and inference before announcing the release.
|
|
209
|
+
|
|
210
|
+
This guide describes a release procedure, not evidence that publishing or live
|
|
211
|
+
verification has occurred. npm versions are immutable; fixes require a new version.
|
package/index.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { homedir as osHomedir } from "node:os"
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
4
|
+
import {
|
|
5
|
+
classifyFormat,
|
|
6
|
+
buildVariants,
|
|
7
|
+
type Format,
|
|
8
|
+
type Capabilities,
|
|
9
|
+
type Body,
|
|
10
|
+
} from "./thinking"
|
|
11
|
+
|
|
12
|
+
const PROVIDER_ID = "9router"
|
|
13
|
+
const DEFAULT_BASE_URL = "http://localhost:20128/v1"
|
|
14
|
+
const CACHE_TTL_MS = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
type NineRouterCapabilities = {
|
|
17
|
+
vision?: boolean
|
|
18
|
+
tools?: boolean
|
|
19
|
+
reasoning?: boolean
|
|
20
|
+
thinkingFormat?: string | null
|
|
21
|
+
thinkingCanDisable?: boolean
|
|
22
|
+
thinkingEffortSupported?: boolean
|
|
23
|
+
thinkingRange?: [number, number] | null
|
|
24
|
+
contextWindow?: number
|
|
25
|
+
maxOutput?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type NineRouterModel = {
|
|
29
|
+
id: string
|
|
30
|
+
owned_by?: string
|
|
31
|
+
capabilities?: NineRouterCapabilities
|
|
32
|
+
context_length?: number
|
|
33
|
+
max_completion_tokens?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ConfigModel = {
|
|
37
|
+
id?: string
|
|
38
|
+
name?: string
|
|
39
|
+
reasoning?: boolean
|
|
40
|
+
tool_call?: boolean
|
|
41
|
+
attachment?: boolean
|
|
42
|
+
temperature?: boolean
|
|
43
|
+
cost?: Record<string, unknown>
|
|
44
|
+
limit?: { context?: number; output?: number; input?: number }
|
|
45
|
+
options?: Record<string, unknown>
|
|
46
|
+
variants?: Record<string, Body | { disabled?: boolean }>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const NineRouter: Plugin = async (input, options) => {
|
|
50
|
+
// Plugin-level options come from the tuple form: ["file://…", { baseURL }]
|
|
51
|
+
const pluginOptions = (options ?? {}) as Record<string, unknown>
|
|
52
|
+
|
|
53
|
+
const resolveBaseURL = (): string => {
|
|
54
|
+
const fromEnv =
|
|
55
|
+
process.env.NINE_ROUTER_BASE_URL ??
|
|
56
|
+
process.env.ROUTER_BASE_URL ??
|
|
57
|
+
undefined
|
|
58
|
+
return (
|
|
59
|
+
(typeof pluginOptions.baseURL === "string" && pluginOptions.baseURL) ||
|
|
60
|
+
fromEnv ||
|
|
61
|
+
DEFAULT_BASE_URL
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolveKey = (): string | undefined => {
|
|
66
|
+
const fromOptions =
|
|
67
|
+
typeof pluginOptions.apiKey === "string" && pluginOptions.apiKey
|
|
68
|
+
? pluginOptions.apiKey
|
|
69
|
+
: undefined
|
|
70
|
+
return (
|
|
71
|
+
fromOptions ??
|
|
72
|
+
process.env.NINE_ROUTER_API_KEY ??
|
|
73
|
+
process.env.ROUTER_API_KEY ??
|
|
74
|
+
storedKey() ??
|
|
75
|
+
undefined
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The config() hook runs before opencode's auth loader, so the stored
|
|
80
|
+
// /connect credential is read straight from the auth database file.
|
|
81
|
+
function storedKey(): string | undefined {
|
|
82
|
+
try {
|
|
83
|
+
const p = `${osHomedir()}/.local/share/opencode/auth.json`
|
|
84
|
+
const auth = JSON.parse(readFileSync(p, "utf8")) as Record<
|
|
85
|
+
string,
|
|
86
|
+
{ type?: string; key?: string; access?: string }
|
|
87
|
+
>
|
|
88
|
+
const entry = auth[PROVIDER_ID]
|
|
89
|
+
if (!entry) return undefined
|
|
90
|
+
return entry.type === "api" ? entry.key : entry.type === "oauth" ? entry.access : entry.key
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let cache: { at: number; models: Record<string, unknown> } | undefined
|
|
97
|
+
let cacheKey: string | undefined
|
|
98
|
+
|
|
99
|
+
async function fetchModels(baseURL: string, key?: string) {
|
|
100
|
+
if (cache && cacheKey === baseURL && Date.now() - cache.at < CACHE_TTL_MS) {
|
|
101
|
+
return cache.models
|
|
102
|
+
}
|
|
103
|
+
const url = baseURL.replace(/\/$/, "") + "/models"
|
|
104
|
+
const resp = await fetch(url, {
|
|
105
|
+
headers: key ? { Authorization: `Bearer ${key}` } : {},
|
|
106
|
+
})
|
|
107
|
+
if (!resp.ok) throw new Error(`9router: GET ${url} -> ${resp.status}`)
|
|
108
|
+
const json = (await resp.json()) as { data?: NineRouterModel[] }
|
|
109
|
+
const list = Array.isArray(json.data) ? json.data : []
|
|
110
|
+
const models: Record<string, unknown> = {}
|
|
111
|
+
for (const m of list) {
|
|
112
|
+
if (!m?.id) continue
|
|
113
|
+
models[m.id] = toConfigModel(m)
|
|
114
|
+
}
|
|
115
|
+
cache = { at: Date.now(), models }
|
|
116
|
+
cacheKey = baseURL
|
|
117
|
+
return models
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toConfigModel(m: NineRouterModel) {
|
|
121
|
+
const caps = m.capabilities ?? {}
|
|
122
|
+
const reasoning = caps.reasoning === true
|
|
123
|
+
const context = caps.contextWindow ?? m.context_length ?? 200_000
|
|
124
|
+
const output = caps.maxOutput ?? m.max_completion_tokens ?? Math.min(context, 32_000)
|
|
125
|
+
const pretty = prettyName(m.id)
|
|
126
|
+
const model: Record<string, unknown> = {
|
|
127
|
+
name: pretty,
|
|
128
|
+
reasoning: reasoning === true,
|
|
129
|
+
tool_call: caps.tools !== false,
|
|
130
|
+
attachment: caps.vision === true,
|
|
131
|
+
temperature: true,
|
|
132
|
+
release_date: "",
|
|
133
|
+
limit: { context, output },
|
|
134
|
+
// 9Router does not expose per-model pricing; zero cost keeps the
|
|
135
|
+
// usage math defined without inventing numbers.
|
|
136
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
137
|
+
}
|
|
138
|
+
const variants = buildVariants(
|
|
139
|
+
classifyFormat(caps.thinkingFormat, m.id, caps.reasoning === true),
|
|
140
|
+
{
|
|
141
|
+
reasoning,
|
|
142
|
+
canDisable: caps.thinkingCanDisable !== false,
|
|
143
|
+
effortSupported: caps.thinkingEffortSupported === true,
|
|
144
|
+
range: (caps.thinkingRange as [number, number] | null) ?? null,
|
|
145
|
+
},
|
|
146
|
+
m.id,
|
|
147
|
+
)
|
|
148
|
+
if (Object.keys(variants).length > 0) model.variants = variants
|
|
149
|
+
return model
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
// Register the provider and inject discovered models. The provider.models
|
|
154
|
+
// hook is only consulted for providers that exist in the models.dev
|
|
155
|
+
// catalog, so for a config-only provider the discovery must happen here:
|
|
156
|
+
// config() runs before opencode parses cfg.provider into the database.
|
|
157
|
+
config: async (cfg) => {
|
|
158
|
+
cfg.provider ??= {}
|
|
159
|
+
const existing = cfg.provider[PROVIDER_ID] ?? {}
|
|
160
|
+
const baseURL = existing.options?.baseURL ?? resolveBaseURL()
|
|
161
|
+
const key = resolveKey()
|
|
162
|
+
cfg.provider[PROVIDER_ID] = {
|
|
163
|
+
npm: "@ai-sdk/openai-compatible",
|
|
164
|
+
name: existing.name ?? "9Router",
|
|
165
|
+
options: {
|
|
166
|
+
...(existing.options ?? {}),
|
|
167
|
+
baseURL,
|
|
168
|
+
},
|
|
169
|
+
models: existing.models ?? {},
|
|
170
|
+
}
|
|
171
|
+
if (!key) return
|
|
172
|
+
try {
|
|
173
|
+
const discovered = await fetchModels(baseURL, key)
|
|
174
|
+
const merged = { ...(cfg.provider[PROVIDER_ID].models as object) }
|
|
175
|
+
for (const [id, model] of Object.entries(discovered)) {
|
|
176
|
+
if (!merged[id]) merged[id] = model
|
|
177
|
+
}
|
|
178
|
+
cfg.provider[PROVIDER_ID].models = merged
|
|
179
|
+
} catch {
|
|
180
|
+
// unreachable router: provider stays registered with whatever the
|
|
181
|
+
// user hand-wrote; opencode will error on that model, not at boot
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
auth: {
|
|
186
|
+
provider: PROVIDER_ID,
|
|
187
|
+
// Return {} and let opencode inject options.apiKey from the stored key.
|
|
188
|
+
loader: async () => ({}),
|
|
189
|
+
methods: [
|
|
190
|
+
{
|
|
191
|
+
type: "api",
|
|
192
|
+
label: "9Router API key",
|
|
193
|
+
prompts: [
|
|
194
|
+
{
|
|
195
|
+
key: "baseURL",
|
|
196
|
+
type: "text" as const,
|
|
197
|
+
message: "9Router base URL (leave blank for http://localhost:20128/v1)",
|
|
198
|
+
placeholder: "http://localhost:20128/v1",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
async authorize(inputs) {
|
|
202
|
+
const key = inputs?.["apiKey"] ?? ""
|
|
203
|
+
if (!key.trim()) return { type: "failed" as const }
|
|
204
|
+
const baseURL = inputs?.["baseURL"]?.trim()
|
|
205
|
+
const metadata: Record<string, string> = {}
|
|
206
|
+
if (baseURL) metadata.baseURL = baseURL
|
|
207
|
+
return {
|
|
208
|
+
type: "success" as const,
|
|
209
|
+
key,
|
|
210
|
+
provider: PROVIDER_ID,
|
|
211
|
+
metadata,
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
provider: {
|
|
219
|
+
id: PROVIDER_ID,
|
|
220
|
+
models: async (_provider, ctx) => {
|
|
221
|
+
const key = resolveKey() ?? keyFromAuth(ctx.auth)
|
|
222
|
+
if (!key) return {}
|
|
223
|
+
const baseURL = resolveBaseURL()
|
|
224
|
+
try {
|
|
225
|
+
return await fetchModels(baseURL, key)
|
|
226
|
+
} catch {
|
|
227
|
+
// unreachable router: expose nothing rather than a broken provider
|
|
228
|
+
return {}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function keyFromAuth(auth: unknown): string | undefined {
|
|
236
|
+
if (!auth || typeof auth !== "object") return undefined
|
|
237
|
+
const a = auth as { type?: string; key?: string; access?: string }
|
|
238
|
+
if (a.type === "api") return a.key
|
|
239
|
+
if (a.type === "oauth") return a.access
|
|
240
|
+
if (a.type === "wellknown") return a.key
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function prettyName(id: string): string {
|
|
245
|
+
const tail = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id
|
|
246
|
+
return tail
|
|
247
|
+
.replace(/[:_]/g, " ")
|
|
248
|
+
.replace(/-/g, " ")
|
|
249
|
+
.replace(/\s+/g, " ")
|
|
250
|
+
.trim()
|
|
251
|
+
.split(" ")
|
|
252
|
+
.map((w) => (w.length <= 3 ? w.toUpperCase() : w[0].toUpperCase() + w.slice(1)))
|
|
253
|
+
.join(" ")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type { ConfigModel }
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebulesstech/opencode-9router",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode provider plugin for 9Router model discovery and thinking variants",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"thinking.ts",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Nebuless/Opencode-Workflows.git",
|
|
22
|
+
"directory": "plugins/9router"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"opencode-plugin",
|
|
27
|
+
"9router",
|
|
28
|
+
"ai",
|
|
29
|
+
"provider",
|
|
30
|
+
"reasoning"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"selfcheck": "bun run selfcheck.ts",
|
|
34
|
+
"pack:check": "npm pack --dry-run"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@opencode-ai/plugin": "1.18.31"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/thinking.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// 9Router thinking-level resolution.
|
|
2
|
+
//
|
|
3
|
+
// Levels and budgets mirror 9Router's own source of truth:
|
|
4
|
+
// open-sse/translator/concerns/thinking.js (decolua/9router)
|
|
5
|
+
// EFFORT_LEVELS = minimal|low|medium|high|xhigh|max
|
|
6
|
+
// LEVEL_TO_BUDGET = none:0, minimal:512, low:1024, medium:8192,
|
|
7
|
+
// high:24576, xhigh:32768, max:128000
|
|
8
|
+
//
|
|
9
|
+
// A "thinking format" describes the request-body shape 9Router expects for a
|
|
10
|
+
// model. 9Router normalizes the body itself per format (thinkingUnified.js),
|
|
11
|
+
// so variants only need to emit the right top-level keys.
|
|
12
|
+
|
|
13
|
+
/** Ordered low -> high. */
|
|
14
|
+
export const EFFORT_LEVELS = [
|
|
15
|
+
"none",
|
|
16
|
+
"minimal",
|
|
17
|
+
"low",
|
|
18
|
+
"medium",
|
|
19
|
+
"high",
|
|
20
|
+
"xhigh",
|
|
21
|
+
"max",
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type EffortLevel = (typeof EFFORT_LEVELS)[number]
|
|
25
|
+
|
|
26
|
+
/** Web-standard level -> budget_tokens (Anthropic/Gemini docs, via 9router). */
|
|
27
|
+
export const LEVEL_TO_BUDGET: Record<EffortLevel, number> = {
|
|
28
|
+
none: 0,
|
|
29
|
+
minimal: 512,
|
|
30
|
+
low: 1024,
|
|
31
|
+
medium: 8192,
|
|
32
|
+
high: 24576,
|
|
33
|
+
xhigh: 32768,
|
|
34
|
+
max: 128000,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Request-body patch a variant contributes. Applied as providerOptions["9router"]. */
|
|
38
|
+
export type Body = Record<string, unknown>
|
|
39
|
+
|
|
40
|
+
export type Capabilities = {
|
|
41
|
+
reasoning: boolean
|
|
42
|
+
/** Model cannot disable thinking: clamp "none" instead of emitting a disable body. */
|
|
43
|
+
canDisable: boolean
|
|
44
|
+
/** Server advertises a discrete-effort ladder. */
|
|
45
|
+
effortSupported: boolean
|
|
46
|
+
/** [min, max] budget when known, else null. */
|
|
47
|
+
range: [number, number] | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Format =
|
|
51
|
+
| "openai"
|
|
52
|
+
| "zai"
|
|
53
|
+
| "deepseek"
|
|
54
|
+
| "kimi"
|
|
55
|
+
| "minimax"
|
|
56
|
+
| "qwen"
|
|
57
|
+
| "budget" // claude/gemini/hunyuan-style { thinking: { type, budget_tokens } }
|
|
58
|
+
| "none"
|
|
59
|
+
|
|
60
|
+
/** Clamp a budget into an optional thinkingRange. */
|
|
61
|
+
function clampBudget(budget: number, range: [number, number] | null): number {
|
|
62
|
+
if (!range) return budget
|
|
63
|
+
const [min, max] = range
|
|
64
|
+
if (Number.isFinite(min) && budget < min) budget = min
|
|
65
|
+
if (Number.isFinite(max) && budget > max) budget = max
|
|
66
|
+
return budget
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve which effort levels a model exposes.
|
|
71
|
+
*
|
|
72
|
+
* 1. thinkingEffortSupported -> full ladder
|
|
73
|
+
* 2. thinkingRange present -> levels whose budget fits the range
|
|
74
|
+
* 3. format/family heuristics
|
|
75
|
+
*/
|
|
76
|
+
export function resolveLevels(
|
|
77
|
+
format: Format,
|
|
78
|
+
caps: Capabilities,
|
|
79
|
+
modelId: string,
|
|
80
|
+
): EffortLevel[] {
|
|
81
|
+
const id = modelId.toLowerCase()
|
|
82
|
+
|
|
83
|
+
const noneFirst: EffortLevel[] = caps.canDisable ? ["none"] : []
|
|
84
|
+
|
|
85
|
+
if (caps.effortSupported) {
|
|
86
|
+
// zai GLM-5.3+: discrete low|high (+ effort pass-through); openai-style full.
|
|
87
|
+
if (format === "zai") return [...noneFirst, "low", "high", "xhigh", "max"]
|
|
88
|
+
return [...noneFirst, "low", "medium", "high", "xhigh", "max"]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (caps.range) {
|
|
92
|
+
const [min, max] = caps.range
|
|
93
|
+
const fits = (lvl: EffortLevel) => {
|
|
94
|
+
const b = LEVEL_TO_BUDGET[lvl]
|
|
95
|
+
return b >= (Number.isFinite(min) ? min : 0) && b <= (Number.isFinite(max) ? max : Infinity)
|
|
96
|
+
}
|
|
97
|
+
const set = [...noneFirst, "low", "medium", "high", "xhigh", "max"].filter(
|
|
98
|
+
(l) => l === "none" || fits(l),
|
|
99
|
+
)
|
|
100
|
+
return set as EffortLevel[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Family first: some ids carry their own thinking interface regardless of format.
|
|
104
|
+
if (id.includes("nemotron")) {
|
|
105
|
+
// Nemotron 3 has no discrete thinking controls on this gateway: one shot.
|
|
106
|
+
return caps.canDisable ? ["none", "high"] : []
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
switch (format) {
|
|
110
|
+
case "openai":
|
|
111
|
+
// gpt families: xhigh is top; "max" is not a native openai enum.
|
|
112
|
+
return [...noneFirst, "low", "medium", "high", "xhigh"]
|
|
113
|
+
case "zai":
|
|
114
|
+
// GLM without effort support: enable/disable + low|high effort (5.2 reads reasoning_effort).
|
|
115
|
+
return [...noneFirst, "low", "high"]
|
|
116
|
+
case "deepseek":
|
|
117
|
+
// deepseek maps low/medium -> high upstream; v4 adds native max.
|
|
118
|
+
return id.includes("deepseek-v4")
|
|
119
|
+
? [...noneFirst, "high", "max"]
|
|
120
|
+
: [...noneFirst, "high"]
|
|
121
|
+
case "kimi":
|
|
122
|
+
return [...noneFirst, "low", "medium", "high"]
|
|
123
|
+
case "minimax":
|
|
124
|
+
// Adaptive: one on/off toggle is all the format can express.
|
|
125
|
+
return caps.canDisable ? ["none", "high"] : []
|
|
126
|
+
case "qwen":
|
|
127
|
+
return [...noneFirst, "high"]
|
|
128
|
+
case "budget":
|
|
129
|
+
return [...noneFirst, "low", "medium", "high", "xhigh", "max"]
|
|
130
|
+
case "none":
|
|
131
|
+
return []
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Map a resolved level to the request-body keys for the model's format.
|
|
137
|
+
* Must match 9Router's applyThinking switch (thinkingUnified.js) so the
|
|
138
|
+
* upstream provider receives exactly what it expects.
|
|
139
|
+
*/
|
|
140
|
+
export function levelToBody(
|
|
141
|
+
format: Format,
|
|
142
|
+
level: EffortLevel,
|
|
143
|
+
caps: Capabilities,
|
|
144
|
+
modelId: string,
|
|
145
|
+
): Body {
|
|
146
|
+
const id = modelId.toLowerCase()
|
|
147
|
+
const budget = clampBudget(LEVEL_TO_BUDGET[level], caps.range)
|
|
148
|
+
const disable: Body = { thinking: { type: "disabled" } }
|
|
149
|
+
|
|
150
|
+
switch (format) {
|
|
151
|
+
case "openai":
|
|
152
|
+
// openai enum: none|minimal|low|medium|high|xhigh (no "max")
|
|
153
|
+
if (level === "none") return { reasoning_effort: "none" }
|
|
154
|
+
if (level === "max") return { reasoning_effort: "xhigh" }
|
|
155
|
+
return { reasoning_effort: level }
|
|
156
|
+
|
|
157
|
+
case "zai": {
|
|
158
|
+
// z.ai reads reasoning_effort from GLM-5.2 onward; disable only when allowed.
|
|
159
|
+
if (level === "none") return caps.canDisable ? disable : { reasoning_effort: "low" }
|
|
160
|
+
if (level === "low") return { reasoning_effort: "low" }
|
|
161
|
+
if (level === "medium") return { reasoning_effort: "low" } // zai has no medium
|
|
162
|
+
if (level === "high") return { reasoning_effort: "high" }
|
|
163
|
+
return { reasoning_effort: "high" } // xhigh/max clamp: zai caps at high
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "deepseek": {
|
|
167
|
+
// low/medium -> high, xhigh/max -> max (9router mapping)
|
|
168
|
+
if (level === "none") return caps.canDisable ? disable : enabledHigh()
|
|
169
|
+
if (level === "minimal" || level === "low" || level === "medium")
|
|
170
|
+
return enabledHigh()
|
|
171
|
+
return { thinking: { type: "enabled" }, reasoning_effort: "max" }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case "kimi": {
|
|
175
|
+
if (level === "none") return caps.canDisable ? disable : enabledKimi()
|
|
176
|
+
// kimi accepts low|medium|high; higher levels clamp to high
|
|
177
|
+
const effort =
|
|
178
|
+
level === "minimal" || level === "low" ? "low" : level === "medium" ? "medium" : "high"
|
|
179
|
+
return { thinking: { type: "enabled" }, reasoning_effort: effort }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case "minimax":
|
|
183
|
+
// M2.x/M3 are adaptive; budget fields are ignored upstream.
|
|
184
|
+
if (level === "none") return caps.canDisable ? disable : { thinking: { type: "adaptive" } }
|
|
185
|
+
return { thinking: { type: "adaptive" } }
|
|
186
|
+
|
|
187
|
+
case "qwen":
|
|
188
|
+
if (level === "none") return caps.canDisable ? { enable_thinking: false } : high()
|
|
189
|
+
return { enable_thinking: true, thinking_budget: budget || undefined }
|
|
190
|
+
|
|
191
|
+
case "budget":
|
|
192
|
+
if (level === "none") return caps.canDisable ? disable : budgetBody(-1)
|
|
193
|
+
return budgetBody(budget)
|
|
194
|
+
|
|
195
|
+
case "none":
|
|
196
|
+
return {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function enabledHigh(): Body {
|
|
200
|
+
return { thinking: { type: "enabled" }, reasoning_effort: "high" }
|
|
201
|
+
}
|
|
202
|
+
function enabledKimi(): Body {
|
|
203
|
+
return { thinking: { type: "enabled" }, reasoning_effort: "high" }
|
|
204
|
+
}
|
|
205
|
+
function high(): Body {
|
|
206
|
+
return { enable_thinking: true, thinking_budget: LEVEL_TO_BUDGET.high }
|
|
207
|
+
}
|
|
208
|
+
function budgetBody(b: number): Body {
|
|
209
|
+
return b === -1
|
|
210
|
+
? { thinking: { type: "enabled" } }
|
|
211
|
+
: { thinking: { type: "enabled", budget_tokens: b } }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build the variants record for one model.
|
|
217
|
+
* Dedup invariant: if two levels produce byte-identical bodies, keep the
|
|
218
|
+
* higher-level name so /models never shows two identical variants.
|
|
219
|
+
*/
|
|
220
|
+
export function buildVariants(
|
|
221
|
+
format: Format,
|
|
222
|
+
caps: Capabilities,
|
|
223
|
+
modelId: string,
|
|
224
|
+
): Record<string, Body> {
|
|
225
|
+
const levels = resolveLevels(format, caps, modelId)
|
|
226
|
+
if (levels.length === 0) return {}
|
|
227
|
+
const out: Record<string, Body> = {}
|
|
228
|
+
const seen = new Map<string, EffortLevel>()
|
|
229
|
+
for (const level of levels) {
|
|
230
|
+
const body = levelToBody(format, level, caps, modelId)
|
|
231
|
+
const key = JSON.stringify(body)
|
|
232
|
+
const dup = seen.get(key)
|
|
233
|
+
if (dup !== undefined) {
|
|
234
|
+
// identical body already emitted at a lower level -> drop this one
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
seen.set(key, level)
|
|
238
|
+
out[level] = body
|
|
239
|
+
}
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Classify a 9Router model id + thinkingFormat into our Format union. */
|
|
244
|
+
export function classifyFormat(
|
|
245
|
+
thinkingFormat: string | null | undefined,
|
|
246
|
+
modelId: string,
|
|
247
|
+
reasoning = false,
|
|
248
|
+
): Format {
|
|
249
|
+
switch (thinkingFormat) {
|
|
250
|
+
case "openai":
|
|
251
|
+
return "openai"
|
|
252
|
+
case "zai":
|
|
253
|
+
return "zai"
|
|
254
|
+
case "deepseek":
|
|
255
|
+
return "deepseek"
|
|
256
|
+
case "kimi":
|
|
257
|
+
return "kimi"
|
|
258
|
+
case "minimax":
|
|
259
|
+
return "minimax"
|
|
260
|
+
case "qwen":
|
|
261
|
+
return "qwen"
|
|
262
|
+
default: {
|
|
263
|
+
// null format + reasoning: upstream is OpenAI-compatible (ollama,
|
|
264
|
+
// generic gateways) and reads reasoning_effort. Non-reasoning models
|
|
265
|
+
// must stay "none" or buildVariants would emit thinking bodies for
|
|
266
|
+
// models that reject them.
|
|
267
|
+
if (!thinkingFormat || !reasoning) {
|
|
268
|
+
if (!reasoning) return "none"
|
|
269
|
+
const id = modelId.toLowerCase()
|
|
270
|
+
if (
|
|
271
|
+
id.includes("claude") ||
|
|
272
|
+
id.includes("gemini") ||
|
|
273
|
+
id.includes("o1") ||
|
|
274
|
+
id.includes("o3") ||
|
|
275
|
+
id.includes("o4")
|
|
276
|
+
) {
|
|
277
|
+
return "budget"
|
|
278
|
+
}
|
|
279
|
+
return "openai"
|
|
280
|
+
}
|
|
281
|
+
return "none"
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|