From a7004683f5eef1f06e5a07b7661d5c003fa0a099 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:52:44 +0300 Subject: [PATCH 1/7] ci: run checks on pull requests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b045269..5def5f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - master + pull_request: permissions: contents: read From 7b0c9f68969331c454cd8f1619337177cada45a2 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:53:19 +0300 Subject: [PATCH 2/7] chore: remove stale dist/ directory and add to .gitignore --- .gitignore | 1 + dist/index.d.ts | 7 ---- dist/index.js | 86 ---------------------------------------------- dist/index.js.map | 1 - dist/limits.d.ts | 5 --- dist/limits.js | 34 ------------------ dist/limits.js.map | 1 - dist/models.d.ts | 2 -- dist/models.js | 66 ----------------------------------- dist/models.js.map | 1 - 10 files changed, 1 insertion(+), 203 deletions(-) delete mode 100644 dist/index.d.ts delete mode 100644 dist/index.js delete mode 100644 dist/index.js.map delete mode 100644 dist/limits.d.ts delete mode 100644 dist/limits.js delete mode 100644 dist/limits.js.map delete mode 100644 dist/models.d.ts delete mode 100644 dist/models.js delete mode 100644 dist/models.js.map diff --git a/.gitignore b/.gitignore index c2658d7..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index 91ece3b..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Hooks, PluginInput } from "@opencode-ai/plugin"; -declare function plugin(_input: PluginInput): Promise; -declare const _default: { - id: string; - server: typeof plugin; -}; -export default _default; diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index 6d4049c..0000000 --- a/dist/index.js +++ /dev/null @@ -1,86 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { fetchModels } from "./models.js"; -const PROVIDER_NAME = "CoreInfra AI Hub"; -const LEGACY_PROVIDER_NAMES = new Set(["coreinfra", "CoreInfra Hub"]); -const OPENCODE_MODELS_CACHE_PATH = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "opencode", "models.json"); -function resolveProviderName(currentName) { - return !currentName || LEGACY_PROVIDER_NAMES.has(currentName) ? PROVIDER_NAME : currentName; -} -async function ensureOpencodeProviderMetadata() { - let raw; - try { - raw = await readFile(OPENCODE_MODELS_CACHE_PATH, "utf8"); - } - catch { - return; - } - let cache; - try { - cache = JSON.parse(raw); - } - catch { - return; - } - const current = cache.coreinfra; - const name = resolveProviderName(current?.name); - if (current?.id === "coreinfra" && - current?.name === name && - Array.isArray(current.env) && - current.models && - typeof current.models === "object") { - return; - } - cache.coreinfra = { - ...(current ?? {}), - id: "coreinfra", - name, - env: current?.env ?? [], - models: current?.models ?? {}, - }; - await mkdir(dirname(OPENCODE_MODELS_CACHE_PATH), { recursive: true }); - await writeFile(OPENCODE_MODELS_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`); -} -function ensureCoreInfraProvider(config) { - config.provider ??= {}; - const currentName = config.provider.coreinfra?.name; - config.provider.coreinfra = { - ...(config.provider.coreinfra ?? {}), - name: resolveProviderName(currentName), - }; -} -async function plugin(_input) { - await ensureOpencodeProviderMetadata(); - return { - config: async (config) => { - ensureCoreInfraProvider(config); - }, - auth: { - provider: "coreinfra", - methods: [ - { - type: "api", - label: "CoreInfra API Key", - }, - ], - loader: async (getAuth) => { - const auth = await getAuth(); - if (!auth || auth.type !== "api") - return {}; - return { apiKey: auth.key }; - }, - }, - provider: { - id: "coreinfra", - models: async () => { - return await fetchModels(); - }, - }, - }; -} -export default { - id: "coreinfra-opencode-plugin", - server: plugin, -}; -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map deleted file mode 100644 index 3448955..0000000 --- a/dist/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/limits.d.ts b/dist/limits.d.ts deleted file mode 100644 index 3bfbf43..0000000 --- a/dist/limits.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ModelLimits = { - context: number; - output: number; -}; -export declare function getLimits(modelId: string): ModelLimits; diff --git a/dist/limits.js b/dist/limits.js deleted file mode 100644 index c51bc17..0000000 --- a/dist/limits.js +++ /dev/null @@ -1,34 +0,0 @@ -const OPENAI_LIMITS = { - "gpt-5": { context: 400000, output: 128000 }, - "gpt-5-nano": { context: 400000, output: 128000 }, - "gpt-5-codex": { context: 400000, output: 128000 }, - "gpt-5.1": { context: 400000, output: 128000 }, - "gpt-5.1-codex": { context: 400000, output: 128000 }, - "gpt-5.1-codex-mini": { context: 400000, output: 128000 }, - "gpt-5.1-codex-max": { context: 400000, output: 128000 }, - "gpt-5.2": { context: 400000, output: 128000 }, - "gpt-5.2-codex": { context: 400000, output: 128000 }, - "gpt-5.2-pro": { context: 400000, output: 128000 }, - "gpt-5.3-codex": { context: 400000, output: 128000 }, - "gpt-5.4-mini": { context: 400000, output: 128000 }, - "gpt-5.4-nano": { context: 400000, output: 128000 }, - "gpt-5-pro": { context: 400000, output: 272000 }, - "gpt-5.4": { context: 1050000, output: 128000 }, - "gpt-5.4-pro": { context: 1050000, output: 128000 }, -}; -const ANTHROPIC_LIMITS = { - "claude-3-haiku-20240307": { context: 200000, output: 4096 }, - "claude-opus-4-20250514": { context: 200000, output: 32000 }, - "claude-opus-4-1-20250805": { context: 200000, output: 32000 }, - "claude-haiku-4-5-20251001": { context: 200000, output: 64000 }, - "claude-sonnet-4-20250514": { context: 200000, output: 64000 }, - "claude-sonnet-4-5-20250929": { context: 200000, output: 64000 }, - "claude-opus-4-5-20251101": { context: 200000, output: 64000 }, - "claude-sonnet-4-6": { context: 1000000, output: 64000 }, - "claude-opus-4-6": { context: 1000000, output: 128000 }, -}; -const DEFAULT_LIMITS = { context: 200000, output: 64000 }; -export function getLimits(modelId) { - return OPENAI_LIMITS[modelId] ?? ANTHROPIC_LIMITS[modelId] ?? DEFAULT_LIMITS; -} -//# sourceMappingURL=limits.js.map \ No newline at end of file diff --git a/dist/limits.js.map b/dist/limits.js.map deleted file mode 100644 index dda6e50..0000000 --- a/dist/limits.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"limits.js","sourceRoot":"","sources":["../src/limits.ts"],"names":[],"mappings":"AAKA,MAAM,aAAa,GAAgC;IACjD,OAAO,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAC5C,YAAY,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACjD,aAAa,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAClD,SAAS,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAC9C,eAAe,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACpD,oBAAoB,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACzD,mBAAmB,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAC9C,eAAe,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACpD,aAAa,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAClD,eAAe,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACpD,cAAc,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACnD,cAAc,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IACnD,WAAW,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAChD,SAAS,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE;IAC/C,aAAa,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE;CACpD,CAAA;AAED,MAAM,gBAAgB,GAAgC;IACpD,yBAAyB,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE;IAC5D,wBAAwB,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAC5D,0BAA0B,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAC9D,2BAA2B,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAC/D,0BAA0B,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAC9D,4BAA4B,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAChE,0BAA0B,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;IAC9D,mBAAmB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE;IACxD,iBAAiB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE;CACxD,CAAA;AAED,MAAM,cAAc,GAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;AAEtE,MAAM,UAAU,SAAS,CAAC,OAAe;IACvC,OAAO,aAAa,CAAC,OAAO,CAAC,IAAI,gBAAgB,CAAC,OAAO,CAAC,IAAI,cAAc,CAAA;AAC9E,CAAC"} \ No newline at end of file diff --git a/dist/models.d.ts b/dist/models.d.ts deleted file mode 100644 index 479892a..0000000 --- a/dist/models.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import type { Model } from "@opencode-ai/sdk/v2"; -export declare function fetchModels(): Promise>; diff --git a/dist/models.js b/dist/models.js deleted file mode 100644 index 407e507..0000000 --- a/dist/models.js +++ /dev/null @@ -1,66 +0,0 @@ -import { getLimits } from "./limits.js"; -const HUB_URL = "https://hub.coreinfra.ai/hub/api/prices"; -const OPENAI_BASE = "https://hub.coreinfra.ai/codex/api/v1"; -const ANTHROPIC_BASE = "https://hub.coreinfra.ai/claude/api/v1"; -const ANTHROPIC_BETA_HEADER = "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"; -function isAnthropic(provider) { - return provider === "anthropic"; -} -function buildModel(modelId, provider, displayName, prices) { - const anthropic = isAnthropic(provider); - const limits = getLimits(modelId); - return { - id: modelId, - providerID: "coreinfra", - api: { - id: modelId, - url: anthropic ? ANTHROPIC_BASE : OPENAI_BASE, - npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai", - }, - name: displayName, - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { text: true, audio: true, image: true, video: true, pdf: true }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: anthropic - ? { field: "reasoning_content" } - : true, - }, - cost: { - input: prices.input_tokens, - output: prices.output_tokens, - cache: { - read: prices.cache_read_tokens ?? 0, - write: prices.cache_5m_write_tokens ?? 0, - }, - }, - limit: { - context: limits.context, - output: limits.output, - }, - status: "active", - options: {}, - headers: anthropic - ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } - : {}, - release_date: "", - }; -} -export async function fetchModels() { - const res = await fetch(HUB_URL); - if (!res.ok) { - throw new Error(`Failed to fetch CoreInfra prices: ${res.status} ${res.statusText}`); - } - const data = await res.json(); - const models = {}; - for (const [provider, providerData] of Object.entries(data.providers)) { - for (const [modelId, modelData] of Object.entries(providerData.models)) { - models[modelId] = buildModel(modelId, provider, modelData.display_name, modelData.prices); - } - } - return models; -} -//# sourceMappingURL=models.js.map \ No newline at end of file diff --git a/dist/models.js.map b/dist/models.js.map deleted file mode 100644 index 4b921b0..0000000 --- a/dist/models.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"models.js","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"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"} \ No newline at end of file From 06d9bdca53eeebfc47824d336efd59f78a4faab0 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:53:50 +0300 Subject: [PATCH 3/7] docs: fix install command in Russian README --- README.ru.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ru.md b/README.ru.md index 6832c46..4c9bfdb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -8,7 +8,7 @@ Установка плагина ```bash -opencode plugin -g 'coreinfra-opencode-plugin@github:CoreInfraAI/opencode-plugin' +opencode plugin -g '@coreinfra/opencode-plugin@latest' ``` Авторизация с вводом API-токена From d0f329e2050ee3ecb67376b1f262bc4c7086cae6 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:54:15 +0300 Subject: [PATCH 4/7] fix: make logging fire-and-forget to prevent startup hang --- src/index.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index e024d37..94e1f03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,15 +20,22 @@ function ensureCoreInfraProvider(config: Config) { }; } -async function log(input: PluginInput, message: string, extra?: Record) { - await input.client.app.log({ - body: { - service: "plugin.coreinfra", - level: "debug", - message, - extra, - }, - }); +async function log( + input: PluginInput, + message: string, + extra?: Record, + level: "debug" | "warn" | "error" = "debug", +) { + await input.client.app + .log({ + body: { + service: "plugin.coreinfra", + level, + message, + extra, + }, + }) + .catch(() => {}); } async function plugin(input: PluginInput): Promise { From 295af81497bfb51e348150fab77db24bbfc2aadf Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:54:52 +0300 Subject: [PATCH 5/7] refactor: remove legacy provider name resolution --- src/index.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 94e1f03..156f787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,20 +3,12 @@ import type { Config, Hooks, PluginInput } from "@opencode-ai/plugin"; import { fetchModels } from "./models.ts"; const PROVIDER_NAME = "CoreInfra AI Hub"; -const LEGACY_PROVIDER_NAMES = new Set(["coreinfra", "CoreInfra Hub"]); - -function resolveProviderName(currentName?: string) { - return !currentName || LEGACY_PROVIDER_NAMES.has(currentName) - ? PROVIDER_NAME - : currentName; -} function ensureCoreInfraProvider(config: Config) { config.provider ??= {}; - const currentName = config.provider.coreinfra?.name; config.provider.coreinfra = { ...(config.provider.coreinfra ?? {}), - name: resolveProviderName(currentName), + name: PROVIDER_NAME, }; } From 0875c83737893eee1dd12c149309b7d223d4d297 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:58:10 +0300 Subject: [PATCH 6/7] feat: switch to runtime models.dev intersection via config hook Replace static models.json with runtime intersection of models.dev API and Hub prices endpoint. Fetch model capabilities (limits, reasoning, tool use, modalities, costs) from models.dev at startup, intersected with the Hub catalog. Move model population from provider hook into config hook. Add 10s fetch timeout and xdg-basedir cache for models.dev data. Support COREINFRA_HUB_BASE_URL env var for custom endpoints. Delete models.json, scripts/fetch-models.ts, and the update-models CI workflow. --- .github/workflows/update-models.yml | 51 ---- bun.lock | 5 + models.json | 429 ---------------------------- package.json | 4 +- scripts/fetch-models.ts | 287 ------------------- src/index.ts | 61 ++-- src/models.ts | 269 +++++++++++------ test/index.test.ts | 231 +++++++++++++++ test/models.test.ts | 371 ++++++++++++++++++++---- 9 files changed, 781 insertions(+), 927 deletions(-) delete mode 100644 .github/workflows/update-models.yml delete mode 100644 models.json delete mode 100644 scripts/fetch-models.ts create mode 100644 test/index.test.ts diff --git a/.github/workflows/update-models.yml b/.github/workflows/update-models.yml deleted file mode 100644 index 632d7f3..0000000 --- a/.github/workflows/update-models.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Update Models - -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -permissions: - contents: write - -jobs: - update-models: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup just - uses: extractions/setup-just@v2 - - - name: Fetch models - id: fetch - run: just fetch-models - continue-on-error: true - - - name: Check for changes - id: check_changes - run: | - if git diff --quiet models.json; then - echo "changed=false" >> $GITHUB_OUTPUT - else - echo "changed=true" >> $GITHUB_OUTPUT - fi - - - name: Commit and push - if: steps.check_changes.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add models.json - git commit -m "chore: update models [skip ci]" - git push - - - name: Fail if fetch had missing models - if: steps.fetch.outcome == 'failure' - run: | - echo "❌ fetch-models exited non-zero: some hub models are missing from models.dev" - exit 1 diff --git a/bun.lock b/bun.lock index 6820441..dbe2ebf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "coreinfra-opencode-plugin", + "dependencies": { + "xdg-basedir": "^5.1.0", + }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@opencode-ai/plugin": "1.4.0", @@ -209,6 +212,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/models.json b/models.json deleted file mode 100644 index 3270bcb..0000000 --- a/models.json +++ /dev/null @@ -1,429 +0,0 @@ -{ - "models": { - "claude-haiku-4-5-20251001": { - "name": "Claude Haiku 4.5", - "limit": { - "context": 200000, - "output": 64000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25 - } - }, - "claude-opus-4-1-20250805": { - "name": "Claude Opus 4.1", - "limit": { - "context": 200000, - "output": 32000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75 - } - }, - "claude-opus-4-5-20251101": { - "name": "Claude Opus 4.5", - "limit": { - "context": 200000, - "output": 64000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 - } - }, - "claude-opus-4-6": { - "name": "Claude Opus 4.6", - "limit": { - "context": 1000000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 - } - }, - "claude-opus-4-7": { - "name": "Claude Opus 4.7", - "limit": { - "context": 1000000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 - } - }, - "claude-sonnet-4-5-20250929": { - "name": "Claude Sonnet 4.5", - "limit": { - "context": 200000, - "output": 64000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, - "claude-sonnet-4-6": { - "name": "Claude Sonnet 4.6", - "limit": { - "context": 1000000, - "output": 64000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, - "gpt-5": { - "name": "GPT-5", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.25, - "output": 10, - "cache_read": 0.125, - "cache_write": 0 - } - }, - "gpt-5-codex": { - "name": "GPT-5-Codex", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": false, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.25, - "output": 10, - "cache_read": 0.125, - "cache_write": 0 - } - }, - "gpt-5-nano": { - "name": "GPT-5 Nano", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 0.05, - "output": 0.4, - "cache_read": 0.005, - "cache_write": 0 - } - }, - "gpt-5-pro": { - "name": "GPT-5 Pro", - "limit": { - "context": 400000, - "output": 272000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 15, - "output": 120, - "cache_read": 0, - "cache_write": 0 - } - }, - "gpt-5.1": { - "name": "GPT-5.1", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.25, - "output": 10, - "cache_read": 0.13, - "cache_write": 0 - } - }, - "gpt-5.1-codex": { - "name": "GPT-5.1 Codex", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.25, - "output": 10, - "cache_read": 0.125, - "cache_write": 0 - } - }, - "gpt-5.1-codex-max": { - "name": "GPT-5.1 Codex Max", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.25, - "output": 10, - "cache_read": 0.125, - "cache_write": 0 - } - }, - "gpt-5.1-codex-mini": { - "name": "GPT-5.1 Codex mini", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 0.25, - "output": 2, - "cache_read": 0.025, - "cache_write": 0 - } - }, - "gpt-5.2": { - "name": "GPT-5.2", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175, - "cache_write": 0 - } - }, - "gpt-5.2-codex": { - "name": "GPT-5.2 Codex", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175, - "cache_write": 0 - } - }, - "gpt-5.2-pro": { - "name": "GPT-5.2 Pro", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 21, - "output": 168, - "cache_read": 0, - "cache_write": 0 - } - }, - "gpt-5.3-codex": { - "name": "GPT-5.3 Codex", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175, - "cache_write": 0 - } - }, - "gpt-5.4": { - "name": "GPT-5.4", - "limit": { - "context": 1050000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25, - "cache_write": 0 - } - }, - "gpt-5.4-mini": { - "name": "GPT-5.4 mini", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 0.75, - "output": 4.5, - "cache_read": 0.075, - "cache_write": 0 - } - }, - "gpt-5.4-nano": { - "name": "GPT-5.4 nano", - "limit": { - "context": 400000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 0.2, - "output": 1.25, - "cache_read": 0.02, - "cache_write": 0 - } - }, - "gpt-5.4-pro": { - "name": "GPT-5.4 Pro", - "limit": { - "context": 1050000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 30, - "output": 180, - "cache_read": 0, - "cache_write": 0 - } - }, - "gpt-5.5": { - "name": "GPT-5.5", - "limit": { - "context": 1050000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5, - "cache_write": 0 - } - }, - "gpt-5.5-pro": { - "name": "GPT-5.5 Pro", - "limit": { - "context": 1050000, - "output": 128000 - }, - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "cost": { - "input": 30, - "output": 180, - "cache_read": 0, - "cache_write": 0 - } - } - } -} diff --git a/package.json b/package.json index 9739c57..82564b4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "files": [ "index.ts", "src", - "models.json", "README.md", "LICENSE" ], @@ -41,5 +40,8 @@ "@biomejs/biome": "^2.4.11", "vitest": "^4.1.4", "typescript": "^5.8.0" + }, + "dependencies": { + "xdg-basedir": "^5.1.0" } } diff --git a/scripts/fetch-models.ts b/scripts/fetch-models.ts deleted file mode 100644 index b36b37b..0000000 --- a/scripts/fetch-models.ts +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env bun -/** - * Fetches model metadata, limits, capabilities, and prices from models.dev and - * intersects that data with hub.coreinfra.ai/hub/api/prices, then writes - * models.json to the repo root. - * - * Usage: bun scripts/fetch-models.ts - */ - -import { writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type ModelDevEntry = { - id: string; - name: string; - limit?: { context?: number; output?: number }; - attachment?: boolean; - reasoning?: boolean; - temperature?: boolean; - tool_call?: boolean; - cost?: { - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; - }; -}; - -type ModelDevResponse = { - [provider: string]: { - models: { - [modelId: string]: ModelDevEntry; - }; - }; -}; - -type HubModelEntry = { - display_name: string; - prices: { - input_tokens: number; - output_tokens: number; - cache_read_tokens?: number; - cache_5m_write_tokens?: number; - }; -}; - -type HubResponse = { - providers: { - [provider: string]: { - models: { - [modelId: string]: HubModelEntry; - }; - }; - }; -}; - -type ModelOutput = { - name: string; - limit: { context: number; output: number }; - attachment: boolean; - reasoning: boolean; - temperature: boolean; - tool_call: boolean; - cost: { - input: number; - output: number; - cache_read: number; - cache_write: number; - }; -}; - -type ModelsJson = { - models: Record; -}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const MODELS_DEV_URL = "https://models.dev/api.json"; -const HUB_URL = "https://hub.coreinfra.ai/hub/api/prices"; - -const DEFAULT_LIMITS = { context: 200000, output: 64000 }; -const DEFAULT_CAPS = { - attachment: true, - reasoning: true, - temperature: true, - tool_call: true, -}; -const DEFAULT_COST = { - input: 0, - output: 0, - cache_read: 0, - cache_write: 0, -}; - -// --------------------------------------------------------------------------- -// Fetch helpers -// --------------------------------------------------------------------------- - -async function fetchJson(url: string): Promise { - const res = await fetch(url); - if (!res.ok) { - throw new Error(`HTTP ${res.status} ${res.statusText} fetching ${url}`); - } - return res.json() as Promise; -} - -// --------------------------------------------------------------------------- -// Build lookup maps from models.dev response -// --------------------------------------------------------------------------- - -type LookupValue = ModelDevEntry | "ambiguous"; - -function buildLookupMaps(modelsDevData: ModelDevResponse): { - byFullId: Map; - byBareId: Map; -} { - const byFullId = new Map(); - const byBareId = new Map(); - - for (const [provider, providerData] of Object.entries(modelsDevData)) { - if (!providerData?.models) continue; - for (const [modelId, entry] of Object.entries(providerData.models)) { - // Full key: "provider/modelId" - byFullId.set(`${provider}/${modelId}`, entry); - - // Bare key: mark as ambiguous if already seen - if (byBareId.has(modelId)) { - byBareId.set(modelId, "ambiguous"); - } else { - byBareId.set(modelId, entry); - } - } - } - - return { byFullId, byBareId }; -} - -// --------------------------------------------------------------------------- -// Resolve a single hub model against the lookup maps -// --------------------------------------------------------------------------- - -function resolveEntry( - provider: string, - modelId: string, - byFullId: Map, - byBareId: Map, -): ModelDevEntry | null { - // 1. Try "provider/modelId" - const fullKey = `${provider}/${modelId}`; - const fullEntry = byFullId.get(fullKey); - if (fullEntry !== undefined) { - return fullEntry; - } - - // 2. Try bare ID in byBareId — only if not ambiguous - const bareValue = byBareId.get(modelId); - if (bareValue !== undefined && bareValue !== "ambiguous") { - return bareValue; - } - - return null; -} - -function extractCost(entry?: ModelDevEntry): ModelOutput["cost"] { - return { - input: entry?.cost?.input ?? DEFAULT_COST.input, - output: entry?.cost?.output ?? DEFAULT_COST.output, - cache_read: entry?.cost?.cache_read ?? DEFAULT_COST.cache_read, - cache_write: entry?.cost?.cache_write ?? DEFAULT_COST.cache_write, - }; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main(): Promise { - // Fetch both APIs in parallel - let modelsDevData: ModelDevResponse; - let hubData: HubResponse; - - try { - [modelsDevData, hubData] = await Promise.all([ - fetchJson(MODELS_DEV_URL), - fetchJson(HUB_URL), - ]); - } catch (err) { - console.error("Fatal: API fetch failed:", (err as Error).message); - process.exit(1); - } - - const { byFullId, byBareId } = buildLookupMaps(modelsDevData); - - type PendingEntry = { - provider: string; - modelId: string; - output: ModelOutput; - }; - const pending: PendingEntry[] = []; - const missingIds: string[] = []; - - for (const [provider, providerData] of Object.entries(hubData.providers)) { - if (!providerData?.models) continue; - - for (const [modelId, hubModel] of Object.entries(providerData.models)) { - const entry = resolveEntry(provider, modelId, byFullId, byBareId); - - if (!entry) { - console.error( - `⚠️ ${provider}/${modelId} not found in models.dev — using default limits (context: ${DEFAULT_LIMITS.context}, output: ${DEFAULT_LIMITS.output})`, - ); - missingIds.push(`${provider}/${modelId}`); - } - - const output: ModelOutput = !entry - ? { - name: hubModel.display_name, - limit: DEFAULT_LIMITS, - ...DEFAULT_CAPS, - cost: DEFAULT_COST, - } - : { - name: entry.name ?? hubModel.display_name, - limit: { - context: entry.limit?.context ?? DEFAULT_LIMITS.context, - output: entry.limit?.output ?? DEFAULT_LIMITS.output, - }, - attachment: entry.attachment ?? DEFAULT_CAPS.attachment, - reasoning: entry.reasoning ?? DEFAULT_CAPS.reasoning, - temperature: DEFAULT_CAPS.temperature, - tool_call: entry.tool_call ?? DEFAULT_CAPS.tool_call, - cost: extractCost(entry), - }; - - pending.push({ provider, modelId, output }); - } - } - - // Sort by provider then model ID for stable, deterministic output - pending.sort((a, b) => { - const byCmp = a.provider.localeCompare(b.provider); - return byCmp !== 0 ? byCmp : a.modelId.localeCompare(b.modelId); - }); - - const outputModels: Record = {}; - for (const { provider, modelId, output } of pending) { - // Warn if this bare ID is already present (last-write-wins collision) - if (outputModels[modelId] !== undefined) { - console.error( - `Warning: bare model ID "${modelId}" collision from provider "${provider}" (overwriting previous entry)`, - ); - } - outputModels[modelId] = output; - } - - const result: ModelsJson = { - models: outputModels, - }; - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const outPath = join(__dirname, "..", "models.json"); - - writeFileSync(outPath, `${JSON.stringify(result, null, 2)}\n`); - - if (missingIds.length > 0) { - console.error( - `\nWarning: ${missingIds.length} hub model(s) not found in models.dev (defaults used):`, - ); - for (const id of missingIds) { - console.error(` - ${id}`); - } - process.exit(1); - } - - console.log( - `✅ Written: ${outPath} (${Object.keys(outputModels).length} models)`, - ); -} - -main(); diff --git a/src/index.ts b/src/index.ts index 156f787..ebb8012 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ import type { Config, Hooks, PluginInput } from "@opencode-ai/plugin"; -import { fetchModels } from "./models.ts"; +import { + buildConfigModels, + fetchModelsDevData, + fetchHubModels, +} from "./models.ts"; const PROVIDER_NAME = "CoreInfra AI Hub"; @@ -33,13 +37,43 @@ async function log( async function plugin(input: PluginInput): Promise { const t0 = performance.now(); await log(input, "plugin load started"); - await log(input, "plugin hooks registered", { duration: `${Math.round(performance.now() - t0)}ms` }); + await log(input, "plugin hooks registered", { + duration: `${Math.round(performance.now() - t0)}ms`, + }); return { config: async (config) => { const t = performance.now(); ensureCoreInfraProvider(config); - await log(input, "config hook completed", { duration: `${Math.round(performance.now() - t)}ms` }); + try { + const [modelsDevData, hubData] = await Promise.all([ + fetchModelsDevData(), + fetchHubModels(), + ]); + const { models: configModels, warnings } = buildConfigModels( + modelsDevData, + hubData, + ); + for (const w of warnings) { + await log(input, w); + } + if (!config.provider) config.provider = {}; + if (!config.provider.coreinfra) config.provider.coreinfra = {}; + config.provider.coreinfra.models = + configModels as typeof config.provider.coreinfra.models; + } catch (err) { + await log( + input, + "fetchModels failed", + { + error: err instanceof Error ? err.message : String(err), + }, + "error", + ); + } + await log(input, "config hook completed", { + duration: `${Math.round(performance.now() - t)}ms`, + }); }, auth: { provider: "coreinfra", @@ -52,21 +86,16 @@ async function plugin(input: PluginInput): Promise { loader: async (getAuth) => { const t = performance.now(); const auth = await getAuth(); - if (!auth || auth.type !== "api") return {}; - await log(input, "auth loader completed", { duration: `${Math.round(performance.now() - t)}ms` }); - return { apiKey: auth.key }; - }, - }, - provider: { - id: "coreinfra", - models: async () => { - const t = performance.now(); - const models = await fetchModels(); - await log(input, "models fetch completed", { + if (!auth || auth.type !== "api") { + await log(input, "auth loader completed (no auth)", { + duration: `${Math.round(performance.now() - t)}ms`, + }); + return {}; + } + await log(input, "auth loader completed", { duration: `${Math.round(performance.now() - t)}ms`, - count: Object.keys(models).length, }); - return models; + return { apiKey: auth.key }; }, }, }; diff --git a/src/models.ts b/src/models.ts index b00043c..954d8d7 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,133 +1,218 @@ -import type { Model } from "@opencode-ai/sdk/v2"; -import modelsData from "../models.json" with { type: "json" }; - -type JsonModelCost = { - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; -}; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { xdgCache } from "xdg-basedir"; -type PricesResponse = { - providers: { - [provider: string]: { - models: { - [model: string]: { - display_name: string; - }; - }; - }; - }; -}; - -const HUB_URL = "https://hub.coreinfra.ai/hub/api/prices"; -const OPENAI_BASE = "https://hub.coreinfra.ai/codex/api/v1"; -const ANTHROPIC_BASE = "https://hub.coreinfra.ai/claude/api/v1"; +const FETCH_TIMEOUT_MS = 10_000; +const CACHE_PATH = join( + xdgCache ?? join(homedir(), ".cache"), + "opencode", + "models.json", +); +const MODELS_DEV_URL = "https://models.dev/api.json"; +const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai"; +const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`; +const OPENAI_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/codex/api/v1`; +const ANTHROPIC_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/claude/api/v1`; const ANTHROPIC_BETA_HEADER = "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"; const DEFAULT_LIMIT = { context: 200000, output: 64000 }; const DEFAULT_CAPS = { - temperature: true, - reasoning: true, attachment: true, + reasoning: true, + temperature: true, tool_call: true, }; -const DEFAULT_COST: Required = { +const DEFAULT_COST = { input: 0, output: 0, cache_read: 0, cache_write: 0, }; -type JsonModelEntry = { +type ModelDevEntry = { + id?: string; + name?: string; limit?: { context?: number; output?: number }; attachment?: boolean; reasoning?: boolean; temperature?: boolean; tool_call?: boolean; - cost?: JsonModelCost; + modalities?: { input?: string[]; output?: string[] }; + cost?: { + input?: number; + output?: number; + cache_read?: number; + cache_write?: number; + }; }; -function isAnthropic(provider: string): boolean { - return provider === "anthropic"; +type ModelsDevData = { + [provider: string]: { + models?: { + [modelId: string]: ModelDevEntry; + }; + }; +}; + +type HubResponse = { + providers: { + [provider: string]: { + models?: { + [model: string]: { display_name: string }; + }; + }; + }; +}; + +export type ConfigModel = { + id: string; + name: string; + provider: { + api: string; + npm: string; + }; + attachment: boolean; + reasoning: boolean; + temperature: boolean; + tool_call: boolean; + modalities: { + input: string[]; + output: string[]; + }; + cost: { + input: number; + output: number; + cache_read: number; + cache_write: number; + }; + limit: { + context: number; + output: number; + }; + interleaved: boolean | { field: string }; + headers: Record; +}; + +function buildLookupMap(modelsDevData: ModelsDevData) { + const byFullId = new Map(); + const allowedProviders = new Set(["openai", "anthropic"]); + + for (const [provider, providerData] of Object.entries(modelsDevData)) { + if (!allowedProviders.has(provider) || !providerData?.models) continue; + for (const [modelId, entry] of Object.entries(providerData.models)) { + byFullId.set(`${provider}/${modelId}`, entry); + } + } + + return byFullId; } -function buildModel( - modelId: string, +function resolveEntry( provider: string, - displayName: string, -): Model { - const anthropic = isAnthropic(provider); - const meta = ( - modelsData.models as Record - )[modelId]; - const cost = { - input: meta?.cost?.input ?? DEFAULT_COST.input, - output: meta?.cost?.output ?? DEFAULT_COST.output, - cache_read: meta?.cost?.cache_read ?? DEFAULT_COST.cache_read, - cache_write: meta?.cost?.cache_write ?? DEFAULT_COST.cache_write, - }; + modelId: string, + byFullId: Map, +): ModelDevEntry | null { + const entry = byFullId.get(`${provider}/${modelId}`); + return entry ?? null; +} +function extractCost(entry?: ModelDevEntry | null) { return { - id: modelId, - providerID: "coreinfra", - api: { - id: modelId, - url: anthropic ? ANTHROPIC_BASE : OPENAI_BASE, - npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai", - }, - name: displayName, - capabilities: { - temperature: meta?.temperature ?? DEFAULT_CAPS.temperature, - reasoning: meta?.reasoning ?? DEFAULT_CAPS.reasoning, - attachment: meta?.attachment ?? DEFAULT_CAPS.attachment, - toolcall: meta?.tool_call ?? DEFAULT_CAPS.tool_call, - input: { text: true, audio: true, image: true, video: true, pdf: true }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: anthropic ? { field: "reasoning_content" as const } : true, - }, - cost: { - input: cost.input, - output: cost.output, - cache: { - read: cost.cache_read, - write: cost.cache_write, - }, - }, - limit: { - context: meta?.limit?.context ?? DEFAULT_LIMIT.context, - output: meta?.limit?.output ?? DEFAULT_LIMIT.output, - }, - status: "active", - options: {}, - headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {}, - release_date: "", + input: entry?.cost?.input ?? DEFAULT_COST.input, + output: entry?.cost?.output ?? DEFAULT_COST.output, + cache_read: entry?.cost?.cache_read ?? DEFAULT_COST.cache_read, + cache_write: entry?.cost?.cache_write ?? DEFAULT_COST.cache_write, }; } -export async function fetchModels(): Promise> { - const res = await fetch(HUB_URL); +export async function fetchModelsDevData(): Promise { + try { + const raw = await readFile(CACHE_PATH, "utf-8"); + return JSON.parse(raw) as ModelsDevData; + } catch { + const res = await fetch(MODELS_DEV_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error( + `Failed to fetch models.dev: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } +} + +export async function fetchHubModels(): Promise { + const res = await fetch(HUB_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); if (!res.ok) { throw new Error( `Failed to fetch CoreInfra prices: ${res.status} ${res.statusText}`, ); } - const data: PricesResponse = await res.json(); + return res.json() as Promise; +} - const models: Record = {}; +function checkIsAnthropic(provider: string): boolean { + return provider === "anthropic"; +} - for (const [provider, providerData] of Object.entries(data.providers)) { - for (const [modelId, modelData] of Object.entries(providerData.models)) { - models[modelId] = buildModel(modelId, provider, modelData.display_name); +export function buildConfigModels( + modelsDevData: ModelsDevData, + hubData: HubResponse, +): { models: Record; warnings: string[] } { + const byFullId = buildLookupMap(modelsDevData); + const models: Record = {}; + const warnings: string[] = []; + + for (const [provider, providerData] of Object.entries(hubData.providers)) { + if (!providerData?.models) continue; + + const anthropic = checkIsAnthropic(provider); + + for (const [modelId, hubModel] of Object.entries(providerData.models)) { + const entry = resolveEntry(provider, modelId, byFullId); + + if (!entry) { + warnings.push( + `${provider}/${modelId} not found in models.dev — using defaults`, + ); + } + + models[modelId] = { + id: modelId, + name: entry?.name ?? hubModel.display_name, + provider: { + api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE, + npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai", + }, + attachment: entry?.attachment ?? DEFAULT_CAPS.attachment, + reasoning: entry?.reasoning ?? DEFAULT_CAPS.reasoning, + temperature: entry?.temperature ?? DEFAULT_CAPS.temperature, + tool_call: entry?.tool_call ?? DEFAULT_CAPS.tool_call, + modalities: { + input: entry?.modalities?.input ?? [ + "text", + "image", + "audio", + "video", + "pdf", + ], + output: entry?.modalities?.output ?? ["text"], + }, + cost: extractCost(entry), + limit: { + context: entry?.limit?.context ?? DEFAULT_LIMIT.context, + output: entry?.limit?.output ?? DEFAULT_LIMIT.output, + }, + interleaved: anthropic ? { field: "reasoning_content" } : true, + headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {}, + }; } } - return models; + return { models, warnings }; } diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..e4a582b --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Hooks, PluginInput } from "@opencode-ai/plugin"; + +import mod from "../src/index.ts"; + +const plugin = mod.server; + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), +})); + +function mockInput(): PluginInput { + const log = vi.fn().mockResolvedValue(undefined); + return { + client: { + app: { log }, + }, + project: {} as PluginInput["project"], + directory: "/tmp", + worktree: "/tmp", + serverUrl: new URL("http://localhost:3000"), + $: {} as PluginInput["$"], + }; +} + +const MODELS_DEV_DATA = { + openai: { + models: { + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + limit: { context: 400000, output: 128000 }, + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "audio"], + output: ["text"], + }, + cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 }, + }, + }, + }, + anthropic: { + models: { + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + limit: { context: 200000, output: 64000 }, + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "pdf"], + output: ["text"], + }, + cost: { + input: 3, + output: 15, + cache_read: 0.3, + cache_write: 3.75, + }, + }, + }, + }, +}; + +function hubResponse() { + return { + providers: { + openai: { + models: { + "gpt-5.4-nano": { display_name: "GPT-5.4 Nano" }, + }, + }, + anthropic: { + models: { + "claude-sonnet-4-20250514": { display_name: "Claude Sonnet 4" }, + }, + }, + }, + }; +} + +async function setupFetchMocks(opts?: { + modelsDevData?: object; + hubData?: object; + fetchError?: Error; +}) { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify(opts?.modelsDevData ?? MODELS_DEV_DATA), + ); + + if (opts?.fetchError) { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(opts.fetchError)); + } else { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + const data = url.includes("models.dev") + ? (opts?.modelsDevData ?? MODELS_DEV_DATA) + : (opts?.hubData ?? hubResponse()); + return Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue(data), + }); + }), + ); + } +} + +describe("config hook", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("populates config with full capabilities from models.dev", async () => { + await setupFetchMocks(); + + const hooks: Hooks = await plugin(mockInput()); + const config = { provider: {} } as Parameters< + NonNullable + >[0]; + + await hooks.config?.(config); + + expect(config.provider?.coreinfra?.name).toBe("CoreInfra AI Hub"); + const models = config.provider?.coreinfra?.models ?? {}; + + expect(models["gpt-5.4-nano"]).toEqual({ + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + provider: { + api: "https://hub.coreinfra.ai/codex/api/v1", + npm: "@ai-sdk/openai", + }, + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 }, + limit: { context: 400000, output: 128000 }, + interleaved: true, + headers: {}, + }); + + expect(models["claude-sonnet-4-20250514"]).toEqual({ + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + provider: { + api: "https://hub.coreinfra.ai/claude/api/v1", + npm: "@ai-sdk/anthropic", + }, + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + limit: { context: 200000, output: 64000 }, + interleaved: { field: "reasoning_content" }, + headers: { + "anthropic-beta": + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + }, + }); + }); + + it("uses defaults when models.dev has no matching model", async () => { + await setupFetchMocks({ modelsDevData: {} }); + + const hooks: Hooks = await plugin(mockInput()); + const config = { provider: {} } as Parameters< + NonNullable + >[0]; + + await hooks.config?.(config); + + const models = config.provider?.coreinfra?.models ?? {}; + expect(models["gpt-5.4-nano"]).toEqual( + expect.objectContaining({ + reasoning: true, + cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, + limit: { context: 200000, output: 64000 }, + }), + ); + }); + + it("leaves models empty on fetchModels failure", async () => { + await setupFetchMocks({ fetchError: new Error("network down") }); + + const hooks: Hooks = await plugin(mockInput()); + const config = { provider: {} } as Parameters< + NonNullable + >[0]; + + await hooks.config?.(config); + + expect(config.provider?.coreinfra?.name).toBe("CoreInfra AI Hub"); + expect(config.provider?.coreinfra?.models).toBeUndefined(); + }); +}); + +describe("auth hook", () => { + it("returns apiKey from auth loader", async () => { + await setupFetchMocks({ hubData: { providers: {} } }); + const hooks: Hooks = await plugin(mockInput()); + const getAuth = vi.fn().mockResolvedValue({ type: "api", key: "sk-test" }); + const providerArg = {} as Parameters< + NonNullable["loader"] + >[1]; + const result = await hooks.auth?.loader?.(getAuth, providerArg); + expect(result).toEqual({ apiKey: "sk-test" }); + }); + + it("returns empty object when no auth", async () => { + await setupFetchMocks({ hubData: { providers: {} } }); + const hooks: Hooks = await plugin(mockInput()); + const getAuth = vi.fn().mockResolvedValue(null); + const providerArg = {} as Parameters< + NonNullable["loader"] + >[1]; + const result = await hooks.auth?.loader?.(getAuth, providerArg); + expect(result).toEqual({}); + }); +}); diff --git a/test/models.test.ts b/test/models.test.ts index 99a5ca9..a585771 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -1,79 +1,348 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { fetchModels } from "../src/models.ts"; +import { + buildConfigModels, + fetchModelsDevData, + fetchHubModels, +} from "../src/models.ts"; -describe("fetchModels", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), +})); - it("maps the hub response into opencode models", async () => { - const json = vi.fn().mockResolvedValue({ - providers: { - openai: { - models: { - "gpt-5.4-nano": { - display_name: "GPT-5.4 Nano", - }, - }, +const MODELS_DEV_FIXTURE = { + openai: { + models: { + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + limit: { context: 400000, output: 128000 }, + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "audio", "video", "pdf"], + output: ["text"], }, - anthropic: { - models: { - "claude-sonnet-4-20250514": { - display_name: "Claude Sonnet 4", - }, - }, + cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 }, + }, + }, + }, + anthropic: { + models: { + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + limit: { context: 200000, output: 64000 }, + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "pdf"], + output: ["text"], + }, + cost: { + input: 3, + output: 15, + cache_read: 0.3, + cache_write: 3.75, }, }, - }); - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json, - }); - - vi.stubGlobal("fetch", fetchMock); + }, + }, +}; - const models = await fetchModels(); +const HUB_FIXTURE = { + providers: { + openai: { + models: { + "gpt-5.4-nano": { display_name: "GPT-5.4 Nano" }, + }, + }, + anthropic: { + models: { + "claude-sonnet-4-20250514": { display_name: "Claude Sonnet 4" }, + }, + }, + }, +}; - expect(fetchMock).toHaveBeenCalledWith( - "https://hub.coreinfra.ai/hub/api/prices", +describe("buildConfigModels", () => { + it("intersects hub data with models.dev data", () => { + const { models, warnings } = buildConfigModels( + MODELS_DEV_FIXTURE, + HUB_FIXTURE, ); - expect(models["gpt-5.4-nano"]).toMatchObject({ + expect(warnings).toEqual([]); + expect(Object.keys(models)).toHaveLength(2); + + const gpt = models["gpt-5.4-nano"]; + expect(gpt).toEqual({ id: "gpt-5.4-nano", - providerID: "coreinfra", - api: { - id: "gpt-5.4-nano", - url: "https://hub.coreinfra.ai/codex/api/v1", + name: "GPT-5.4 Nano", + provider: { + api: "https://hub.coreinfra.ai/codex/api/v1", npm: "@ai-sdk/openai", }, - name: "GPT-5.4 Nano", - capabilities: { - interleaved: true, + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "audio", "video", "pdf"], + output: ["text"], }, - status: "active", + cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 }, + limit: { context: 400000, output: 128000 }, + interleaved: true, + headers: {}, }); - expect(models["claude-sonnet-4-20250514"]).toMatchObject({ + const claude = models["claude-sonnet-4-20250514"]; + expect(claude).toEqual({ id: "claude-sonnet-4-20250514", - providerID: "coreinfra", - api: { - id: "claude-sonnet-4-20250514", - url: "https://hub.coreinfra.ai/claude/api/v1", + name: "Claude Sonnet 4", + provider: { + api: "https://hub.coreinfra.ai/claude/api/v1", npm: "@ai-sdk/anthropic", }, - name: "Claude Sonnet 4", - capabilities: { - interleaved: { - field: "reasoning_content", - }, + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "pdf"], + output: ["text"], }, + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + limit: { context: 200000, output: 64000 }, + interleaved: { field: "reasoning_content" }, headers: { "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", }, - status: "active", }); }); + + it("uses defaults and emits warning when model not in models.dev", () => { + const hubData = { + providers: { + openai: { + models: { + "unknown-model": { display_name: "Unknown Model" }, + }, + }, + }, + }; + + const { models, warnings } = buildConfigModels({}, hubData); + + expect(warnings).toEqual([ + "openai/unknown-model not found in models.dev — using defaults", + ]); + expect(models["unknown-model"]).toEqual({ + id: "unknown-model", + name: "Unknown Model", + provider: { + api: "https://hub.coreinfra.ai/codex/api/v1", + npm: "@ai-sdk/openai", + }, + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + modalities: { + input: ["text", "image", "audio", "video", "pdf"], + output: ["text"], + }, + cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, + limit: { context: 200000, output: 64000 }, + interleaved: true, + headers: {}, + }); + }); + + it("resolves via full provider/modelId key", () => { + const modelsDevData = { + openai: { + models: { + "shared-id": { + id: "shared-id", + name: "OpenAI Shared", + limit: { context: 100000, output: 32000 }, + tool_call: true, + cost: { input: 1, output: 2 }, + }, + }, + }, + }; + + const hubData = { + providers: { + openai: { + models: { + "shared-id": { display_name: "Hub Display Name" }, + }, + }, + }, + }; + + const { models } = buildConfigModels(modelsDevData, hubData); + + expect(models["shared-id"].name).toBe("OpenAI Shared"); + expect(models["shared-id"].cost.input).toBe(1); + expect(models["shared-id"].limit.context).toBe(100000); + }); + + it("does not resolve from non-openai/anthropic providers in models.dev", () => { + const modelsDevData = { + "provider-a": { + models: { + "some-model": { + id: "some-model", + name: "Should Not Match", + cost: { input: 99 }, + }, + }, + }, + }; + + const hubData = { + providers: { + "provider-a": { + models: { + "some-model": { display_name: "Hub Model" }, + }, + }, + }, + }; + + const { models, warnings } = buildConfigModels(modelsDevData, hubData); + + expect(warnings).toEqual([ + "provider-a/some-model not found in models.dev — using defaults", + ]); + expect(models["some-model"].name).toBe("Hub Model"); + expect(models["some-model"].cost.input).toBe(0); + }); + + it("uses hub display_name as fallback when models.dev entry has no name", () => { + const modelsDevData = { + openai: { + models: { + "no-name-model": { + limit: { context: 100000 }, + cost: { input: 1 }, + }, + }, + }, + }; + + const hubData = { + providers: { + openai: { + models: { + "no-name-model": { display_name: "Hub Display Name" }, + }, + }, + }, + }; + + const { models } = buildConfigModels(modelsDevData, hubData); + expect(models["no-name-model"].name).toBe("Hub Display Name"); + }); + + it("returns empty models for empty hub data", () => { + const { models, warnings } = buildConfigModels(MODELS_DEV_FIXTURE, { + providers: {}, + }); + + expect(models).toEqual({}); + expect(warnings).toEqual([]); + }); +}); + +describe("fetchModelsDevData", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reads from cache file when available", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ openai: { models: {} } }), + ); + + const data = await fetchModelsDevData(); + expect(data).toEqual({ openai: { models: {} } }); + }); + + it("falls back to network fetch when cache missing", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockRejectedValue(new Error("ENOENT")); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ openai: { models: {} } }), + }), + ); + + const data = await fetchModelsDevData(); + expect(data).toEqual({ openai: { models: {} } }); + }); + + it("throws on non-ok network response", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockRejectedValue(new Error("ENOENT")); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }), + ); + + await expect(fetchModelsDevData()).rejects.toThrow( + "Failed to fetch models.dev: 503 Service Unavailable", + ); + }); +}); + +describe("fetchHubModels", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fetches hub prices", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(HUB_FIXTURE), + }), + ); + + const data = await fetchHubModels(); + expect(data).toEqual(HUB_FIXTURE); + }); + + it("throws on non-ok response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }), + ); + + await expect(fetchHubModels()).rejects.toThrow( + "Failed to fetch CoreInfra prices: 500 Internal Server Error", + ); + }); }); From c4975b7825537c4134299e190af08c0758108a90 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Thu, 14 May 2026 16:59:19 +0300 Subject: [PATCH 7/7] docs: document COREINFRA_HUB_BASE_URL env var and models.dev integration --- README.md | 10 +++++++++- README.ru.md | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 535fd18..d1adbe6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ During authentication, you will need a [CoreInfra AI Hub](https://hub.coreinfra. ## Features -- **Up-to-date model list** - the catalog is loaded dynamically from the Hub API on every startup and always reflects its current state. +- **Up-to-date model list** - the catalog is loaded dynamically from the Hub API on every startup and always reflects its current state. Model capabilities (context limits, reasoning, tool use, etc.) are resolved from the models.dev catalog. - **OpenAI and Anthropic models** - both model families are supported, including GPT-5.x and Claude 4.x. - **Reasoning support** - `interleaved thinking` mode is enabled automatically for Anthropic models. @@ -49,6 +49,14 @@ opencode models coreinfra The full model list is determined by the Hub contents at startup time. The plugin supports all models listed on this page: https://hub.coreinfra.ai/pricing +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `COREINFRA_HUB_BASE_URL` | `https://hub.coreinfra.ai` | Base URL of the CoreInfra Hub instance. Overrides the default endpoint for model listings and API proxying. | + ## Development Code formatting: diff --git a/README.ru.md b/README.ru.md index 4c9bfdb..5cf8735 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@ opencode providers login --provider coreinfra ## Возможности -- **Актуальный список моделей** — каталог динамически загружается из API Hub при каждом запуске и всегда отражает его текущее состояние. +- **Актуальный список моделей** — каталог динамически загружается из API Hub при каждом запуске и всегда отражает его текущее состояние. Возможности моделей (контекст, reasoning, tool use и т.д.) определяются из каталога models.dev. - **Модели OpenAI и Anthropic** — поддерживаются обе линейки, включая GPT-5.x и Claude 4.x. - **Поддержка reasoning** — для моделей Anthropic автоматически включается режим `interleaved thinking`. @@ -49,6 +49,14 @@ opencode models coreinfra Полный список моделей определяется содержимым Hub на момент запуска. Плагин поддерживает все модели, перечисленные на странице: https://hub.coreinfra.ai/pricing +## Конфигурация + +### Переменные окружения + +| Переменная | По умолчанию | Описание | +|---|---|---| +| `COREINFRA_HUB_BASE_URL` | `https://hub.coreinfra.ai` | Базовый URL экземпляра CoreInfra Hub. Переопределяет стандартный эндпоинт для получения списка моделей и проксирования API. | + ## Разработка Форматирование кода: