diff --git a/CHANGELOG.md b/CHANGELOG.md index 91da1eb..1d6fec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### Features + +* **cost:** read model costs from `/v1/model/info` (`input_cost_per_token`, `output_cost_per_token`, `cache_read_input_token_cost`, `cache_creation_input_token_cost`) + # [0.7.0](https://github.com/yuseferi/opencode-litellm/compare/v0.6.0...v0.7.0) (2026-07-24) diff --git a/src/plugin/build-model.ts b/src/plugin/build-model.ts index e8a270f..2f9b5e7 100644 --- a/src/plugin/build-model.ts +++ b/src/plugin/build-model.ts @@ -1,5 +1,5 @@ import type { Model as ModelV2 } from '@opencode-ai/sdk/v2' -import type { LiteLLMModel } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo } from '../types' import { categorizeModel, formatModelName, @@ -8,18 +8,14 @@ import { /** * Build an OpenCode V2 `Model` entry from a discovered LiteLLM model. * - * The V2 schema requires a lot of fields we have no real data for - * (`cost`, `limit`, `release_date`, …). We fill these with sensible - * defaults — zero cost / zero limits / today's date — so the entry - * type-checks and the picker renders something useful. Real values - * can be added in a future release if LiteLLM exposes them via - * `/v1/model/info`, which carries `max_tokens`, `input_cost_per_token`, - * etc. + * Cost data is populated from `/v1/model/info` (`info` param). + * Falls back to zero when the endpoint is unreachable. */ export function buildModelV2( providerID: string, api: { id: string; url: string; npm: string }, model: LiteLLMModel, + info: LiteLLMModelInfo, ): ModelV2 { const type = categorizeModel(model) const isVision = !!model.supports_vision @@ -53,9 +49,14 @@ export function buildModelV2( interleaved: false, }, cost: { - input: 0, - output: 0, - cache: { read: 0, write: 0 }, + // OpenCode uses dollars per million tokens ($/M tokens); + // LiteLLM returns dollars per token. Multiply by 1e6. + input: (info.input_cost_per_token ?? 0) * 1_000_000, + output: (info.output_cost_per_token ?? 0) * 1_000_000, + cache: { + read: (info.cache_read_input_token_cost ?? 0) * 1_000_000, + write: (info.cache_creation_input_token_cost ?? 0) * 1_000_000, + }, }, limit: { context: model.max_input_tokens ?? 0, diff --git a/src/plugin/discover.ts b/src/plugin/discover.ts index b67cc5f..4978902 100644 --- a/src/plugin/discover.ts +++ b/src/plugin/discover.ts @@ -2,11 +2,12 @@ import type { Model as ModelV2, Provider as ProviderV2 } from '@opencode-ai/sdk/ import { autoDetectLiteLLM, checkLiteLLMHealth, + discoverLiteLLMModelInfo, discoverLiteLLMModels, normalizeBaseURL, } from '../utils/litellm-api' import { requiresResponsesAPI } from '../utils/format-model-name' -import type { LiteLLMModel, Transport, TransportPolicy } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo, Transport, TransportPolicy } from '../types' import { buildModelV2 } from './build-model' const DISCOVERY_TIMEOUT_MS = 5000 @@ -138,16 +139,29 @@ export async function discoverBucket( return } - let models: LiteLLMModel[] - try { - models = await discoverLiteLLMModels(baseURL, apiKey, customHeaders) - } catch (error) { + const [modelsResult, infoResult] = await Promise.allSettled([ + discoverLiteLLMModels(baseURL, apiKey, customHeaders), + discoverLiteLLMModelInfo(baseURL, apiKey, customHeaders), + ]) + + if (modelsResult.status === 'rejected') { console.warn( '[opencode-litellm] Model discovery failed:', - error instanceof Error ? error.message : String(error), + modelsResult.reason instanceof Error ? modelsResult.reason.message : String(modelsResult.reason), ) return } + const models = modelsResult.value + + let infoByName: Map | null = null + if (infoResult.status === 'fulfilled') { + infoByName = infoResult.value + } else { + console.warn( + '[opencode-litellm] /v1/model/info unavailable; model costs will be zero:', + infoResult.reason instanceof Error ? infoResult.reason.message : String(infoResult.reason), + ) + } if (models.length === 0) { console.warn( @@ -176,7 +190,8 @@ export async function discoverBucket( // it with "team not allowed"). Set `api.id` per-model so each // entry carries the correct upstream model name. const perModelApi = { ...resolvedApi, id: model.id } - out[model.id] = buildModelV2(resolvedApi.id, perModelApi, model) + const info = infoByName?.get(model.id) ?? {} + out[model.id] = buildModelV2(resolvedApi.id, perModelApi, model, info) } } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1f94960..899f923 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -87,7 +87,7 @@ function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel * image, audio) — they can't be used as primary chat models and would * clutter the picker. */ -function toConfigModel(model: LiteLLMModel): Record | null { +function toConfigModel(model: LiteLLMModel, info: LiteLLMModelInfo): Record | null { const type = categorizeModel(model) if (type === 'embedding' || type === 'image' || type === 'audio') { return null @@ -101,6 +101,21 @@ function toConfigModel(model: LiteLLMModel): Record | null { output: model.max_output_tokens ?? 0, } } + if (info.input_cost_per_token != null || info.output_cost_per_token != null) { + // OpenCode's cost schema uses dollars per million tokens ($/M tokens), + // while LiteLLM's model_info returns dollars per token. Multiply by 1e6. + const cost: Record = { + input: (info.input_cost_per_token ?? 0) * 1_000_000, + output: (info.output_cost_per_token ?? 0) * 1_000_000, + } + if (info.cache_read_input_token_cost != null) { + cost.cache_read = info.cache_read_input_token_cost * 1_000_000 + } + if (info.cache_creation_input_token_cost != null) { + cost.cache_write = info.cache_creation_input_token_cost * 1_000_000 + } + entry.cost = cost + } if (model.supports_function_calling) { entry.tool_call = true } @@ -303,7 +318,7 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { if (models[model.id]) continue const info = infoByName?.get(model.id) if (infoByName && !info) unmatched.push(model.id) - const entry = toConfigModel(info ? enrichModel(model, info) : model) + const entry = toConfigModel(info ? enrichModel(model, info) : model, info ?? {}) if (!entry) { skipped++ continue diff --git a/src/types/index.ts b/src/types/index.ts index 26ec7c7..d6d99ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,6 +58,14 @@ export interface LiteLLMModelInfo { supports_reasoning?: boolean supports_pdf_input?: boolean supports_audio_input?: boolean + /** + * Cost per token (in USD). Populated from `/v1/model/info`; defaults + * to 0 if the endpoint is unreachable or the field is absent. + */ + input_cost_per_token?: number + output_cost_per_token?: number + cache_read_input_token_cost?: number + cache_creation_input_token_cost?: number } /** A single entry returned by LiteLLM's `/v1/model/info` endpoint. */ diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index 3b46b0c..805b4e3 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -125,6 +125,23 @@ export async function discoverLiteLLMModelInfo( info[flag] = paramsValue } } + // Fill cost fields + const costFields = [ + 'input_cost_per_token', + 'output_cost_per_token', + 'cache_read_input_token_cost', + 'cache_creation_input_token_cost', + ] as const + for (const field of costFields) { + const paramsValue = entry.litellm_params?.[field] + // LiteLLM's ModelInfo defaults cost fields to 0.0 (not null) when the + // model is not in its pricing map. A zero in model_info therefore does + // not mean "free" — it means "unknown". If litellm_params carries an + // explicit non-zero value (set by the user in config.yaml), prefer it. + if ((info[field] == null || info[field] === 0) && typeof paramsValue === 'number' && paramsValue > 0) { + info[field] = paramsValue + } + } // Index under every alias LiteLLM may use for this model — the // `/v1/models` id can match any of them depending on how the // deployment names its entries (alias vs upstream model string).