Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
23 changes: 12 additions & 11 deletions src/plugin/build-model.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions src/plugin/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, LiteLLMModelInfo> | 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(
Expand Down Expand Up @@ -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)
}
}

Expand Down
19 changes: 17 additions & 2 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
function toConfigModel(model: LiteLLMModel, info: LiteLLMModelInfo): Record<string, unknown> | null {
const type = categorizeModel(model)
if (type === 'embedding' || type === 'image' || type === 'audio') {
return null
Expand All @@ -101,6 +101,21 @@ function toConfigModel(model: LiteLLMModel): Record<string, unknown> | 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<string, unknown> = {
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
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 17 additions & 0 deletions src/utils/litellm-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down