@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.
Files changed (4) hide show
  1. package/README.md +211 -0
  2. package/index.ts +256 -0
  3. package/package.json +39 -0
  4. 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
+ }