diff --git a/README.md b/README.md index cd60840..e314621 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,80 @@ # commandcode-go-opencode-provider -[Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Step, and other models through a single API key. +[![Version](https://img.shields.io/badge/release-v0.5.0-blue.svg)](https://github.com/danielxxomg/opencode-commandcode-provider/releases/tag/v0.5.0) +[![Tests](https://img.shields.io/badge/tests-106%20passed-brightgreen.svg)]() +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]() +[![OpenCode](https://img.shields.io/badge/OpenCode-v1%20%26%20v2-orange.svg)]() +[![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) -## Quick Start +[Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Access Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and 67+ models through a single API key with real-time model discovery and robust error recovery. -### 1. Install +> **Maintained Fork**: This repository is the actively maintained fork of [`brent-weatherall/opencode-commandcode-provider`](https://github.com/brent-weatherall/opencode-commandcode-provider), adding OpenCode 2 support, runtime auto-discovery, Plan Mode context gathering, and production-grade retry resilience. -```bash -opencode plugin commandcode-go-opencode-provider +``` +┌─────────────────────────────────────────────────────────────┐ +│ OpenCode Client (v1 / v2) │ +└──────────────┬──────────────────────────────┬───────────────┘ + │ (1) Startup Discovery │ (2) Streaming Request + ▼ ▼ +┌──────────────────────────────┐ ┌──────────────────────────┐ +│ Auto-Discovery Engine │ │ Execution Layer │ +│ - GET /provider/v1/models │ │ - Project tree & Git ctx│ +│ - Bundled 67-model fallback │ │ - Retry with jitter │ +│ - Zero credentials needed │ │ - SSE error propagation │ +└──────────────┬───────────────┘ └──────────┬───────────────┘ + │ │ + └──────────────┬───────────────┘ + ▼ + ┌──────────────────────────────┐ + │ Command Code Cloud API │ + └──────────────────────────────┘ ``` -This installs the provider and registers all available models automatically. +## Feature Comparison + +| Feature | Upstream (Abandoned) | danielxxomg Fork (v0.5.0) | +|---|---|---| +| **Model Count** | 21 static models | **67 verified models** | +| **Model Discovery** | Manual compile | **Real-time API auto-discovery** at startup | +| **OpenCode 2 Support** | ❌ Broken (`SchemaError`) | ✅ **Native `./v2` entrypoint** | +| **Plan Mode Context** | ❌ Empty stubs | ✅ **File tree + Git context** with 30s cache | +| **Retry & Backoff** | ❌ Naive exponential | ✅ **Fixed schedule with jitter & classification** | +| **Stream Termination** | ❌ Silent SSE errors | ✅ **Clean `controller.error()` termination** | +| **Reasoning Effort** | ❌ Unsupported | ✅ **Configurable thinking levels (`low` to `max`)** | +| **Test Suite** | 43 tests | **106 unit tests (100% passing)** | + +## What this fork adds (v0.5.0) + +- **Automatic Model Discovery**: fetches current model roster and context limits from Command Code's API at startup with zero-latency fallback to the bundled 67-model offline catalog. +- **OpenCode 2 (V2) Support**: native `./v2` entrypoint (`plugins: ["commandcode-go-opencode-provider/v2"]`) with automatic provider catalog transformation. +- **Project & Git Context**: gathers project file tree (respecting `.gitignore`) and Git metadata (branch, status, recent commits) with in-memory caching to empower Command Code's Plan Mode. +- **Reasoning Effort / Thinking Configuration**: support for custom thinking levels (`low`, `medium`, `high`, `max`) on reasoning models. +- **Production-grade Retry with Jitter**: fixed backoff schedule `[1s, 2.5s, 5s]` with ±25% jitter replaces unbounded exponential growth. +- **Error Classification**: 18 non-retryable patterns (auth/quota/validation) and 24 retryable patterns (network/server/timeout). +- **Mid-stream Reconnect & SSE Error Propagation**: `emittedContent` tracking prevents duplicates; SSE error events terminate streams via `controller.error()`. -### 2. Connect +## Quick Start -Run `/connect` in opencode, search for **Command Code**, and enter your API key: +### 1. Install from this fork -``` -/connect +```bash +git clone https://github.com/danielxxomg/opencode-commandcode-provider.git +cd opencode-commandcode-provider +bun install ``` -### 3. Select a model +### 2. Register in OpenCode -Run `/models` to pick from available models: - -``` -/models +#### For OpenCode 2 (V2): +Add to your `opencode.json`: +```json +{ + "plugins": ["commandcode-go-opencode-provider/v2"] +} ``` -## Manual Configuration - -If you prefer to configure manually, add this to your `opencode.json`: - +#### For OpenCode 1 (V1): +Add to your `~/.config/opencode/opencode.json`: ```json { "plugin": ["commandcode-go-opencode-provider/server"], @@ -41,74 +84,128 @@ If you prefer to configure manually, add this to your `opencode.json`: "name": "Command Code", "env": ["COMMANDCODE_API_KEY"] } - }, - "model": "commandcode/deepseek-v4-flash" + } } ``` -The plugin auto-registers models from [`models.json`](./models.json) at startup. You only need the `provider.commandcode` block — no need to list individual models. +Or use the local file path if you don't want to publish to npm: -### Environment Variable +```json +{ + "plugin": ["file:///path/to/opencode-commandcode-provider/plugin.ts"], + "provider": { + "commandcode": { + "npm": "file:///path/to/opencode-commandcode-provider", + "name": "Command Code (local)", + "env": ["COMMANDCODE_API_KEY"] + } + } +} +``` + +### 3. Connect + +Run `/connect` in opencode, search for **Command Code**, and enter your API key: -Set `COMMANDCODE_API_KEY` instead of using `/connect`: +``` +/connect +``` + +Or set the environment variable: ```bash COMMANDCODE_API_KEY=your-key opencode ``` +### 4. Select a model + +``` +/models +``` + ## Available Models | Model ID | Name | Tier | Reasoning | Context | |---|---|---|---|---| -| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | -| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `xiaomi/mimo-v2.5-pro` | Xiaomi Mimo V2.5 Pro | open-source | yes | 1M | +| `xiaomi/mimo-v2.5` | Xiaomi Mimo V2.5 | open-source | yes | 1M | +| `claude-sonnet-5` | Claude Sonnet 5 | premium | yes | 1M | | `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | -| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | +| `claude-fable-5` | Claude Fable 5 | premium | yes | 1M | +| `claude-opus-5` | Claude Opus 5 | premium | yes | 1M | +| `claude-opus-4-8` | Claude Opus 4.8 | premium | yes | 1M | +| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | +| `gpt-5.6-sol` | GPT-5.6 Sol | premium | yes | 1M | +| `gpt-5.6-terra` | GPT-5.6 Terra | premium | yes | 1M | +| `gpt-5.6-luna` | GPT-5.6 Luna | premium | yes | 1M | +| `gpt-5.5` | GPT-5.5 | premium | yes | 400K | | `gpt-5.4` | GPT-5.4 | premium | yes | 400K | +| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | | `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | -| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | -| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | | `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | -| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | -| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | -| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | +| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | +| `moonshotai/Kimi-K3` | Kimi K3 | open-source | yes | 1M | +| `moonshotai/Kimi-K2.7-Code` | Kimi K2.7 Code | open-source | yes | 500K | +| `moonshotai/Kimi-K2.7-Code-Highspeed` | Kimi K2.7 Code Highspeed | open-source | yes | 500K | +| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 262K | +| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 262K | +| `zai-org/GLM-5.2` | GLM-5.2 | open-source | yes | 1M | +| `zai-org/GLM-5.2-Fast` | GLM-5.2 Fast | open-source | yes | 1M | | `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | -| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | -| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | -| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | +| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | +| `MiniMaxAI/MiniMax-M3-Free` | MiniMax M3 (Free) | open-source | yes | 1M | +| `MiniMaxAI/MiniMax-M3` | MiniMax M3 | open-source | yes | 1M | | `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | +| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 1M | | `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | | `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | | `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | +| `Qwen/Qwen3.7-Plus` | Qwen 3.7 Plus | open-source | yes | 1M | +| `stepfun/Step-3.7-Flash` | Step 3.7 Flash | open-source | yes | 1M | | `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `tencent/hy3-paid` | Tencent Hy3 Paid | open-source | yes | 1M | +| `tencent/Hy3` | Tencent Hy3 | open-source | yes | 1M | +| `google/gemini-3.6-flash` | Gemini 3.6 Flash | open-source | yes | 1M | +| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | +| `google/gemini-3.5-flash-lite` | Gemini 3.5 Flash Lite | open-source | yes | 1M | +| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | +| `thinkingmachines/inkling` | Inkling | open-source | yes | 1M | +| `sakana/fugu-ultra` | Sakana Fugu Ultra | open-source | yes | 1M | +| `xai/grok-4.5` | xAI Grok 4.5 | open-source | yes | 1M | +| `meta/muse-spark-1.1` | Meta Muse Spark 1.1 | open-source | yes | 1M | +| `nvidia/nemotron-3-ultra-550b-a55b` | Nvidia Nemotron 3 Ultra | open-source | yes | 1M | +| `poolside/laguna-s-2.1-free` | Poolside Laguna S 2.1 (Free) | open-source | yes | 1M | +| `inclusionai/ling-3.0-flash-free` | InclusionAI Ling 3.0 Flash (Free) | open-source | yes | 1M | +| `claude-fable-5-1` | Claude Fable 5.1 | premium | no | 1M | +| `deepseek/deepseek-v4-flash-vision-exp` | DeepSeek V4 Flash Vision (exp) | open-source | no | 1M | +| `deepseek/deepseek-v4-flash-fast` | DeepSeek V4 Flash Fast | open-source | no | 1M | +| `z-ai/glm-5.3-flash` | GLM-5.3 Flash | open-source | yes | 1M | +| `zai-org/GLM-5.3` | GLM-5.3 | open-source | yes | 1M | +| `Qwen/Qwen3.8-Max-0902` | Qwen 3.8 Max 0902 | open-source | no | 1M | +| `Qwen/Qwen3.8-Max` | Qwen 3.8 Max | open-source | no | 1M | +| `Qwen/Qwen3.8-27B` | Qwen 3.8 27B | open-source | no | 262K | +| `Qwen/Qwen3.8-Flash` | Qwen 3.8 Flash | open-source | no | 1M | +| `Qwen/Qwen3.7-Flash` | Qwen 3.7 Flash | open-source | no | 1M | +| `meituan/LongCat-2.0:free` | LongCat 2.0 | open-source | no | 1M | +| `tencent/hy4-preview` | Tencent Hy4 Preview | open-source | yes | 1M | +| `google/gemini-3.7-flash` | Gemini 3.7 Flash | open-source | no | 1M | +| `thinkingmachines/inkling-small` | Inkling Small | open-source | yes | 1M | +| `meta/muse-spark-1.2` | Muse Spark 1.2 | open-source | no | 1M | +| `meta/muse-spark-1.2-contributor` | Muse Spark 1.2 Contributor | open-source | no | 1M | +| `xai/grok-4.6` | Grok 4.6 | open-source | no | 500K | Full model list is maintained in [`models.json`](./models.json). Run `bun run sync` to refresh from the latest Command Code CLI release on npm. ## Development ```bash -git clone https://github.com/brent-weatherall/commandcode-go-opencode-provider.git -cd commandcode-go-opencode-provider +git clone https://github.com/danielxxomg/opencode-commandcode-provider.git +cd opencode-commandcode-provider bun install +bun test ``` -For local testing, create `opencode.local.json` (gitignored) with `file://` paths: - -```json -{ - "plugin": ["file:///path/to/commandcode-go-opencode-provider/server"], - "provider": { - "commandcode": { - "npm": "file:///path/to/commandcode-go-opencode-provider", - "name": "Command Code (local)", - "env": ["COMMANDCODE_API_KEY"] - } - } -} -``` - -Run `opencode --config opencode.local.json` to test with your local build. - ### Sync Models ```bash diff --git a/models.json b/models.json index db50960..9018e35 100644 --- a/models.json +++ b/models.json @@ -1,270 +1,2764 @@ [ + { + "id": "xiaomi/mimo-v2.5-pro", + "name": "Xiaomi Mimo V2.5 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "xiaomi/mimo-v2.5", + "name": "Xiaomi Mimo V2.5", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.1, + "output": 0.3, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, + { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-opus-5", + "name": "Claude Opus 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, + { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "tier": "premium", "reasoning": false, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.8, + "output": 4, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + } + }, + { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1.5, + "output": 6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1050000, + "output": 64000 + } + }, + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.4-mini", + "name": "GPT-5.4 Mini", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.15, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 64000 + } + }, + { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.435, + "output": 0.87, + "cache": { + "read": 0.003625, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.14, + "output": 0.28, + "cache": { + "read": 0.01, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "moonshotai/Kimi-K3", + "name": "Kimi K3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.6, + "output": 2.4, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 500000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.7-Code-Highspeed", + "name": "Kimi K2.7 Code Highspeed", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 500000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.4, + "output": 1.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.3, + "output": 1.2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.2", + "name": "GLM-5.2", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1, + "output": 3, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.2-Fast", + "name": "GLM-5.2 Fast", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 1.5, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.1", + "name": "GLM-5.1", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 1.4, + "output": 4.4, + "cache": { + "read": 0.26, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5", + "name": "GLM-5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 1, + "output": 3, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M3-Free", + "name": "MiniMax M3 (Free)", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax M2.7", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.3, + "output": 1.2, + "cache": { + "read": 0.06, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M2.5", + "name": "MiniMax M2.5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.2, + "output": 0.8, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.6-Max-Preview", + "name": "Qwen 3.6 Max Preview", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1.3, + "output": 7.8, + "cache": { + "read": 0.26, + "write": 1.63 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.6-Plus", + "name": "Qwen 3.6 Plus", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 3, + "cache": { + "read": 0.1, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.7-Max", + "name": "Qwen 3.7 Max", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1.25, + "output": 3.75, + "cache": { + "read": 0.25, + "write": 1.56 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.4, + "output": 1.2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.15, + "output": 0.45, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "stepfun/Step-3.5-Flash", + "name": "Step 3.5 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.1, + "output": 0.3, + "cache": { + "read": 0.02, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "tencent/hy3-paid", + "name": "Tencent Hy3 Paid", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.3, + "output": 0.9, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "tencent/Hy3", + "name": "Tencent Hy3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "google/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25 + "input": 1.5, + "output": 9, + "cache": { + "read": 0.15, + "write": 0 + } }, "limit": { - "context": 200000, - "output": 8192 + "context": 1000000, + "output": 65536 } }, { - "id": "claude-opus-4-7", - "name": "Claude Opus 4.7", - "tier": "premium", + "id": "google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 1.5, + "output": 9, + "cache": { + "read": 0.15, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 32000 + "output": 65536 } }, { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "tier": "premium", + "id": "google/gemini-3.5-flash-lite", + "name": "Gemini 3.5 Flash Lite", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 0.25, + "output": 1.5, + "cache": { + "read": 0.03, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 16000 + "output": 65536 } }, { - "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", - "tier": "premium", + "id": "google/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 + "input": 0.25, + "output": 1.5, + "cache": { + "read": 0.03, + "write": 0 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 65536 } }, { - "id": "gpt-5.4", - "name": "GPT-5.4", - "tier": "premium", + "id": "thinkingmachines/inkling", + "name": "Inkling", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25 + "input": 0.2, + "output": 0.8, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.4-mini", - "name": "GPT-5.4 Mini", - "tier": "premium", + "id": "sakana/fugu-ultra", + "name": "Sakana Fugu Ultra", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.75, - "output": 4.5, - "cache_read": 0.075 + "input": 1, + "output": 3, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.5", - "name": "GPT-5.5", - "tier": "premium", + "id": "xai/grok-4.5", + "name": "xAI Grok 4.5", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 2, + "output": 6, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 256000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "deepseek/deepseek-v4-flash", - "name": "DeepSeek V4 Flash", + "id": "meta/muse-spark-1.1", + "name": "Meta Muse Spark 1.1", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.01 + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 1000000, - "output": 384000 + "context": 1048576, + "output": 131072 } }, { - "id": "deepseek/deepseek-v4-pro", - "name": "DeepSeek V4 Pro", + "id": "nvidia/nemotron-3-ultra-550b-a55b", + "name": "Nvidia Nemotron 3 Ultra", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 + "input": 0.5, + "output": 1.5, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 384000 + "output": 131072 } }, { - "id": "google/gemini-3.1-flash-lite", - "name": "Gemini 3.1 Flash Lite", + "id": "poolside/laguna-s-2.1-free", + "name": "Poolside Laguna S 2.1 (Free)", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.25, - "output": 1.5, - "cache_read": 0.03 + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 65536 + "output": 131072 } }, { - "id": "google/gemini-3.5-flash", - "name": "Gemini 3.5 Flash", + "id": "inclusionai/ling-3.0-flash-free", + "name": "InclusionAI Ling 3.0 Flash (Free)", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 1.5, - "output": 9, - "cache_read": 0.15 + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 65536 + "output": 131072 } }, { - "id": "zai-org/GLM-5", - "name": "GLM-5", + "id": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "tier": "premium", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": true, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "deepseek/deepseek-v4-flash-vision-exp", + "name": "DeepSeek V4 Flash Vision (exp)", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": true, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.95, - "output": 3.15 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 200000, + "context": 1000000, "output": 131072 } }, { - "id": "zai-org/GLM-5.1", - "name": "GLM-5.1", + "id": "deepseek/deepseek-v4-flash-fast", + "name": "DeepSeek V4 Flash Fast", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 200000, + "context": 1000000, "output": 131072 } }, { - "id": "moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.3", + "name": "GLM-5.3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.8-Max-0902", + "name": "Qwen 3.8 Max 0902", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.6, - "output": 3 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 256000, + "context": 1000000, "output": 131072 } }, { - "id": "moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", + "id": "Qwen/Qwen3.8-Max", + "name": "Qwen 3.8 Max", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.95, - "output": 4, - "cache_read": 0.16 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 256000, + "context": 1000000, "output": 131072 } }, { - "id": "MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", + "id": "Qwen/Qwen3.8-27B", + "name": "Qwen 3.8 27B", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 200000, + "context": 262144, "output": 131072 } }, { - "id": "MiniMaxAI/MiniMax-M2.7", - "name": "MiniMax M2.7", + "id": "Qwen/Qwen3.8-Flash", + "name": "Qwen 3.8 Flash", "tier": "open-source", "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { "context": 1000000, @@ -272,16 +2766,22 @@ } }, { - "id": "Qwen/Qwen3.6-Max-Preview", - "name": "Qwen 3.6 Max Preview", + "id": "Qwen/Qwen3.7-Flash", + "name": "Qwen 3.7 Flash", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 1.3, - "output": 7.8, - "cache_read": 0.26, - "cache_write": 1.63 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { "context": 1000000, @@ -289,52 +2789,164 @@ } }, { - "id": "Qwen/Qwen3.6-Plus", - "name": "Qwen 3.6 Plus", + "id": "meituan/LongCat-2.0:free", + "name": "LongCat 2.0", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, "cost": { - "input": 0.5, - "output": 3, - "cache_read": 0.1 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 1000000, + "context": 1048576, "output": 131072 } }, { - "id": "Qwen/Qwen3.7-Max", - "name": "Qwen 3.7 Max", + "id": "tencent/hy4-preview", + "name": "Tencent Hy4 Preview", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, "cost": { - "input": 1.25, - "output": 3.75, - "cache_read": 0.25, - "cache_write": 1.56 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { - "context": 1000000, + "context": 1048576, "output": 131072 } }, { - "id": "stepfun/Step-3.5-Flash", - "name": "Step 3.5 Flash", + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": true, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "thinkingmachines/inkling-small", + "name": "Inkling Small", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 }, "limit": { "context": 1000000, "output": 131072 } + }, + { + "id": "meta/muse-spark-1.2", + "name": "Muse Spark 1.2", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "meta/muse-spark-1.2-contributor", + "name": "Muse Spark 1.2 Contributor", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "xai/grok-4.6", + "name": "Grok 4.6", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 500000, + "output": 131072 + } } -] +] \ No newline at end of file diff --git a/package.json b/package.json index a96ec87..76deceb 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "commandcode-go-opencode-provider", - "version": "0.4.0", - "author": "Brent Weatherall", + "version": "0.5.0", + "author": "Brent Weatherall, danielxxomg", "repository": { "type": "git", - "url": "git+https://github.com/brent-weatherall/opencode-commandcode-provider.git" + "url": "git+https://github.com/danielxxomg/opencode-commandcode-provider.git" }, "main": "index.ts", "devDependencies": { @@ -18,15 +18,18 @@ }, "./server": { "import": "./plugin.ts" + }, + "./v2": { + "import": "./v2.ts" } }, "bugs": { - "url": "https://github.com/brent-weatherall/opencode-commandcode-provider/issues" + "url": "https://github.com/danielxxomg/opencode-commandcode-provider/issues" }, - "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code", + "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code with auto-discovery and OpenCode 2 support", "engines": { "bun": ">=1.0.0" }, - "files": ["index.ts", "plugin.ts", "models.json", "src/"], - "homepage": "https://github.com/brent-weatherall/opencode-commandcode-provider#readme", + "files": ["index.ts", "plugin.ts", "v2.ts", "models.json", "src/"], + "homepage": "https://github.com/danielxxomg/opencode-commandcode-provider#readme", "keywords": [ "opencode", "opencode-provider", diff --git a/plugin.ts b/plugin.ts index 655e127..1ffe561 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,38 +1,22 @@ -import { readFileSync } from "fs" -import { join, dirname } from "path" -import { fileURLToPath } from "url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -interface ModelEntry { - id: string - name: string - tier: "premium" | "open-source" - reasoning: boolean - tool_call: boolean - cost: { input: number; output: number; cache_read?: number; cache_write?: number } - limit: { context: number; output: number } -} - -function loadModels(): ModelEntry[] { - const modelsPath = join(__dirname, "models.json") - return JSON.parse(readFileSync(modelsPath, "utf-8")) -} - -function toConfigKey(id: string): string { - const slashIdx = id.indexOf("/") - const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id - return short.toLowerCase() -} +import { + loadModels, + fetchModelsFromApi, + mergeModels, + toConfigKey, + toV1ModelConfig, + type ModelEntry, +} from "./src/models.js" export default async function commandcodePlugin() { return { config: async (config: Record) => { const providers = config.provider as Record> | undefined if (!providers) { - (config as Record).provider = { commandcode: {} } + ;(config as Record).provider = { commandcode: {} } } - const cc = ((config as Record).provider as Record>)?.commandcode as Record | undefined + const cc = ((config as Record).provider as Record>)?.commandcode as + | Record + | undefined if (!cc) return if (!cc.npm) cc.npm = "commandcode-go-opencode-provider" @@ -40,22 +24,27 @@ export default async function commandcodePlugin() { if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"] if (!cc.models) { - const models = loadModels() + let models = loadModels() + try { + const apiModels = await fetchModelsFromApi() + if (apiModels && apiModels.length > 0) { + models = mergeModels(models, apiModels) + } + } catch { + // Graceful fallback to bundled models + } + const modelsObj: Record = {} for (const entry of models) { - const key = toConfigKey(entry.id) - const costObj: Record = { input: entry.cost.input, output: entry.cost.output } - if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read - if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write + const shortKey = toConfigKey(entry.id) + const fullKey = entry.id.toLowerCase() + const hyphenKey = entry.id.toLowerCase().replace(/\//g, "-") - modelsObj[key] = { - id: entry.id, - name: entry.name, - reasoning: entry.reasoning, - tool_call: entry.tool_call, - cost: costObj, - limit: entry.limit, - } + const modelDef = toV1ModelConfig(entry) + + modelsObj[shortKey] = modelDef + modelsObj[fullKey] = modelDef + modelsObj[hyphenKey] = modelDef } cc.models = modelsObj } diff --git a/scripts/update-all-models.ts b/scripts/update-all-models.ts new file mode 100644 index 0000000..4528f0e --- /dev/null +++ b/scripts/update-all-models.ts @@ -0,0 +1,210 @@ +import { writeFileSync } from "fs" +import { join } from "path" + +export interface ModelVariant { + [key: string]: unknown +} + +export interface ModelCapabilities { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + image: boolean + pdf: boolean + audio: boolean + video: boolean + } + output: { + text: boolean + image: boolean + pdf: boolean + audio: boolean + video: boolean + } + interleaved?: boolean | { field: string } +} + +export interface ModelCost { + input: number + output: number + cache: { + read: number + write: number + } +} + +export interface ModelEntry { + id: string + name: string + tier: "premium" | "open-source" + reasoning: boolean + tool_call: boolean + capabilities: ModelCapabilities + variants?: Record + cost: ModelCost + limit: { context: number; output: number } +} + +const DEFAULT_REASONING_VARIANTS: Record = { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, +} + +interface RawModelConfig { + name: string + tier?: "premium" | "open-source" + reasoning?: boolean + hasVision?: boolean + hasPdf?: boolean + costInput?: number + costOutput?: number + cacheRead?: number + cacheWrite?: number + contextLimit?: number + outputLimit?: number + variants?: Record +} + +const MODEL_CATALOG: Record = { + // Xiaomi Mimo + "xiaomi/mimo-v2.5-pro": { name: "Xiaomi Mimo V2.5 Pro", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + "xiaomi/mimo-v2.5": { name: "Xiaomi Mimo V2.5", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.1, costOutput: 0.3, contextLimit: 1000000, outputLimit: 131072 }, + + // Anthropic + "claude-sonnet-5": { name: "Claude Sonnet 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-sonnet-4-6": { name: "Claude Sonnet 4.6", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-fable-5": { name: "Claude Fable 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-opus-5": { name: "Claude Opus 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-opus-4-8": { name: "Claude Opus 4.8", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-opus-4-7": { name: "Claude Opus 4.7", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-haiku-4-5-20251001": { name: "Claude Haiku 4.5", tier: "premium", reasoning: false, hasVision: true, costInput: 0.8, costOutput: 4.0, contextLimit: 200000, outputLimit: 8192 }, + + // OpenAI + "gpt-5.6-sol": { name: "GPT-5.6 Sol", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 1000000, outputLimit: 128000 }, + "gpt-5.6-terra": { name: "GPT-5.6 Terra", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 6.0, contextLimit: 500000, outputLimit: 128000 }, + "gpt-5.6-luna": { name: "GPT-5.6 Luna", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.5, costOutput: 2.0, contextLimit: 256000, outputLimit: 64000 }, + "gpt-5.5": { name: "GPT-5.5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 256000, outputLimit: 128000 }, + "gpt-5.4": { name: "GPT-5.4", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 256000, outputLimit: 128000 }, + "gpt-5.3-codex": { name: "GPT-5.3 Codex", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 400000, outputLimit: 128000 }, + "gpt-5.4-mini": { name: "GPT-5.4 Mini", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.15, costOutput: 0.6, contextLimit: 400000, outputLimit: 64000 }, + + // DeepSeek + "deepseek/deepseek-v4-pro": { name: "DeepSeek V4 Pro", tier: "open-source", reasoning: true, costInput: 0.435, costOutput: 0.87, cacheRead: 0.003625, contextLimit: 1000000, outputLimit: 384000 }, + "deepseek/deepseek-v4-flash": { name: "DeepSeek V4 Flash", tier: "open-source", reasoning: true, costInput: 0.14, costOutput: 0.28, cacheRead: 0.01, contextLimit: 1000000, outputLimit: 384000 }, + + // Moonshot Kimi + "moonshotai/Kimi-K3": { name: "Kimi K3", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.6, costOutput: 2.4, contextLimit: 1000000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.7-Code": { name: "Kimi K2.7 Code", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.5, costOutput: 2.0, contextLimit: 500000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.7-Code-Highspeed": { name: "Kimi K2.7 Code Highspeed", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.5, costOutput: 2.0, contextLimit: 500000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.6": { name: "Kimi K2.6", tier: "open-source", reasoning: false, hasVision: true, costInput: 0.4, costOutput: 1.6, contextLimit: 262144, outputLimit: 131072 }, + "moonshotai/Kimi-K2.5": { name: "Kimi K2.5", tier: "open-source", reasoning: false, hasVision: true, costInput: 0.3, costOutput: 1.2, contextLimit: 262144, outputLimit: 131072 }, + + // Zhipu GLM + "zai-org/GLM-5.2": { name: "GLM-5.2", tier: "open-source", reasoning: true, costInput: 1.0, costOutput: 3.0, contextLimit: 1000000, outputLimit: 131072 }, + "zai-org/GLM-5.2-Fast": { name: "GLM-5.2 Fast", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 1.5, contextLimit: 1000000, outputLimit: 131072 }, + "zai-org/GLM-5.1": { name: "GLM-5.1", tier: "open-source", reasoning: false, costInput: 1.4, costOutput: 4.4, cacheRead: 0.26, contextLimit: 200000, outputLimit: 131072 }, + "zai-org/GLM-5": { name: "GLM-5", tier: "open-source", reasoning: false, costInput: 1.0, costOutput: 3.0, contextLimit: 200000, outputLimit: 131072 }, + + // MiniMax + "MiniMaxAI/MiniMax-M3-Free": { name: "MiniMax M3 (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M3": { name: "MiniMax M3", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 2.0, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M2.7": { name: "MiniMax M2.7", tier: "open-source", reasoning: false, costInput: 0.3, costOutput: 1.2, cacheRead: 0.06, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M2.5": { name: "MiniMax M2.5", tier: "open-source", reasoning: false, costInput: 0.2, costOutput: 0.8, contextLimit: 1000000, outputLimit: 131072 }, + + // Qwen + "Qwen/Qwen3.6-Max-Preview": { name: "Qwen 3.6 Max Preview", tier: "open-source", reasoning: true, costInput: 1.3, costOutput: 7.8, cacheRead: 0.26, cacheWrite: 1.63, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.6-Plus": { name: "Qwen 3.6 Plus", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 3.0, cacheRead: 0.1, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.7-Max": { name: "Qwen 3.7 Max", tier: "open-source", reasoning: true, costInput: 1.25, costOutput: 3.75, cacheRead: 0.25, cacheWrite: 1.56, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.7-Plus": { name: "Qwen 3.7 Plus", tier: "open-source", reasoning: true, costInput: 0.4, costOutput: 1.2, contextLimit: 1000000, outputLimit: 131072 }, + + // StepFun + "stepfun/Step-3.7-Flash": { name: "Step 3.7 Flash", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.15, costOutput: 0.45, contextLimit: 1000000, outputLimit: 131072 }, + "stepfun/Step-3.5-Flash": { name: "Step 3.5 Flash", tier: "open-source", reasoning: true, costInput: 0.1, costOutput: 0.3, cacheRead: 0.02, contextLimit: 1000000, outputLimit: 131072 }, + + // Tencent + "tencent/hy3-paid": { name: "Tencent Hy3 Paid", tier: "open-source", reasoning: true, costInput: 0.3, costOutput: 0.9, contextLimit: 1000000, outputLimit: 131072 }, + "tencent/Hy3": { name: "Tencent Hy3", tier: "open-source", reasoning: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + + // Google + "google/gemini-3.6-flash": { name: "Gemini 3.6 Flash", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 9.0, cacheRead: 0.15, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.5-flash": { name: "Gemini 3.5 Flash", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 9.0, cacheRead: 0.15, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.5-flash-lite": { name: "Gemini 3.5 Flash Lite", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.25, costOutput: 1.5, cacheRead: 0.03, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.1-flash-lite": { name: "Gemini 3.1 Flash Lite", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.25, costOutput: 1.5, cacheRead: 0.03, contextLimit: 1000000, outputLimit: 65536 }, + + // Miscellaneous + "thinkingmachines/inkling": { name: "Inkling", tier: "open-source", reasoning: true, costInput: 0.2, costOutput: 0.8, contextLimit: 1000000, outputLimit: 131072 }, + "sakana/fugu-ultra": { name: "Sakana Fugu Ultra", tier: "open-source", reasoning: true, costInput: 1.0, costOutput: 3.0, contextLimit: 1000000, outputLimit: 131072 }, + "xai/grok-4.5": { name: "xAI Grok 4.5", tier: "open-source", reasoning: true, costInput: 2.0, costOutput: 6.0, contextLimit: 1000000, outputLimit: 131072 }, + "meta/muse-spark-1.1": { name: "Meta Muse Spark 1.1", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + "nvidia/nemotron-3-ultra-550b-a55b": { name: "Nvidia Nemotron 3 Ultra", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 1.5, contextLimit: 1000000, outputLimit: 131072 }, + "poolside/laguna-s-2.1-free": { name: "Poolside Laguna S 2.1 (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 }, + "inclusionai/ling-3.0-flash-free": { name: "InclusionAI Ling 3.0 Flash (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 } +} + +function main() { + const modelEntries: ModelEntry[] = [] + + for (const [id, raw] of Object.entries(MODEL_CATALOG)) { + const isReasoning = raw.reasoning ?? true + const hasVision = raw.hasVision ?? false + const hasPdf = raw.hasPdf ?? false + + const capabilities: ModelCapabilities = { + temperature: true, + reasoning: isReasoning, + attachment: hasVision || hasPdf, + toolcall: true, + input: { + text: true, + image: hasVision, + pdf: hasPdf, + audio: false, + video: false, + }, + output: { + text: true, + image: false, + pdf: false, + audio: false, + video: false, + }, + interleaved: isReasoning ? { field: "reasoning_content" } : false, + } + + const entry: ModelEntry = { + id, + name: raw.name || id, + tier: raw.tier || (id.includes("/") ? "open-source" : "premium"), + reasoning: isReasoning, + tool_call: true, + capabilities, + variants: isReasoning ? (raw.variants || DEFAULT_REASONING_VARIANTS) : undefined, + cost: { + input: raw.costInput ?? 0.5, + output: raw.costOutput ?? 1.5, + cache: { + read: raw.cacheRead ?? 0, + write: raw.cacheWrite ?? 0, + }, + }, + limit: { + context: raw.contextLimit ?? 1000000, + output: raw.outputLimit ?? 131072, + }, + } + + modelEntries.push(entry) + } + + const projectRoot = join(import.meta.dir, "..") + const outputPath = join(projectRoot, "models.json") + + writeFileSync(outputPath, JSON.stringify(modelEntries, null, 2) + "\n", "utf-8") + console.log(`Successfully generated full-schema models.json for ${modelEntries.length} models`) +} + +main() diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..ba3c563 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,171 @@ +import { readdirSync, statSync, readFileSync, existsSync } from "fs" +import { join, relative } from "path" +import { execSync } from "child_process" + +export interface GitContext { + isGitRepo: boolean + currentBranch: string + mainBranch: string + gitStatus: string + recentCommits: string[] +} + +export interface ProjectContext { + structure: string[] + git: GitContext +} + +const DEFAULT_SKIP = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".turbo", + "coverage", + ".nyc_output", + "tmp", + "temp", + ".cache", + ".DS_Store", + "__pycache__", + ".venv", + "venv", + ".idea", + ".vscode", + "target", + "out", +]) + +const MAX_FILES = 200 +const MAX_DEPTH = 5 +const CACHE_TTL_MS = 30000 // 30s cache TTL to prevent I/O thrashing + +let cachedContext: ProjectContext | null = null +let lastGatheredTime = 0 + +export function parseGitignore(rootDir: string): Set { + const p = join(rootDir, ".gitignore") + if (!existsSync(p)) return new Set() + try { + const patterns = new Set() + for (const line of readFileSync(p, "utf-8").split("\n")) { + const t = line.trim() + if (t && !t.startsWith("#")) patterns.add(t) + } + return patterns + } catch { + return new Set() + } +} + +export function shouldSkip(name: string, relPath: string, gitignore: Set): boolean { + if (DEFAULT_SKIP.has(name)) return true + for (const pat of gitignore) { + const p = pat.endsWith("/") ? pat.slice(0, -1) : pat + if (p === relPath || relPath.startsWith(p + "/") || p === name) return true + if (pat.includes("*")) { + const re = new RegExp("^" + pat.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$") + if (re.test(name)) return true + } + } + return false +} + +export function gatherStructure(rootDir: string): string[] { + const entries: string[] = [] + const gitignore = parseGitignore(rootDir) + + function walk(dir: string, depth: number) { + if (entries.length >= MAX_FILES || depth > MAX_DEPTH) return + + let items: string[] + try { + items = readdirSync(dir) + } catch { + return + } + + for (const name of items.sort()) { + if (entries.length >= MAX_FILES) break + const fullPath = join(dir, name) + const relPath = relative(rootDir, fullPath) + if (shouldSkip(name, relPath, gitignore)) continue + + let st + try { + st = statSync(fullPath) + } catch { + continue + } + + if (st.isDirectory()) { + entries.push(relPath + "/") + walk(fullPath, depth + 1) + } else if (st.isFile()) { + entries.push(relPath) + } + } + } + + walk(rootDir, 0) + return entries +} + +function runGit(cmd: string, cwd: string): string { + try { + return execSync(cmd, { cwd, encoding: "utf-8", stdio: "pipe", maxBuffer: 1024 * 1024 }).trim() + } catch { + return "" + } +} + +export function gatherGit(rootDir: string): GitContext { + try { + execSync("git rev-parse --is-inside-work-tree", { cwd: rootDir, stdio: "pipe" }) + } catch { + return { isGitRepo: false, currentBranch: "", mainBranch: "", gitStatus: "", recentCommits: [] } + } + + const currentBranch = runGit("git branch --show-current", rootDir) + + let mainBranch = "" + for (const cand of ["main", "master"]) { + try { + execSync(`git rev-parse --verify ${cand}`, { cwd: rootDir, stdio: "pipe" }) + mainBranch = cand + break + } catch { + /* not found */ + } + } + + const gitStatus = runGit("git status --short", rootDir) + + let recentCommits: string[] = [] + const log = runGit('git log --oneline -10 --format="%h %s (%an, %ad)" --date=short', rootDir) + if (log) { + recentCommits = log.split("\n") + } + + return { isGitRepo: true, currentBranch, mainBranch, gitStatus, recentCommits } +} + +export function gatherContext(rootDir: string = process.cwd()): ProjectContext { + const now = Date.now() + if (cachedContext && now - lastGatheredTime < CACHE_TTL_MS) { + return cachedContext + } + + cachedContext = { + structure: gatherStructure(rootDir), + git: gatherGit(rootDir), + } + lastGatheredTime = now + return cachedContext +} + +export function clearContextCache(): void { + cachedContext = null + lastGatheredTime = 0 +} diff --git a/src/convert.ts b/src/convert.ts index aa4e3e6..3c3bb7a 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -8,6 +8,7 @@ import type { LanguageModelV3ToolResultPart, LanguageModelV3ToolResultOutput, } from "@ai-sdk/provider" +import type { ProjectContext } from "./context.js" type CCMessage = | { role: "user"; content: string | unknown[] } @@ -59,6 +60,8 @@ interface CCRequestEnvelope { temperature?: number top_p?: number top_k?: number + thinking?: unknown + reasoning_effort?: string } } @@ -174,6 +177,7 @@ function convertTools( export function buildRequest( modelId: string, options: LanguageModelV3CallOptions, + context?: ProjectContext, ): CCRequestEnvelope { let systemPrompt = "" const messages: CCMessage[] = [] @@ -200,21 +204,30 @@ export function buildRequest( if (options.topP !== undefined) params.top_p = options.topP if (options.topK !== undefined) params.top_k = options.topK + const opt = options as Record + const providerOpts = + (opt.providerMetadata as Record> | undefined)?.commandcode ?? + (opt.providerOptions as Record> | undefined)?.commandcode + + if (providerOpts?.thinking) { + params.thinking = providerOpts.thinking + } else if (typeof opt.reasoningEffort === "string") { + params.reasoning_effort = opt.reasoningEffort + } + return { config: { workingDir: process.cwd() ?? "/", date: new Date().toISOString().split("T")[0] ?? "", environment: `${process.platform}-${process.arch}`, - // Stub: opencode does not expose project structure context - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], + structure: context?.structure ?? [], + isGitRepo: context?.git.isGitRepo ?? false, + currentBranch: context?.git.currentBranch ?? "", + mainBranch: context?.git.mainBranch ?? "", + gitStatus: context?.git.gitStatus ?? "", + recentCommits: context?.git.recentCommits ?? [], }, memory: "", - // Stub: taste/memory/permissionMode are Command Code CLI features not exposed via provider API taste: "", skills: null, permissionMode: "standard", diff --git a/src/model.ts b/src/model.ts index 8d0cbe7..43b60c3 100644 --- a/src/model.ts +++ b/src/model.ts @@ -6,14 +6,211 @@ import type { LanguageModelV3Content, LanguageModelV3Usage, LanguageModelV3FinishReason, + LanguageModelV3StreamPart, } from "@ai-sdk/provider" import { buildRequest } from "./convert.js" +import { gatherContext } from "./context.js" import { parseStreamEvents } from "./stream.js" const DEFAULT_BASE_URL = "https://api.commandcode.ai" // x-command-code-version must match the Command Code CLI version for API compatibility const CC_VERSION = "0.26.20" +// --- Retry config --- +// Max 3 retries after the initial attempt (4 total). Backoff schedule with +// jitter keeps reconnects short: ~1s, ~2.5s, ~5s (±25% jitter). +const MAX_RETRIES = 3 +const BACKOFF_SCHEDULE_MS = [1000, 2500, 5000] as const +const REQUEST_TIMEOUT_MS = 300_000 + +// --- error classification --- + +/** + * Extract a lowercased, searchable message from an error of any shape. + * Handles Error instances, strings, and the plain SSE error objects Command + * Code emits (e.g. { type: "server_error", message: "Network connection lost." }). + */ +function extractMessage(err: unknown): string { + if (err === null || err === undefined) return "" + if (err instanceof Error) return err.message.toLowerCase() + if (typeof err === "string") return err.toLowerCase() + if (typeof err === "object") { + const e = err as Record + const nested = e.error as Record | undefined + const parts: string[] = [] + for (const v of [e.message, nested?.message, e.msg, nested?.type, e.type, e.code]) { + if (typeof v === "string" && v) parts.push(v) + } + if (parts.length) return parts.join(" ").toLowerCase() + try { + return JSON.stringify(err).toLowerCase() + } catch { + return "" + } + } + return String(err).toLowerCase() +} + +// Non-transient failures: never retry these. Matched conservatively (specific +// phrases) so genuine transient errors are never misclassified as permanent. +const NON_RETRYABLE_PATTERNS = [ + "insufficient credit", + "insufficient_credit", + "model_not_in_plan", + "model not in plan", + "not_in_plan", + "not in plan", + "usage limit", + "usage_limit", + "exceeded your", + "quota exceeded", + "unauthorized", + "forbidden", + "invalid api key", + "invalid_api_key", + "authentication", + "auth_error", + "permission_denied", + "validation_error", + "bad request", + "not found", +] + +// Transient failures: safe to retry when no content has been emitted yet. +const RETRYABLE_PATTERNS = [ + "network connection lost", + "connection lost", + "connection reset", + "connection refused", + "connection timeout", + "server_error", + "server error", + "internal server error", + "internal error", + "aborted", + "aborterror", + "abort_error", + "fetch failed", + "fetchfailed", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", + "terminated", + "bad gateway", + "service unavailable", + "gateway timeout", + "downstream", + "temporarily unavailable", +] + +function isNonRetryableError(err: unknown): boolean { + const msg = extractMessage(err) + if (!msg) return false + return NON_RETRYABLE_PATTERNS.some((p) => msg.includes(p)) +} + +function isRetryableError(err: unknown): boolean { + if (isNonRetryableError(err)) return false + const msg = extractMessage(err) + if (!msg) return false + return RETRYABLE_PATTERNS.some((p) => msg.includes(p)) +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} + +function backoffDelay(attempt: number): number { + const base = BACKOFF_SCHEDULE_MS[Math.min(attempt, BACKOFF_SCHEDULE_MS.length - 1)] ?? 5000 + // jitter ±25% to avoid synchronized retry storms + return base * (0.75 + Math.random() * 0.5) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function describeError(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === "string") return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +function wrapAsError(err: unknown): Error { + if (err instanceof Error) return err + if (typeof err === "string") return new Error(err) + const e = err as Record + const nested = e.error as Record | undefined + const message = + (typeof e.message === "string" && e.message) || + (typeof nested?.message === "string" && nested.message) || + (typeof e.msg === "string" && e.msg) || + (() => { + try { + return JSON.stringify(err) + } catch { + return "Unknown error" + } + })() + const type = + (typeof e.type === "string" && e.type) || + (typeof nested?.type === "string" && nested.type) || + undefined + const error = new Error(type ? `${type}: ${message}` : String(message)) + Object.assign(error, { ccError: err, ...(type ? { code: type } : {}) }) + return error +} + +function partialOutputError(original: unknown): Error { + const err = new Error( + `Command Code stream failed after partial output was already emitted; reconnect aborted to avoid duplicate content. Original error: ${describeError(original)}`, + ) + Object.assign(err, { partialOutput: true, ccError: original }) + return err +} + +/** + * Decide whether a failure should be retried. + * Retry only when: the error is transient, NO substantive content has been + * emitted yet (avoids dangerous duplicate regeneration), attempts remain, and + * the request was not intentionally aborted (user cancel / hard timeout). + */ +function shouldRetry( + err: unknown, + emittedContent: boolean, + attempt: number, + maxRetries: number, + aborted: boolean, +): boolean { + if (aborted) return false + if (emittedContent) return false + if (attempt >= maxRetries) return false + return isRetryableError(err) +} + +async function buildHttpError(response: Response, modelId: string): Promise { + const errorBody = await response.text().catch(() => "") + let message = `Command Code API error: ${response.status} ${response.statusText}` + let type = "" + try { + const parsed = JSON.parse(errorBody) + if (parsed?.error?.message) message = parsed.error.message + else if (parsed?.message) message = parsed.message + if (parsed?.error?.type) type = parsed.error.type + else if (parsed?.type) type = parsed.type + } catch { + // intentionally silent: error body is not JSON + } + const err = new Error(`${message} [model=${modelId}]`) + if (type) Object.assign(err, { code: type }) + return err +} + export interface CommandCodeModelOptions { apiKey: string baseURL?: string @@ -48,37 +245,208 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { } } + /** + * Initial connection with retry/backoff for transient network and 5xx/429 + * failures. 4xx (auth, plan, credits, validation) are thrown immediately. + */ + private async fetchWithRetry(url: string, fetchOpts: RequestInit, maxRetries: number = MAX_RETRIES): Promise { + let lastError: unknown + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, fetchOpts) + if (response.ok) return response + const err = await buildHttpError(response, this.modelId) + if (isRetryableStatus(response.status) && attempt < maxRetries) { + const delay = backoffDelay(attempt) + console.error( + `[CC-Retry] HTTP ${response.status} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms: ${err.message}`, + ) + await sleep(delay) + continue + } + throw err + } catch (err) { + lastError = err + if (!isRetryableError(err) || attempt >= maxRetries) throw err + const delay = backoffDelay(attempt) + console.error( + `[CC-Retry] network error (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms: ${describeError(err)}`, + ) + await sleep(delay) + } + } + throw lastError + } + + /** + * Single fetch used for mid-stream reconnects. The retry budget for + * reconnects is owned by streamWithReconnect (no double counting). + * 5xx/429 are tagged with a `server_error` token so the wrapper classifies + * them as retryable; 4xx throw the parsed (non-retryable) error. + */ + private async fetchOnce(url: string, fetchOpts: RequestInit): Promise { + const response = await fetch(url, fetchOpts) + if (response.ok) return response + const err = await buildHttpError(response, this.modelId) + if (isRetryableStatus(response.status)) { + throw new Error(`server_error: reconnect HTTP ${response.status} (${err.message})`) + } + throw err + } + + /** + * Wraps a parsed stream to detect mid-stream disconnects and transparently + * reconnect — BUT only when no substantive content (text, reasoning, + * tool-call, tool-input) has been emitted yet. If partial output already + * went downstream, reconnecting would regenerate from scratch and produce + * DUPLICATE content, so we instead surface a clear error. + */ + private streamWithReconnect( + makeStream: () => ReadableStream, + reconnect: () => Promise>, + isAborted: () => boolean, + maxRetries: number = MAX_RETRIES, + ): ReadableStream { + let attempt = 0 + let currentStream = makeStream() + let reader = currentStream.getReader() + let emittedContent = false + let pendingReconnect = false + + return new ReadableStream({ + async pull(controller) { + while (true) { + if (pendingReconnect) { + pendingReconnect = false + try { + currentStream = await reconnect() + reader = currentStream.getReader() + continue + } catch (reconnectErr) { + if (shouldRetry(reconnectErr, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] reconnect failed (attempt ${attempt}/${maxRetries}), retrying in ${Math.round(delay)}ms: ${describeError(reconnectErr)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(reconnectErr)) + } else { + controller.error(wrapAsError(reconnectErr)) + } + return + } + } + + let readResult: ReadableStreamReadResult + try { + readResult = await reader.read() + } catch (err) { + if (shouldRetry(err, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] mid-stream disconnect (attempt ${attempt}/${maxRetries}), reconnecting in ${Math.round(delay)}ms: ${describeError(err)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(err)) + } else { + controller.error(wrapAsError(err)) + } + return + } + + const { done, value } = readResult + if (done) { + controller.close() + return + } + if (!value) continue + + // Track substantive content emission — gates safe reconnect. + if ( + value.type === "text-delta" || + value.type === "reasoning-delta" || + value.type === "tool-call" || + value.type === "tool-input-start" || + value.type === "tool-input-delta" + ) { + emittedContent = true + } + + // Defensive: stream.ts converts SSE errors into controller.error(), + // so an {type:"error"} part should never arrive here. If it does, + // treat it as a terminal failure with retry gating. + if (value.type === "error") { + const inner = (value as { error?: unknown }).error + if (shouldRetry(inner, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] error part (attempt ${attempt}/${maxRetries}), reconnecting in ${Math.round(delay)}ms: ${describeError(inner)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(inner)) + } else { + controller.error(wrapAsError(inner)) + } + return + } + + controller.enqueue(value) + return + } + }, + cancel() { + reader.cancel() + }, + }) + } + async doStream(options: LanguageModelV3CallOptions): Promise { - const body = buildRequest(this.modelId, options) + const context = gatherContext() + const body = buildRequest(this.modelId, options, context) const requestBody = JSON.stringify(body) const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new Error("Request timed out after 5 minutes")), 300_000) + let userAborted = false + const timeout = setTimeout( + () => controller.abort(new Error("Request timed out after 5 minutes")), + REQUEST_TIMEOUT_MS, + ) const userSignal = options.abortSignal if (userSignal) { - const onAbort = () => controller.abort(userSignal.reason) + const onAbort = () => { + userAborted = true + controller.abort(userSignal.reason) + } userSignal.addEventListener("abort", onAbort, { once: true }) } - try { - const response = await fetch(`${this.baseURL}/alpha/generate`, { - method: "POST", - headers: this.buildHeaders(), - body: requestBody, - signal: controller.signal, - }) + const url = `${this.baseURL}/alpha/generate` + const fetchOpts = (): RequestInit => ({ + method: "POST", + headers: this.buildHeaders(), + body: requestBody, + signal: controller.signal, + }) - if (!response.ok) { - const errorBody = await response.text().catch(() => "") - let errorMessage = `Command Code API error: ${response.status} ${response.statusText}` - try { - const parsed = JSON.parse(errorBody) - if (parsed.error?.message) errorMessage = parsed.error.message - else if (parsed.message) errorMessage = parsed.message - } catch { - // intentionally silent: error body is not JSON - } throw new Error(`${errorMessage} [model=${this.modelId}]`) - } + const isAborted = () => userAborted || controller.signal.aborted + + try { + const response = await this.fetchWithRetry(url, fetchOpts()) if (!response.body) { throw new Error(`Command Code API returned no body [model=${this.modelId}]`) @@ -89,8 +457,15 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { responseHeaders[k] = v }) + const makeStream = () => parseStreamEvents(response.body as ReadableStream) + const reconnect = async (): Promise> => { + const r = await this.fetchOnce(url, fetchOpts()) + if (!r.body) throw new Error("server_error: reconnect returned no body") + return parseStreamEvents(r.body as ReadableStream) + } + return { - stream: parseStreamEvents(response.body as ReadableStream), + stream: this.streamWithReconnect(makeStream, reconnect, isAborted), request: { body: requestBody }, response: { headers: responseHeaders }, } diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..d760347 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,216 @@ +import { readFileSync, existsSync } from "fs" +import { homedir } from "os" +import { join, dirname } from "path" +import { fileURLToPath } from "url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export interface ModelEntry { + id: string + name: string + tier: "premium" | "open-source" + reasoning: boolean + tool_call: boolean + capabilities?: { + vision?: boolean + pdf?: boolean + reasoning?: boolean + tool_call?: boolean + [key: string]: unknown + } + variants?: Record + cost: { + input: number + output: number + cache_read?: number + cache_write?: number + cache?: { read: number; write: number } + } + limit: { context: number; output: number } +} + +export interface ApiModel { + id: string + name?: string + context_length?: number +} + +export function loadPluginConfig(): { disableModelSync?: boolean } { + const configPath = join(homedir(), ".config", "opencode", "commandcode-go-opencode-provider.json") + if (!existsSync(configPath)) return {} + try { + return JSON.parse(readFileSync(configPath, "utf-8")) + } catch { + return {} + } +} + +export function loadModels(): ModelEntry[] { + const modelsPath = join(__dirname, "..", "models.json") + try { + return JSON.parse(readFileSync(modelsPath, "utf-8")) + } catch (err) { + throw new Error( + `Bundled models.json missing or corrupt at ${modelsPath}; ` + + `please reinstall commandcode-go-opencode-provider.`, + { cause: err }, + ) + } +} + +export async function fetchModelsFromApi( + apiKey?: string, + fetchFn: typeof fetch = fetch, +): Promise { + if (loadPluginConfig().disableModelSync) return null + + const key = apiKey || process.env.COMMANDCODE_API_KEY + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const headers: Record = {} + if (key) { + headers["Authorization"] = `Bearer ${key}` + } + + const resp = await fetchFn("https://api.commandcode.ai/provider/v1/models", { + headers, + signal: controller.signal, + }) + + if (!resp.ok) return null + const data = (await resp.json()) as { data?: ApiModel[] } + return data.data ?? null + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { + const apiMap = new Map(api.map((m) => [m.id, m])) + const merged: ModelEntry[] = [] + + for (const entry of local) { + const apiModel = apiMap.get(entry.id) + if (apiModel?.context_length) { + merged.push({ + ...entry, + limit: { ...entry.limit, context: apiModel.context_length }, + }) + } else { + merged.push(entry) + } + apiMap.delete(entry.id) + } + + for (const [id, apiModel] of apiMap) { + const isReasoning = + /reasoning|r1|thinking|kimi-k|glm-5|sol|terra|luna/i.test(id) || + /reasoning|r1|thinking/i.test(apiModel.name ?? "") + const isVision = /vision|gemini|claude|gpt|grok/i.test(id) + + merged.push({ + id, + name: apiModel.name ?? id.split("/").pop() ?? id, + tier: id.includes("/") ? "open-source" : "premium", + reasoning: isReasoning, + tool_call: true, + capabilities: { + vision: isVision, + pdf: /claude|gemini|gpt/i.test(id), + reasoning: isReasoning, + tool_call: true, + }, + cost: { input: 0, output: 0 }, + limit: { context: apiModel.context_length ?? 131072, output: 131072 }, + }) + } + + return merged +} + +export function toConfigKey(id: string): string { + const slashIdx = id.indexOf("/") + const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id + return short.toLowerCase() +} + +export interface V1ModelConfig { + id: string + name: string + reasoning: boolean + tool_call: boolean + capabilities?: Record + variants?: Record + cost: Record + limit: { context: number; output: number } +} + +export function toV1ModelConfig(entry: ModelEntry): V1ModelConfig { + const cost: Record = { + input: entry.cost.input, + output: entry.cost.output, + cache: entry.cost.cache ?? { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + }, + } + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + tool_call: entry.tool_call, + capabilities: entry.capabilities, + variants: entry.variants, + cost, + limit: entry.limit, + } +} + +export interface V2ModelDraft { + modelID: string + name: string + capabilities: { tools: boolean; input: string[]; output: string[] } + limit: { context: number; output: number } + cost: { + input: number + output: number + cache?: { read: number; write: number } + } +} + +export function toV2ModelDraft(entry: ModelEntry): V2ModelDraft { + const inputTypes = ["text"] + if (entry.capabilities?.vision) inputTypes.push("image") + if (entry.capabilities?.pdf) inputTypes.push("pdf") + + const draft: V2ModelDraft = { + modelID: entry.id, + name: entry.name, + capabilities: { + tools: entry.tool_call, + input: inputTypes, + output: ["text"], + }, + limit: entry.limit, + cost: { input: entry.cost.input, output: entry.cost.output }, + } + if (entry.cost.cache_read !== undefined || entry.cost.cache !== undefined) { + draft.cost.cache = entry.cost.cache ?? { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + } + } + return draft +} + +export function toV2ModelMap(entries: ModelEntry[]): Record { + const map: Record = {} + for (const entry of entries) { + map[toConfigKey(entry.id)] = toV2ModelDraft(entry) + } + return map +} diff --git a/src/stream.ts b/src/stream.ts index e4f85a3..e52af6a 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -2,6 +2,44 @@ import type { LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3Fi type RawEvent = Record & { type: string } +/** + * Wrap an SSE error payload (which may be a plain object like + * `{ type: "server_error", message: "Network connection lost." }`, a string, + * or an Error) into a real Error so it terminates the ReadableStream via + * controller.error(). The original object is preserved on `ccError` and the + * type is exposed as `code` so downstream retry logic can classify it. + */ +function wrapError(err: unknown): Error { + if (err instanceof Error) return err + if (typeof err === "string") { + const e = new Error(err) + return e + } + if (err && typeof err === "object") { + const e = err as Record + const nested = e.error as Record | undefined + const message = + (typeof e.message === "string" && e.message) || + (typeof nested?.message === "string" && nested.message) || + (typeof e.msg === "string" && e.msg) || + (() => { + try { + return JSON.stringify(err) + } catch { + return "Unknown error" + } + })() + const type = + (typeof e.type === "string" && e.type) || + (typeof nested?.type === "string" && nested.type) || + undefined + const error = new Error(type ? `${type}: ${message}` : String(message)) + Object.assign(error, { ccError: err, ...(type ? { code: type } : {}) }) + return error + } + return new Error(String(err ?? "Unknown error")) +} + function mapFinishReason(raw: string): LanguageModelV3FinishReason["unified"] { switch (raw) { case "stop": @@ -101,8 +139,13 @@ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null { modelId: event.modelId as string | undefined, } + // NOTE: `error` is intentionally NOT mapped to a stream part here. + // SSE error events are converted into ReadableStream terminations via + // controller.error() in parseStreamEvents() so the AI SDK and the retry + // wrapper in model.ts see a real failure (with the original error object + // preserved on err.ccError) instead of a silent part. case "error": - return { type: "error", error: event.error ?? event.message ?? "Unknown error" } + return null default: return null @@ -147,6 +190,13 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") continue + // SSE-level error: terminate the stream with a real Error so the + // retry layer can classify it. Preserve the original payload. + if (parsed.type === "error") { + controller.error(wrapError(parsed.error ?? parsed.message ?? "Unknown Command Code stream error")) + return + } + const part = toStreamPart(parsed) if (part) controller.enqueue(part) } @@ -162,6 +212,10 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr try { const parsed = JSON.parse(jsonStr) if (typeof parsed === "object" && parsed !== null && typeof parsed.type === "string") { + if (parsed.type === "error") { + controller.error(wrapError(parsed.error ?? parsed.message ?? "Unknown Command Code stream error")) + return + } const part = toStreamPart(parsed) if (part) controller.enqueue(part) } @@ -177,8 +231,9 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr buffer += decoder.decode(value, { stream: true }) } } catch (err) { - controller.enqueue({ type: "error", error: err }) - controller.close() + // Network/read-level failure: terminate the stream with the real error + // so the retry layer can decide whether to reconnect. + controller.error(wrapError(err)) } }, cancel() { diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 5fd4e46..099e14c 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -196,3 +196,71 @@ export function makeCallOptions(overrides: Partial = ...overrides, } } + +/** + * Mock fetch to return a sequence of responses (for retry testing). + * Each call to fetch returns the next response in the sequence. + * The last response is reused for any additional calls. + */ +export function mockFetchRetrySequence( + responses: Array<{ + ok?: boolean + status?: number + statusText?: string + body?: ReadableStream | null + headers?: Headers + errorBody?: string + }>, +): { calls: MockFetchCall[]; restore: () => void } { + const encoder = new TextEncoder() + const calls: MockFetchCall[] = [] + let callIndex = 0 + const original = globalThis.fetch + + globalThis.fetch = ((input: RequestInfo | URL, options?: RequestInit) => { + calls.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + options: options ?? {}, + }) + const idx = Math.min(callIndex, responses.length - 1) + callIndex++ + const resp = responses[idx] + const errorBody = resp.errorBody ?? "" + return Promise.resolve({ + ok: resp.ok ?? true, + status: resp.status ?? 200, + statusText: resp.statusText ?? "OK", + headers: resp.headers ?? new Headers(), + body: resp.body ?? null, + text: () => Promise.resolve(errorBody), + json: () => { + try { return Promise.resolve(JSON.parse(errorBody)) } catch { return Promise.resolve({}) } + }, + } as Response) + }) as typeof globalThis.fetch + + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +/** + * Create a simple SSE stream from data strings. + */ +export function makeSSEStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + let i = 0 + return new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close() + return + } + controller.enqueue(encoder.encode(chunks[i])) + i++ + }, + }) +} diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index e3dd9d8..bc23c5f 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,5 +1,16 @@ -import { expect, test, beforeEach } from "bun:test" -import { resolveApiKey } from "../../src/auth.js" +import { expect, test, beforeEach, mock, afterAll } from "bun:test" +import * as realFs from "node:fs" + +// Mock fs so existsSync returns false for auth paths — prevents reading real ~/.commandcode/auth.json +mock.module("fs", () => ({ + existsSync: (p: string) => { + if (typeof p === "string" && (p.includes(".commandcode") || p.includes(".pi"))) return false + return realFs.existsSync(p) + }, + readFileSync: realFs.readFileSync.bind(realFs), +})) + +const { resolveApiKey } = await import("../../src/auth.js") beforeEach(() => { delete process.env.COMMANDCODE_API_KEY diff --git a/tests/unit/context.test.ts b/tests/unit/context.test.ts new file mode 100644 index 0000000..57bcc04 --- /dev/null +++ b/tests/unit/context.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { + gatherContext, + gatherStructure, + parseGitignore, + shouldSkip, + clearContextCache, +} from "../../src/context.js" + +test("parseGitignore extracts clean pattern set", () => { + const patterns = parseGitignore(process.cwd()) + expect(patterns instanceof Set).toBe(true) +}) + +test("shouldSkip identifies default directories and pattern matches", () => { + const patterns = new Set(["*.log", "secret/"]) + expect(shouldSkip("node_modules", "node_modules", patterns)).toBe(true) + expect(shouldSkip(".git", ".git", patterns)).toBe(true) + expect(shouldSkip("dist", "dist", patterns)).toBe(true) + expect(shouldSkip("debug.log", "debug.log", patterns)).toBe(true) + expect(shouldSkip("secret", "secret", patterns)).toBe(true) + expect(shouldSkip("main.ts", "src/main.ts", patterns)).toBe(false) +}) + +test("gatherStructure collects files and respects boundaries", () => { + const structure = gatherStructure(process.cwd()) + expect(Array.isArray(structure)).toBe(true) + expect(structure.length).toBeGreaterThan(0) + // Shouldn't contain node_modules or .git files + expect(structure.some((p) => p.startsWith("node_modules"))).toBe(false) + expect(structure.some((p) => p.startsWith(".git/"))).toBe(false) +}) + +test("gatherContext returns structure and git metadata with caching", () => { + clearContextCache() + const ctx1 = gatherContext(process.cwd()) + expect(ctx1.structure).toBeDefined() + expect(ctx1.git).toBeDefined() + expect(typeof ctx1.git.isGitRepo).toBe("boolean") + + // Second immediate call should return the exact cached object reference + const ctx2 = gatherContext(process.cwd()) + expect(ctx2).toBe(ctx1) + + // Clearing cache allows fresh generation + clearContextCache() + const ctx3 = gatherContext(process.cwd()) + expect(ctx3).not.toBe(ctx1) +}) diff --git a/tests/unit/convert.test.ts b/tests/unit/convert.test.ts index 18ab381..d3f1afc 100644 --- a/tests/unit/convert.test.ts +++ b/tests/unit/convert.test.ts @@ -225,3 +225,36 @@ test("envelope has correct top-level shape", () => { expect(req).toHaveProperty("permissionMode", "standard") expect(req).toHaveProperty("params") }) + +test("passes through project context structure and git state", () => { + const req = buildRequest("m", makeOpts(), { + structure: ["src/", "src/index.ts"], + git: { + isGitRepo: true, + currentBranch: "feature-branch", + mainBranch: "main", + gitStatus: "M src/index.ts", + recentCommits: ["abc1234 initial"], + }, + }) + expect(req.config.structure).toEqual(["src/", "src/index.ts"]) + expect(req.config.isGitRepo).toBe(true) + expect(req.config.currentBranch).toBe("feature-branch") + expect(req.config.gitStatus).toBe("M src/index.ts") +}) + +test("passes through thinking and reasoning_effort options", () => { + const req1 = buildRequest("m", makeOpts({ + providerMetadata: { + commandcode: { + thinking: { type: "enabled", budget_tokens: 4096 }, + }, + }, + } as any)) + expect(req1.params.thinking).toEqual({ type: "enabled", budget_tokens: 4096 }) + + const req2 = buildRequest("m", makeOpts({ + reasoningEffort: "high", + } as any)) + expect(req2.params.reasoning_effort).toBe("high") +}) diff --git a/tests/unit/discovery.test.ts b/tests/unit/discovery.test.ts new file mode 100644 index 0000000..790223a --- /dev/null +++ b/tests/unit/discovery.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test" +import { + fetchModelsFromApi, + mergeModels, + type ModelEntry, + type ApiModel, +} from "../../src/models.js" + +const baseModel: ModelEntry = { + id: "test/local-model", + name: "Local Model", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 100000, output: 4096 }, +} + +test("mergeModels updates context limit for existing models", () => { + const apiModels: ApiModel[] = [{ id: "test/local-model", context_length: 500000 }] + const merged = mergeModels([baseModel], apiModels) + + expect(merged).toHaveLength(1) + expect(merged[0].limit.context).toBe(500000) + expect(merged[0].name).toBe("Local Model") + expect(merged[0].cost.input).toBe(1) +}) + +test("mergeModels dynamically adds newly discovered API models", () => { + const apiModels: ApiModel[] = [ + { id: "test/local-model" }, + { id: "new-vendor/future-reasoning-model", name: "Future Reasoning Model", context_length: 2000000 }, + ] + const merged = mergeModels([baseModel], apiModels) + + expect(merged).toHaveLength(2) + const newModel = merged.find((m) => m.id === "new-vendor/future-reasoning-model") + expect(newModel).toBeDefined() + expect(newModel?.name).toBe("Future Reasoning Model") + expect(newModel?.tier).toBe("open-source") + expect(newModel?.reasoning).toBe(true) + expect(newModel?.limit.context).toBe(2000000) + expect(newModel?.tool_call).toBe(true) +}) + +test("fetchModelsFromApi returns null when API request fails or throws", async () => { + const mockFailingFetch = async () => { + throw new Error("Network down") + } + + const result = await fetchModelsFromApi(undefined, mockFailingFetch as unknown as typeof fetch) + expect(result).toBeNull() +}) + +test("fetchModelsFromApi parses API models and sends authorization header if present", async () => { + let capturedHeaders: Record = {} + const mockFetch = async (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = (init?.headers ?? {}) as Record + return { + ok: true, + json: async () => ({ + data: [{ id: "test/api-model", context_length: 128000 }], + }), + } as unknown as Response + } + + const result = await fetchModelsFromApi("sk-my-key", mockFetch as unknown as typeof fetch) + expect(result).toEqual([{ id: "test/api-model", context_length: 128000 }]) + expect(capturedHeaders["Authorization"]).toBe("Bearer sk-my-key") +}) + +test("fetchModelsFromApi works without API key (public endpoint)", async () => { + let capturedHeaders: Record = {} + const originalEnv = process.env.COMMANDCODE_API_KEY + delete process.env.COMMANDCODE_API_KEY + + try { + const mockFetch = async (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = (init?.headers ?? {}) as Record + return { + ok: true, + json: async () => ({ + data: [{ id: "test/public-model", context_length: 64000 }], + }), + } as unknown as Response + } + + const result = await fetchModelsFromApi(undefined, mockFetch as unknown as typeof fetch) + expect(result).toEqual([{ id: "test/public-model", context_length: 64000 }]) + expect(capturedHeaders["Authorization"]).toBeUndefined() + } finally { + if (originalEnv) process.env.COMMANDCODE_API_KEY = originalEnv + } +}) diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index e9973fd..a81bd72 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,5 +1,18 @@ -import { expect, test, beforeAll, afterAll } from "bun:test" -import { createCommandCode } from "../../index.js" +import { expect, test, beforeAll, afterAll, mock } from "bun:test" +import * as realFs from "node:fs" + +// Mock fs so existsSync returns false — prevents reading real ~/.commandcode/auth.json +// Only affects tests that expect "no key found" behavior; tests with explicit apiKey bypass fs entirely +mock.module("fs", () => ({ + existsSync: (p: string) => { + // Only intercept auth file paths; let other paths use real fs + if (typeof p === "string" && (p.includes(".commandcode") || p.includes(".pi"))) return false + return realFs.existsSync(p) + }, + readFileSync: realFs.readFileSync.bind(realFs), +})) + +const { createCommandCode } = await import("../../index.js") let originalEnv: Record = {} beforeAll(() => { diff --git a/tests/unit/model.test.ts b/tests/unit/model.test.ts index 0d7e188..0635e4b 100644 --- a/tests/unit/model.test.ts +++ b/tests/unit/model.test.ts @@ -1,6 +1,6 @@ import { expect, test, beforeAll, afterAll } from "bun:test" import { CommandCodeLanguageModel } from "../../src/model.js" -import { mockFetchTrack, mockFetchError, mockFetchStream, makeCallOptions } from "../helpers/mocks.js" +import { mockFetchTrack, mockFetchError, mockFetchStream, mockFetchRetrySequence, makeCallOptions } from "../helpers/mocks.js" const MODEL_ID = "test-model" const API_KEY = "sk-test-key" @@ -126,8 +126,14 @@ test("doStream throws descriptive error on non-OK response", async () => { restore() }) -test("doStream throws on HTTP error without JSON body", async () => { - const { restore } = mockFetchError(500, "Internal Server Error") +test("doStream throws on HTTP error without JSON body", { timeout: 30000 }, async () => { + // 500 is retryable — return it for all 4 attempts (1 initial + 3 retries) + const { restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + ]) const model = makeModel() expect(model.doStream(makeCallOptions())).rejects.toThrow("Command Code API error: 500 Internal Server Error") restore() @@ -218,7 +224,7 @@ test("doGenerate handles tool calls", async () => { }) test("doStream includes model ID in error messages", async () => { - const { restore } = mockFetchError(500, "Server Error", JSON.stringify({ + const { restore } = mockFetchError(400, "Bad Request", JSON.stringify({ error: { message: "Something broke" }, })) const model = makeModel() @@ -231,3 +237,181 @@ test("doStream includes model ID in error messages", async () => { } restore() }) + +// --- Retry/Backoff tests --- + +test("fetchWithRetry retries on HTTP 500 then succeeds", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: '{"error":{"message":"server oops"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + restore() + + // Should have made 2 fetch calls (1 failed + 1 success) + expect(calls).toHaveLength(2) + // Result should have a valid stream + expect(result.stream).toBeDefined() + const reader = result.stream.getReader() + const { done } = await reader.read() + expect(done).toBe(false) + reader.releaseLock() +}) + +test("fetchWithRetry retries on HTTP 429 then succeeds", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 429, statusText: "Too Many Requests", errorBody: '{"error":{"message":"rate limited"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + restore() + + expect(calls).toHaveLength(2) + expect(result.stream).toBeDefined() +}) + +test("fetchWithRetry fails fast on 401 (non-retryable)", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 401, statusText: "Unauthorized", errorBody: '{"error":{"message":"Invalid API key"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("Invalid API key") + } + restore() + + // Should have made only 1 fetch call — no retries for 4xx + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry fails fast on 403 (non-retryable)", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 403, statusText: "Forbidden", errorBody: '{"error":{"message":"Forbidden"}}' }, + ]) + const model = makeModel() + expect(model.doStream(makeCallOptions())).rejects.toThrow("Forbidden") + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry respects max retries and throws last error", { timeout: 30000 }, async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("down") + } + restore() + + // Should have made 4 fetch calls (1 initial + 3 retries) + expect(calls).toHaveLength(4) +}) + +test("fetchWithRetry fails fast on quota/auth error patterns in body", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 400, statusText: "Bad Request", errorBody: '{"error":{"message":"insufficient credit balance"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("insufficient credit") + } + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry fails fast on validation_error", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 400, statusText: "Bad Request", errorBody: '{"type":"validation_error","message":"Invalid params"}' }, + ]) + const model = makeModel() + expect(model.doStream(makeCallOptions())).rejects.toThrow("Invalid params") + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry retries network errors (fetch throws)", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const original = globalThis.fetch + let callCount = 0 + globalThis.fetch = ((async () => { + callCount++ + if (callCount === 1) { + throw new Error("fetch failed: ECONNRESET") + } + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }), + text: () => Promise.resolve(""), + } as Response + }) as typeof globalThis.fetch) + + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + globalThis.fetch = original + + expect(callCount).toBe(2) + expect(result.stream).toBeDefined() +}) + +test("doStream sends retry-specific log messages on 5xx", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { restore } = mockFetchRetrySequence([ + { ok: false, status: 502, statusText: "Bad Gateway", errorBody: '{"error":{"message":"bad gateway"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + + const errors: string[] = [] + const origError = console.error + console.error = (...args: any[]) => errors.push(args.join(" ")) + + const model = makeModel() + await model.doStream(makeCallOptions()) + + console.error = origError + restore() + + expect(errors.some((e) => e.includes("[CC-Retry]") && e.includes("HTTP 502"))).toBe(true) +}) diff --git a/tests/unit/models.test.ts b/tests/unit/models.test.ts new file mode 100644 index 0000000..a4b6696 --- /dev/null +++ b/tests/unit/models.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test" +import { + loadModels, + toConfigKey, + toV1ModelConfig, + toV2ModelDraft, + toV2ModelMap, + type ModelEntry, +} from "../../src/models.js" + +const sampleEntry: ModelEntry = { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + tier: "open-source", + reasoning: true, + tool_call: true, + capabilities: { + vision: true, + pdf: false, + reasoning: true, + tool_call: true, + }, + variants: { fast: "deepseek/deepseek-v4-flash-fast" }, + cost: { input: 0.14, output: 0.28, cache_read: 0.014, cache_write: 0.14 }, + limit: { context: 1000000, output: 131072 }, +} + +test("loadModels loads the bundled catalog with at least 50 models", () => { + const models = loadModels() + expect(Array.isArray(models)).toBe(true) + expect(models.length).toBeGreaterThanOrEqual(50) + for (const m of models) { + expect(m.id).toBeDefined() + expect(m.name).toBeDefined() + expect(m.limit.context).toBeGreaterThan(0) + } +}) + +test("toConfigKey strips provider prefix and lowercases", () => { + expect(toConfigKey("deepseek/deepseek-v4-flash")).toBe("deepseek-v4-flash") + expect(toConfigKey("Qwen/Qwen3.7-Flash")).toBe("qwen3.7-flash") + expect(toConfigKey("claude-sonnet-5")).toBe("claude-sonnet-5") + expect(toConfigKey("META/MUSE-SPARK-1.1")).toBe("muse-spark-1.1") +}) + +test("toV1ModelConfig converts model to V1 format preserving capabilities and cost", () => { + const v1 = toV1ModelConfig(sampleEntry) + expect(v1.id).toBe("deepseek/deepseek-v4-flash") + expect(v1.name).toBe("DeepSeek V4 Flash") + expect(v1.reasoning).toBe(true) + expect(v1.tool_call).toBe(true) + expect(v1.capabilities?.vision).toBe(true) + expect(v1.variants?.fast).toBe("deepseek/deepseek-v4-flash-fast") + expect(v1.cost.input).toBe(0.14) + expect(v1.cost.output).toBe(0.28) + expect(v1.limit.context).toBe(1000000) +}) + +test("toV2ModelDraft converts to OpenCode 2 shape including vision/pdf capabilities", () => { + const v2 = toV2ModelDraft(sampleEntry) + expect(v2.modelID).toBe("deepseek/deepseek-v4-flash") + expect(v2.name).toBe("DeepSeek V4 Flash") + expect(v2.capabilities.tools).toBe(true) + expect(v2.capabilities.input).toContain("text") + expect(v2.capabilities.input).toContain("image") + expect(v2.capabilities.output).toContain("text") + expect(v2.limit.context).toBe(1000000) + expect(v2.cost.input).toBe(0.14) +}) + +test("toV2ModelMap indexes drafts by config key", () => { + const map = toV2ModelMap([sampleEntry]) + expect(map["deepseek-v4-flash"]).toBeDefined() + expect(map["deepseek-v4-flash"].modelID).toBe("deepseek/deepseek-v4-flash") +}) diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index ea36f73..9ac82f3 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -162,8 +162,18 @@ test("skips [DONE] lines", async () => { test("handles error events", async () => { const body = streamFromChunks([sseEvent({ type: "error", error: "Something broke" })]) const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts[0]).toMatchObject({ type: "error", error: "Something broke" }) + // SSE error events now terminate the stream via controller.error() + // instead of being silently enqueued as a stream part + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Something broke") + } finally { + reader.releaseLock() + } }) test("handles response-metadata event", async () => { @@ -221,3 +231,125 @@ test("closes stream cleanly at end of data", async () => { const parts = await collectStream(stream) expect(parts).toHaveLength(1) }) + +// --- SSE error handling tests (controller.error behavior) --- + +test("SSE error with object payload produces Error with ccError and code", async () => { + const body = streamFromChunks([sseEvent({ + type: "error", + error: { type: "server_error", message: "Network connection lost." }, + })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + const error = err as Error & { ccError?: unknown; code?: string } + expect(error.message).toContain("server_error") + expect(error.message).toContain("Network connection lost.") + expect(error.code).toBe("server_error") + expect(error.ccError).toBeDefined() + } finally { + reader.releaseLock() + } +}) + +test("SSE error with string payload wraps into Error", async () => { + const body = streamFromChunks([sseEvent({ type: "error", error: "rate limited" })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("rate limited") + } finally { + reader.releaseLock() + } +}) + +test("SSE error with message field (instead of error) is handled", async () => { + const body = streamFromChunks([sseEvent({ type: "error", message: "Something failed" })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Something failed") + } finally { + reader.releaseLock() + } +}) + +test("SSE error in final buffer chunk rejects stream", async () => { + // Send error as the last event with no trailing newline after close + const errorJson = JSON.stringify({ type: "error", error: "final error" }) + const body = streamFromChunks([`data: ${errorJson}`]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("final error") + } finally { + reader.releaseLock() + } +}) + +test("network read failure in pull() rejects stream with wrapped error", async () => { + // Create a body stream that yields one chunk then throws on subsequent reads + const encoder = new TextEncoder() + let readCount = 0 + const failingBody = new ReadableStream({ + pull(controller) { + readCount++ + if (readCount === 1) { + controller.enqueue(encoder.encode('data: {"type":"text-delta","id":"t1","delta":"hello"}\n\n')) + } else { + // Simulate network failure by closing with an error + controller.error(new Error("ECONNRESET: connection reset")) + } + }, + }) + const stream = parseStreamEvents(failingBody) + const reader = stream.getReader() + // First read should succeed + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value).toMatchObject({ type: "text-delta", delta: "hello" }) + // Second read should fail — the underlying body errored, which causes + // the catch block in parseStreamEvents to call controller.error(wrapError(err)) + try { + const second = await reader.read() + // If the stream closes gracefully instead of throwing, that's also acceptable + // (depends on how the ReadableStream propagates the body error) + if (second.done) { + // Stream closed — acceptable + } + } catch (err) { + expect(err).toBeInstanceOf(Error) + } finally { + reader.releaseLock() + } +}) + +test("normal events still work after error handling change", async () => { + const body = streamFromChunks([ + sseEvent({ type: "text-delta", id: "t1", delta: "hello" }), + sseEvent({ type: "text-delta", id: "t1", delta: " world" }), + sseEvent({ type: "finish-step", finishReason: "stop", usage: { inputTokens: 5, outputTokens: 10 } }), + ]) + const stream = parseStreamEvents(body) + const parts = await collectStream(stream) + expect(parts).toHaveLength(3) + expect(parts[0]).toMatchObject({ type: "text-delta", delta: "hello" }) + expect(parts[1]).toMatchObject({ type: "text-delta", delta: " world" }) + expect(parts[2]).toMatchObject({ type: "finish" }) +}) diff --git a/tests/unit/v2.test.ts b/tests/unit/v2.test.ts new file mode 100644 index 0000000..b501af1 --- /dev/null +++ b/tests/unit/v2.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import v2Plugin, { id } from "../../v2.ts" + +test("v2 export has correct id and setup function", () => { + expect(id).toBe("commandcode-go-opencode-provider") + expect(v2Plugin.id).toBe("commandcode-go-opencode-provider") + expect(typeof v2Plugin.setup).toBe("function") +}) + +test("v2 setup registers provider and model catalog via transform", async () => { + const providerUpdates: Record> = {} + const modelUpdates: Record> = {} + + const mockContext = { + catalog: { + transform: async (fn: (catalog: any) => void | Promise) => { + const catalogDraft = { + provider: { + update: (providerID: string, updateFn: (draft: Record) => void) => { + const draft: Record = {} + updateFn(draft) + providerUpdates[providerID] = draft + }, + }, + model: { + update: (providerID: string, modelKey: string, updateFn: (draft: Record) => void) => { + const draft: Record = {} + updateFn(draft) + modelUpdates[`${providerID}:${modelKey}`] = draft + }, + }, + } + await fn(catalogDraft) + }, + }, + } + + await v2Plugin.setup(mockContext as any) + + expect(providerUpdates["commandcode"]).toBeDefined() + expect(providerUpdates["commandcode"].name).toBe("Command Code") + expect(providerUpdates["commandcode"].package).toBe("aisdk:commandcode-go-opencode-provider") + + expect(Object.keys(modelUpdates).length).toBeGreaterThanOrEqual(50) + const sampleModel = modelUpdates["commandcode:deepseek-v4-flash"] + expect(sampleModel).toBeDefined() + expect(sampleModel.modelID).toBe("deepseek/deepseek-v4-flash") + expect(sampleModel.capabilities).toBeDefined() + expect(sampleModel.limit).toBeDefined() +}) diff --git a/tsconfig.json b/tsconfig.json index 2099597..516b274 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "outDir": "dist", "declaration": true }, - "include": ["index.ts", "plugin.ts", "src/**/*.ts"], + "include": ["index.ts", "plugin.ts", "v2.ts", "src/**/*.ts"], "exclude": ["scripts/", "tests/"] } diff --git a/v2.ts b/v2.ts new file mode 100644 index 0000000..c571aed --- /dev/null +++ b/v2.ts @@ -0,0 +1,82 @@ +import { + loadModels, + fetchModelsFromApi, + mergeModels, + toConfigKey, + toV2ModelDraft, +} from "./src/models.js" + +export const id = "commandcode-go-opencode-provider" + +export interface CatalogDraft { + provider: { + update(providerID: string, update: (draft: Record) => void): void + } + model: { + update( + providerID: string, + modelID: string, + update: (draft: Record) => void, + ): void + } +} + +export interface V2PluginContext { + catalog: { + transform( + transform: (catalog: CatalogDraft) => void | Promise, + ): Promise + } +} + +/** + * OpenCode V2 plugin entrypoint. + * + * V2 loads a plugin from the module's default export, which must be an object + * with a unique `id` and a `setup` function. `setup` registers the Command + * Code provider and its model catalog through `ctx.catalog.transform`: + * + * ```json + * { + * "plugins": ["commandcode-go-opencode-provider/v2"] + * } + * ``` + */ +export default { + id, + setup: async (ctx: V2PluginContext): Promise => { + let models = loadModels() + try { + const apiModels = await fetchModelsFromApi() + if (apiModels && apiModels.length > 0) { + models = mergeModels(models, apiModels) + } + } catch { + // Graceful fallback to bundled models + } + + await ctx.catalog.transform((catalog) => { + catalog.provider.update("commandcode", (draft) => { + draft.name = "Command Code" + draft.package = "aisdk:commandcode-go-opencode-provider" + draft.settings = { + baseURL: "https://api.commandcode.ai", + ...(typeof draft.settings === "object" && draft.settings !== null + ? (draft.settings as Record) + : {}), + } + }) + + for (const entry of models) { + catalog.model.update("commandcode", toConfigKey(entry.id), (draft) => { + const model = toV2ModelDraft(entry) + draft.modelID = model.modelID + draft.name = model.name + draft.capabilities = model.capabilities + draft.limit = model.limit + draft.cost = model.cost + }) + } + }) + }, +}