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 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/.gitignore b/.gitignore index c2658d7..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ 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 6832c46..5cf8735 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-токена @@ -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. | + ## Разработка Форматирование кода: 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/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 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 e024d37..ebb8012 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,46 +1,79 @@ 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"; -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, }; } -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 { 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", @@ -53,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", + ); + }); });