opencode-v2-axonhub 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zsxsoft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # opencode-v2-axonhub
2
+
3
+ OpenCode **V2** plugin that discovers AxonHub models from `/v1/models` and `/v1/models?include=all`, merges both responses, and exposes them as the `axonhub` provider.
4
+
5
+ This is the V2 port of the plugin. OpenCode V2 loads plugins through its own module contract (`{ id, setup }`), calls plugin domains directly, and ships provider implementations in-process, so the V1 hook shape (`{ id, server }`, `config`/`provider` hooks, `@ai-sdk/*` packages) is no longer used. The V1 implementation is published as `@pandada8/opencode-axonhub` and lives on `master`; this one is on `feat/v2`.
6
+
7
+ ## Usage
8
+
9
+ ```json
10
+ {
11
+ "plugins": [
12
+ {
13
+ "package": "opencode-v2-axonhub",
14
+ "options": { "baseURL": "https://your-axonhub.example.com" }
15
+ }
16
+ ]
17
+ }
18
+ ```
19
+
20
+ The plugin owns the whole `axonhub` provider: no `providers.axonhub` entry is required (and none is read). `baseURL` may also come from `AXONHUB_BASE_URL`; a trailing `/v1` is stripped.
21
+
22
+ Installing from a checkout instead of the registry:
23
+
24
+ ```json
25
+ {
26
+ "plugins": [{ "package": "/absolute/path/to/opencode-axonhub", "options": { "baseURL": "https://your-axonhub.example.com" } }]
27
+ }
28
+ ```
29
+
30
+ A directory plugin needs a `server.ts`, `index.ts`, `server.js`, or `index.js` at its root; this repository ships `server.ts`. OpenCode watches the loaded file, so editing the plugin reloads it without a restart.
31
+
32
+ ### Options
33
+
34
+ | Option | Default | Meaning |
35
+ | --- | --- | --- |
36
+ | `baseURL` | `AXONHUB_BASE_URL` | AxonHub origin. Discovery and every model request need it. |
37
+ | `apiKey` | `AXONHUB_API_KEY`, then the stored credential | API key used for discovery. Requests always use the stored credential. |
38
+ | `enrichModels` | `true` | Copy metadata (family, cost, limits, capabilities, variants) for models that also exist in OpenCode's catalog. |
39
+ | `log` | `true` | Append discovery diagnostics to `~/.cache/opencode/axonhub-plugin.log`. |
40
+
41
+ ### Authentication
42
+
43
+ The plugin registers the `axonhub` integration, so OpenCode's own credential store holds the key:
44
+
45
+ ```sh
46
+ opencode auth login axonhub
47
+ ```
48
+
49
+ `AXONHUB_API_KEY` also works, because the plugin registers it as an environment method. Discovery and provider availability both follow the credential: logging in or out refetches the model list and reloads the provider while the server runs.
50
+
51
+ ### Model routing
52
+
53
+ AxonHub exposes several protocol surfaces. The plugin routes each model by its `owned_by` value (a `gemini-` model id wins over `owned_by`):
54
+
55
+ | `owned_by` | Provider package | Endpoint |
56
+ | --- | --- | --- |
57
+ | `google`, `gemini`, or `gemini-*` id | `@opencode/ai/providers/google` | `<baseURL>/gemini/v1beta` |
58
+ | `openai` | `@opencode/ai/providers/openai` | `<baseURL>/v1` |
59
+ | anything else | `@opencode/ai/providers/anthropic` | `<baseURL>/anthropic/v1` |
60
+
61
+ Package and endpoint are set per model, so one AxonHub provider covers all three surfaces.
62
+
63
+ ### Discovery
64
+
65
+ Model lists are cached at `~/.cache/opencode/axonhub-models.json` for one day. Without a base URL or API key, discovery is skipped and no provider is published. When a fetch fails, the plugin falls back to the last cached list (even past its TTL) instead of dropping models.
66
+
67
+ Enrichment matches AxonHub model ids against the provider catalog OpenCode already loaded, preferring the entry whose provider id equals `owned_by`, then `opencode`, then `openai`. Enriched fields are `family`, `name`, `compatibility`, `capabilities`, `variants` (reasoning-effort variants), `cost`, `limit`, `status`, `headers`, `body`, and settings other than `baseURL`; AxonHub's own context length, output limit, capabilities, and pricing win where it reports them.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "opencode-v2-axonhub",
3
+ "version": "0.3.1",
4
+ "description": "OpenCode V2 plugin for discovering AxonHub models",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/zsxsoft/opencode-axonhub"
10
+ },
11
+ "keywords": [
12
+ "opencode",
13
+ "opencode-plugin",
14
+ "axonhub",
15
+ "llm"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "server.ts",
20
+ "README.md",
21
+ "LICENSE",
22
+ "package.json"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "main": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./server": "./src/index.ts"
31
+ },
32
+ "types": "./src/index.ts",
33
+ "peerDependencies": {
34
+ "@opencode/plugin": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@opencode/plugin": "^2.0.14",
38
+ "@types/node": "^22.18.10",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "scripts": {
42
+ "check": "tsc --noEmit"
43
+ }
44
+ }
package/server.ts ADDED
@@ -0,0 +1,3 @@
1
+ import plugin from "./src/index.ts"
2
+
3
+ export default plugin
package/src/index.ts ADDED
@@ -0,0 +1,390 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import type { Model, Provider } from "@opencode/plugin"
3
+ import type { ProviderEditor } from "@opencode/plugin/promise/provider"
4
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"
5
+ import { homedir } from "node:os"
6
+ import { dirname, join } from "node:path"
7
+
8
+ const PROVIDER_ID = "axonhub"
9
+ const INTEGRATION_ID = "axonhub"
10
+ const CACHE_FILE = join(homedir(), ".cache", "opencode", "axonhub-models.json")
11
+ const LOG_FILE = join(homedir(), ".cache", "opencode", "axonhub-plugin.log")
12
+ const CACHE_TTL = 24 * 60 * 60 * 1000
13
+ const HTTP_TIMEOUT = 15_000
14
+
15
+ const ENV_KEY = "AXONHUB_API_KEY"
16
+ const ENV_BASE_URL = "AXONHUB_BASE_URL"
17
+
18
+ // OpenCode V2 ships these provider entrypoints in-process, so an AxonHub model
19
+ // only has to declare which protocol its AxonHub endpoint speaks.
20
+ const PACKAGES = {
21
+ anthropic: "@opencode/ai/providers/anthropic",
22
+ google: "@opencode/ai/providers/google",
23
+ openai: "@opencode/ai/providers/openai",
24
+ } as const
25
+
26
+ type PackageID = (typeof PACKAGES)[keyof typeof PACKAGES]
27
+
28
+ type ModelInfo = typeof Model.Info.Type
29
+ type ProviderInfo = typeof Provider.Info.Type
30
+ type ModelCost = ModelInfo["cost"][number]
31
+
32
+ type PluginOptions = {
33
+ /** AxonHub origin, for example `https://axonhub.example.com`. Falls back to `AXONHUB_BASE_URL`. */
34
+ baseURL?: string
35
+ /** API key. Falls back to `AXONHUB_API_KEY`, then to the credential stored for the `axonhub` integration. */
36
+ apiKey?: string
37
+ /** Copy metadata (family, cost, limits, capabilities, variants) from OpenCode's model catalog. Defaults to true. */
38
+ enrichModels?: boolean
39
+ /** Append discovery diagnostics to `~/.cache/opencode/axonhub-plugin.log`. Defaults to true. */
40
+ log?: boolean
41
+ }
42
+
43
+ type Logger = (message: string, extra?: Record<string, unknown>) => Promise<void>
44
+
45
+ type AxonHubCapabilities = {
46
+ vision?: boolean
47
+ tool_call?: boolean
48
+ toolCall?: boolean
49
+ reasoning?: boolean
50
+ }
51
+
52
+ type AxonHubPricing = {
53
+ input?: number
54
+ output?: number
55
+ cache_read?: number
56
+ cacheRead?: number
57
+ cache_write?: number
58
+ cacheWrite?: number
59
+ }
60
+
61
+ type AxonHubModel = {
62
+ id?: string
63
+ name?: string
64
+ display_name?: string
65
+ created?: number
66
+ created_at?: string
67
+ owned_by?: string
68
+ context_length?: number
69
+ max_output_tokens?: number
70
+ capabilities?: AxonHubCapabilities
71
+ pricing?: AxonHubPricing
72
+ }
73
+
74
+ type AxonHubResponse = {
75
+ data?: AxonHubModel[]
76
+ }
77
+
78
+ type CatalogMatch = {
79
+ providerID: string
80
+ model: ModelInfo
81
+ }
82
+
83
+ function normalizeBaseURL(baseURL: string) {
84
+ return baseURL.replace(/\/v1\/?$/, "").replace(/\/+$/, "")
85
+ }
86
+
87
+ function packageFor(owner: string, modelID: string): PackageID {
88
+ if (modelID.startsWith("gemini-") || owner === "google" || owner === "gemini") return PACKAGES.google
89
+ if (owner === "openai") return PACKAGES.openai
90
+ return PACKAGES.anthropic
91
+ }
92
+
93
+ function endpointFor(baseURL: string, pkg: PackageID) {
94
+ const clean = normalizeBaseURL(baseURL)
95
+ if (pkg === PACKAGES.google) return `${clean}/gemini/v1beta`
96
+ if (pkg === PACKAGES.openai) return `${clean}/v1`
97
+ return `${clean}/anthropic/v1`
98
+ }
99
+
100
+ function released(item: AxonHubModel, template: ModelInfo | undefined) {
101
+ if (item.created_at) {
102
+ const parsed = Date.parse(item.created_at)
103
+ if (Number.isFinite(parsed)) return parsed
104
+ }
105
+ if (item.created) return item.created * 1000
106
+ return template?.time.released ?? 0
107
+ }
108
+
109
+ function copyCost(cost: ModelInfo["cost"]): ModelInfo["cost"] {
110
+ return cost.map((item) => ({
111
+ ...item,
112
+ ...(item.tier ? { tier: { ...item.tier } } : {}),
113
+ cache: { ...item.cache },
114
+ }))
115
+ }
116
+
117
+ function copyVariants(variants: ModelInfo["variants"]): ModelInfo["variants"] {
118
+ return variants.map((variant) => ({
119
+ id: variant.id,
120
+ ...(variant.settings === undefined ? {} : { settings: { ...variant.settings } }),
121
+ ...(variant.headers === undefined ? {} : { headers: { ...variant.headers } }),
122
+ ...(variant.body === undefined ? {} : { body: { ...variant.body } }),
123
+ }))
124
+ }
125
+
126
+ function catalogIndex(editor: ProviderEditor) {
127
+ const index = new Map<string, CatalogMatch[]>()
128
+ for (const record of editor.list()) {
129
+ for (const model of record.models.values()) {
130
+ const match: CatalogMatch = { providerID: record.provider.id, model }
131
+ for (const id of new Set([model.id, model.modelID])) {
132
+ const existing = index.get(id)
133
+ if (existing) existing.push(match)
134
+ else index.set(id, [match])
135
+ }
136
+ }
137
+ }
138
+ return index
139
+ }
140
+
141
+ function catalogMatch(item: AxonHubModel, index: Map<string, CatalogMatch[]>) {
142
+ if (!item.id) return
143
+ const matches = index.get(item.id)
144
+ if (!matches?.length) return
145
+
146
+ const owner = item.owned_by
147
+ return (
148
+ (owner ? matches.find((match) => match.providerID === owner) : undefined) ??
149
+ matches.find((match) => match.providerID === "opencode") ??
150
+ matches.find((match) => match.providerID === "openai") ??
151
+ matches[0]
152
+ )
153
+ }
154
+
155
+ function buildModel(item: AxonHubModel, baseURL: string, match: CatalogMatch | undefined): ModelInfo | undefined {
156
+ if (!item.id) return
157
+ const id = item.id
158
+ const owner = item.owned_by ?? ""
159
+ const template = match?.model
160
+ const pkg = packageFor(owner, id)
161
+ const capabilities = item.capabilities
162
+
163
+ const input = (modality: string) => template?.capabilities.input.includes(modality) === true
164
+ const output = (modality: string) => template?.capabilities.output.includes(modality) === true
165
+ const vision = capabilities?.vision ?? (template ? input("image") : true)
166
+ const wantsPDF = template ? input("pdf") : true
167
+
168
+ const pricing: ModelCost = {
169
+ input: (item.pricing?.input ?? 0) as ModelCost["input"],
170
+ output: (item.pricing?.output ?? 0) as ModelCost["output"],
171
+ cache: {
172
+ read: (item.pricing?.cache_read ?? item.pricing?.cacheRead ?? 0) as ModelCost["input"],
173
+ write: (item.pricing?.cache_write ?? item.pricing?.cacheWrite ?? 0) as ModelCost["input"],
174
+ },
175
+ }
176
+ const base = template?.cost[0]
177
+ const merged: ModelCost = {
178
+ input: base?.input ?? pricing.input,
179
+ output: base?.output ?? pricing.output,
180
+ cache: {
181
+ read: base?.cache.read ?? pricing.cache.read,
182
+ write: base?.cache.write ?? pricing.cache.write,
183
+ },
184
+ }
185
+
186
+ return {
187
+ id: id as ModelInfo["id"],
188
+ modelID: id as ModelInfo["modelID"],
189
+ providerID: PROVIDER_ID as ModelInfo["providerID"],
190
+ name: item.name ?? item.display_name ?? template?.name ?? id,
191
+ ...(template?.family === undefined ? {} : { family: template.family }),
192
+ package: pkg,
193
+ settings: { ...(template?.settings ?? {}), baseURL: endpointFor(baseURL, pkg) },
194
+ ...(template?.headers === undefined ? {} : { headers: { ...template.headers } }),
195
+ ...(template?.body === undefined ? {} : { body: { ...template.body } }),
196
+ ...(template?.compatibility === undefined ? {} : { compatibility: { ...template.compatibility } }),
197
+ capabilities: {
198
+ tools: capabilities?.tool_call ?? capabilities?.toolCall ?? template?.capabilities.tools ?? true,
199
+ input: Array.from(
200
+ new Set([
201
+ "text",
202
+ ...(vision ? ["image"] : []),
203
+ ...(input("audio") ? ["audio"] : []),
204
+ ...(input("video") ? ["video"] : []),
205
+ ...(wantsPDF ? ["pdf"] : []),
206
+ ]),
207
+ ),
208
+ output: Array.from(
209
+ new Set([
210
+ ...(template ? (output("text") ? ["text"] : []) : ["text"]),
211
+ ...(output("image") ? ["image"] : []),
212
+ ...(output("audio") ? ["audio"] : []),
213
+ ...(output("video") ? ["video"] : []),
214
+ ...(output("pdf") ? ["pdf"] : []),
215
+ ]),
216
+ ),
217
+ },
218
+ variants: template ? copyVariants(template.variants) : [],
219
+ time: { released: released(item, template) },
220
+ cost: template?.cost.length ? [merged, ...copyCost(template.cost.slice(1))] : [merged],
221
+ status: template?.status ?? "active",
222
+ enabled: true,
223
+ limit: {
224
+ context: item.context_length ?? template?.limit.context ?? 200_000,
225
+ ...(template?.limit.input === undefined ? {} : { input: template.limit.input }),
226
+ output: item.max_output_tokens ?? template?.limit.output ?? 32_000,
227
+ },
228
+ }
229
+ }
230
+
231
+ function createLogger(enabled: boolean): Logger {
232
+ return async (message, extra) => {
233
+ if (!enabled) return
234
+ try {
235
+ await mkdir(dirname(LOG_FILE), { recursive: true })
236
+ await appendFile(LOG_FILE, `${new Date().toISOString()} ${message}${extra ? ` ${JSON.stringify(extra)}` : ""}\n`)
237
+ } catch {}
238
+ }
239
+ }
240
+
241
+ async function readCache(freshOnly: boolean) {
242
+ try {
243
+ const info = await stat(CACHE_FILE)
244
+ if (freshOnly && Date.now() - info.mtimeMs > CACHE_TTL) return
245
+ return JSON.parse(await readFile(CACHE_FILE, "utf8")) as AxonHubResponse
246
+ } catch {
247
+ return
248
+ }
249
+ }
250
+
251
+ async function writeCache(payload: AxonHubResponse) {
252
+ await mkdir(dirname(CACHE_FILE), { recursive: true })
253
+ await writeFile(CACHE_FILE, JSON.stringify(payload, null, 2))
254
+ }
255
+
256
+ async function fetchModels(baseURL: string, key: string, log: Logger) {
257
+ const clean = normalizeBaseURL(baseURL)
258
+ const headers = { Authorization: `Bearer ${key}` }
259
+ await log("fetching AxonHub models", { baseURL: clean })
260
+ const responses = await Promise.all(
261
+ [`${clean}/v1/models`, `${clean}/v1/models?include=all`].map((url) =>
262
+ fetch(url, { headers, signal: AbortSignal.timeout(HTTP_TIMEOUT) }),
263
+ ),
264
+ )
265
+
266
+ const payloads: AxonHubResponse[] = []
267
+ for (const response of responses) {
268
+ if (!response.ok) {
269
+ await log("AxonHub model endpoint failed", { status: response.status, url: response.url })
270
+ continue
271
+ }
272
+ const payload = (await response.json()) as AxonHubResponse
273
+ if (Array.isArray(payload.data)) payloads.push(payload)
274
+ }
275
+ if (payloads.length === 0) throw new Error("no AxonHub model payload was returned")
276
+
277
+ const byID = new Map<string, AxonHubModel>()
278
+ for (const payload of payloads) {
279
+ for (const model of payload.data ?? []) {
280
+ if (!model.id) continue
281
+ byID.set(model.id, { ...byID.get(model.id), ...model })
282
+ }
283
+ }
284
+ return { data: [...byID.values()] } satisfies AxonHubResponse
285
+ }
286
+
287
+ export default Plugin.define({
288
+ id: "opencode-axonhub",
289
+ async setup(ctx) {
290
+ const options = (ctx.options ?? {}) as PluginOptions
291
+ const baseURL = normalizeBaseURL(options.baseURL ?? process.env[ENV_BASE_URL] ?? "")
292
+ const enrich = options.enrichModels ?? true
293
+ const log = createLogger(options.log ?? true)
294
+
295
+ let payload: AxonHubResponse | undefined
296
+ let key: string | undefined
297
+
298
+ const storedKey = async () => {
299
+ // Credentials resolve through the integration, so `opencode auth login axonhub` and
300
+ // `AXONHUB_API_KEY` both feed discovery without the plugin reading auth files.
301
+ const connection = await ctx.integration.connection.active(INTEGRATION_ID)
302
+ if (!connection) return
303
+ const value = await ctx.integration.connection.resolve(connection)
304
+ return value?.type === "key" ? value.key : undefined
305
+ }
306
+
307
+ // Returns whether the discovered catalog changed, so an unchanged credential
308
+ // does not refetch or reload the provider.
309
+ const discover = async () => {
310
+ if (!baseURL) {
311
+ await log("no AxonHub base URL configured; skipping model discovery")
312
+ return false
313
+ }
314
+ const next = options.apiKey ?? process.env[ENV_KEY] ?? (await storedKey())
315
+ if (!next) {
316
+ const changed = key !== undefined || payload !== undefined
317
+ key = undefined
318
+ payload = undefined
319
+ await log("no AxonHub API key available; skipping model discovery")
320
+ return changed
321
+ }
322
+ if (next === key && payload) return false
323
+ key = next
324
+ const cached = await readCache(true)
325
+ if (cached) {
326
+ payload = cached
327
+ await log("loaded AxonHub models from cache", { models: cached.data?.length ?? 0 })
328
+ return true
329
+ }
330
+ try {
331
+ payload = await fetchModels(baseURL, next, log)
332
+ await writeCache(payload)
333
+ } catch (error) {
334
+ await log("AxonHub model discovery failed", { error: `${error}` })
335
+ const stale = await readCache(false)
336
+ if (!stale) return false
337
+ payload = stale
338
+ }
339
+ await log("discovered AxonHub models", { models: payload.data?.length ?? 0 })
340
+ return true
341
+ }
342
+
343
+ await ctx.integration.transform((editor) => {
344
+ editor.update(INTEGRATION_ID, (integration) => {
345
+ integration.name = "AxonHub"
346
+ })
347
+ editor.method.update({ integrationID: INTEGRATION_ID, method: { type: "key", label: "API Key" } })
348
+ editor.method.update({ integrationID: INTEGRATION_ID, method: { type: "env", names: [ENV_KEY] } })
349
+ })
350
+
351
+ await discover()
352
+
353
+ await ctx.provider.transform((editor) => {
354
+ if (!payload?.data?.length || !baseURL) return
355
+ const index = enrich ? catalogIndex(editor) : undefined
356
+ const models = payload.data.flatMap((item) => {
357
+ const model = buildModel(item, baseURL, index ? catalogMatch(item, index) : undefined)
358
+ return model ? [model] : []
359
+ })
360
+ if (models.length === 0) return
361
+ const info: ProviderInfo = {
362
+ id: PROVIDER_ID as ProviderInfo["id"],
363
+ name: "AxonHub",
364
+ activation: "auto",
365
+ integrationID: INTEGRATION_ID as ProviderInfo["integrationID"],
366
+ package: PACKAGES.anthropic,
367
+ settings: {},
368
+ }
369
+ editor.add({ info, models })
370
+ })
371
+
372
+ void (async () => {
373
+ for await (const event of ctx.event.subscribe()) {
374
+ if (
375
+ event.type !== "credential.updated" &&
376
+ event.type !== "credential.switched" &&
377
+ event.type !== "integration.updated"
378
+ )
379
+ continue
380
+ try {
381
+ if (!(await discover())) continue
382
+ await ctx.provider.reload()
383
+ await log("reloaded AxonHub provider after credential change")
384
+ } catch (error) {
385
+ await log("AxonHub provider reload failed", { error: `${error}` })
386
+ }
387
+ }
388
+ })().catch(() => {})
389
+ },
390
+ })