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 +21 -0
- package/README.md +67 -0
- package/package.json +44 -0
- package/server.ts +3 -0
- package/src/index.ts +390 -0
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
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
|
+
})
|