diff --git a/CHANGELOG.md b/CHANGELOG.md index 91da1eb..4fe98ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 378b3fe..50d1d27 100644 --- a/README.md +++ b/README.md @@ -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`. | @@ -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. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1f94960..9a1ae71 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -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 @@ -110,6 +120,16 @@ function toConfigModel(model: LiteLLMModel): Record | 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, + } + } const input: Array<'text' | 'image' | 'pdf' | 'audio'> = ['text'] if (model.supports_vision) input.push('image') if (model.supports_pdf_input) input.push('pdf') diff --git a/src/types/index.ts b/src/types/index.ts index 26ec7c7..812d457 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { @@ -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. */