Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **Discovered models now carry real pricing instead of always showing
`$0.00`.** `toConfigModel()` never read `input_cost_per_token` /
`output_cost_per_token` from `/v1/model/info`, so every model —
including ones LiteLLM has genuine pricing for — landed in
`opencode.json` without a `cost` field, and OpenCode's own default
(`0`) made every call look free regardless of actual spend. The
plugin now maps those fields into OpenCode's `cost` block (USD per
token → USD per million tokens, OpenCode's convention), verified live
against `x-litellm-response-cost` on a real completion. Models
LiteLLM has no price anchor for (e.g. rerank) are left without a
`cost` field, same as before — this fix reports real prices, it
doesn't invent ones.
- **Embedding / image / audio models no longer appear in the OpenCode
model picker.** The non-chat filter in `toConfigModel()` was a dead
code path that returned the model entry either way, so models like
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ opencode
| 📡 **Dynamic discovery** | Queries `/v1/models` so your OpenCode model picker always reflects your live `model_list`. |
| 🏷️ **Smart formatting** | Turns `anthropic/claude-3-5-sonnet` into `Claude 3 5 Sonnet` in the picker — handles versions, sizes, quantizations, and brand-cased names like `gpt-4o`. |
| 🧠 **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. |
| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
| 🧪 **Reasoning-aware routing** | Auto-routes `gpt-5*` / `o1`/`o3`/`o4*` models through a sibling `litellm-responses` provider that uses `/v1/responses`, so tools + `reasoning_effort` actually work. Override per model via `responsesApiModels` / `chatApiModels`. |
| 🏢 **Provider extraction** | Pulls `litellm_provider` (or the `provider/model` prefix) into `organizationOwner` so models group correctly in the UI. |
| 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars or `provider.litellm.options.apiKey`. |
Expand Down Expand Up @@ -292,7 +293,7 @@ sequenceDiagram
1. On OpenCode startup the `config` lifecycle hook fires.
2. If `provider.litellm` exists, its `baseURL` is used. Otherwise common ports are probed.
3. A health check (`GET /v1/models`) verifies the proxy is reachable and authorized.
4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries keyed by `id`, with formatted `name`, `organizationOwner`, and inferred `modalities`. Non-chat models (embedding / image / audio) are excluded from the picker.
4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags, and per-token pricing — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries keyed by `id`, with formatted `name`, `organizationOwner`, inferred `modalities`, and `cost` (USD/1M tokens, converted from LiteLLM's USD/token). Non-chat models (embedding / image / audio) are excluded from the picker.
5. Each model is bucketed by transport — reasoning-tier models (`gpt-5*`, `o1`/`o3`/`o4*`, or anything with `mode === 'responses'`) go into the `litellm-responses` provider; everything else goes into `litellm`. Per-model overrides via `responsesApiModels` / `chatApiModels` win.
6. Discovered models are merged on top of any user-defined ones — never overwriting them. A model is skipped if its key already exists under **either** provider.
7. The whole flow is wrapped in a `Promise.race` against a 20 s timeout so a slow proxy never blocks boot.
Expand Down
20 changes: 20 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,19 @@ function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel
supports_reasoning: model.supports_reasoning ?? info.supports_reasoning,
supports_pdf_input: model.supports_pdf_input ?? info.supports_pdf_input,
supports_audio_input: model.supports_audio_input ?? info.supports_audio_input,
input_cost_per_token: model.input_cost_per_token ?? info.input_cost_per_token,
output_cost_per_token: model.output_cost_per_token ?? info.output_cost_per_token,
}
}

/**
* OpenCode's `cost` config field is USD per **million** tokens (the
* models.dev convention); LiteLLM's `/v1/model/info` reports USD per
* single token. `1e6` bridges the two — verified against a live
* `x-litellm-response-cost` header, not just the unit names.
*/
const USD_PER_TOKEN_TO_PER_MILLION = 1_000_000

/**
* Convert a discovered LiteLLM model into an OpenCode config-level
* model entry (the shape used in `provider.*.models` inside
Expand Down Expand Up @@ -110,6 +120,16 @@ function toConfigModel(model: LiteLLMModel): Record<string, unknown> | null {
if (model.supports_vision) {
entry.attachment = true
}
// Only emit `cost` when LiteLLM actually reported a price. Omitting
// it (rather than defaulting to 0) lets OpenCode/models.dev fall back
// to their own default instead of us asserting "this model is free"
// for something LiteLLM simply has no price anchor for (e.g. rerank).
if (model.input_cost_per_token != null || model.output_cost_per_token != null) {
entry.cost = {
input: (model.input_cost_per_token ?? 0) * USD_PER_TOKEN_TO_PER_MILLION,
output: (model.output_cost_per_token ?? 0) * USD_PER_TOKEN_TO_PER_MILLION,
}
}
Comment thread
yuseferi marked this conversation as resolved.
const input: Array<'text' | 'image' | 'pdf' | 'audio'> = ['text']
if (model.supports_vision) input.push('image')
if (model.supports_pdf_input) input.push('pdf')
Expand Down
10 changes: 10 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface LiteLLMModel {
supports_reasoning?: boolean
supports_pdf_input?: boolean
supports_audio_input?: boolean
/**
* USD price per input/output token, reliably available via
* `/v1/model/info` (`/v1/models` omits pricing). Absent for models
* LiteLLM has no price anchor for (e.g. rerank) — treat as "unknown",
* not "free".
*/
input_cost_per_token?: number
output_cost_per_token?: number
}

export interface LiteLLMModelsResponse {
Expand All @@ -58,6 +66,8 @@ export interface LiteLLMModelInfo {
supports_reasoning?: boolean
supports_pdf_input?: boolean
supports_audio_input?: boolean
input_cost_per_token?: number
output_cost_per_token?: number
}

/** A single entry returned by LiteLLM's `/v1/model/info` endpoint. */
Expand Down