Skip to content
Open
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
51 changes: 46 additions & 5 deletions src-tauri/src/acp/opencode_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct CatalogModel {
pub cost_in: Option<f64>,
#[serde(default)]
pub cost_out: Option<f64>,
#[serde(default)]
pub cost_cache_read: Option<f64>,
#[serde(default)]
pub cost_cache_write: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -149,6 +153,14 @@ pub fn normalize_models_dev(raw: &str) -> Result<Vec<CatalogProvider>, AppComman
.get("cost")
.and_then(|v| v.get("output"))
.and_then(|v| v.as_f64()),
cost_cache_read: m
.get("cost")
.and_then(|v| v.get("cache_read"))
.and_then(|v| v.as_f64()),
cost_cache_write: m
.get("cost")
.and_then(|v| v.get("cache_write"))
.and_then(|v| v.as_f64()),
});
}
}
Expand Down Expand Up @@ -181,7 +193,13 @@ pub fn bundled_catalog() -> Vec<CatalogProvider> {
}

fn cache_path(data_dir: &Path) -> PathBuf {
data_dir.join("cache").join("opencode").join("models-dev.json")
// v2: cache_read / cache_write joined the slim shape. A new filename so
// an existing 24h cache is not served without those fields until it ages
// out, which would leave cache tokens unpriced on Token Usage.
data_dir
.join("cache")
.join("opencode")
.join("models-dev-v2.json")
}

fn read_cache(data_dir: &Path, require_fresh: bool) -> Option<Vec<CatalogProvider>> {
Expand Down Expand Up @@ -303,7 +321,17 @@ mod tests {
"reasoning": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 },
"cost": { "input": 1.5, "output": 6.0 }
"cost": {
"input": 1.5,
"output": 6.0,
"cache_read": 0.15,
"cache_write": 1.875
}
},
"demo-free": {
"id": "demo-free",
"name": "Demo Free",
"cost": { "input": 0.0, "output": 0.0 }
}
}
}
Expand All @@ -316,14 +344,27 @@ mod tests {
assert_eq!(p.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
assert_eq!(p.env, vec!["DEMO_API_KEY".to_string()]);
assert_eq!(p.auth_kind, "api");
assert_eq!(p.models.len(), 1);
let m = &p.models[0];
assert_eq!(m.id, "demo-large");
assert_eq!(p.models.len(), 2);
let m = p
.models
.iter()
.find(|m| m.id == "demo-large")
.expect("demo-large");
assert!(m.reasoning);
assert!(m.tool_call);
assert_eq!(m.context, Some(128000));
assert_eq!(m.cost_in, Some(1.5));
assert_eq!(m.cost_out, Some(6.0));
assert_eq!(m.cost_cache_read, Some(0.15));
assert_eq!(m.cost_cache_write, Some(1.875));
let free = p
.models
.iter()
.find(|m| m.id == "demo-free")
.expect("demo-free");
assert_eq!(free.cost_in, Some(0.0));
assert_eq!(free.cost_cache_read, None);
assert_eq!(free.cost_cache_write, None);
}

#[test]
Expand Down
78 changes: 70 additions & 8 deletions src/components/token-usage/token-usage-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { WorkbenchPageTitle } from "@/components/workbench/workbench-page-title"
import { FolderAliasLabel } from "@/components/conversations/folder-alias-label"
import { formatFolderLabelWithAlias } from "@/lib/folder-display"
import {
opencodeProviderCatalog,
tokenUsageFacets,
tokenUsageReport,
tokenUsageStatus,
Expand All @@ -65,6 +66,7 @@ import {
foldBreakdown,
formatDuration,
formatTokensPrecise,
formatUsd,
freshTokens,
idleDays,
localTzOffsetMinutes,
Expand All @@ -75,9 +77,16 @@ import {
suggestBucket,
type TokenUsageRangePreset,
} from "@/lib/token-usage"
import {
buildRateIndex,
estimateItemCost,
estimateReportCost,
resolveRate,
} from "@/lib/model-api-rates"
import { cn } from "@/lib/utils"
import type {
AgentType,
OpenCodeCatalogProvider,
TokenUsageBucket,
TokenUsageFacets,
TokenUsageReport,
Expand Down Expand Up @@ -328,6 +337,7 @@ export function TokenUsagePage() {
const [facets, setFacets] = useState<TokenUsageFacets | null>(null)
const [status, setStatus] = useState<TokenUsageSyncStatus | null>(null)
const [report, setReport] = useState<TokenUsageReport | null>(null)
const [catalog, setCatalog] = useState<OpenCodeCatalogProvider[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [syncing, setSyncing] = useState(false)
Expand Down Expand Up @@ -432,6 +442,23 @@ export function TokenUsagePage() {
void load()
}, [load])

// Independent of the usage report: the same 24h models.dev catalog the
// OpenCode settings page already loads. Failures stay quiet and hide the
// dollar line instead of blocking the token counts.
useEffect(() => {
let cancelled = false
void opencodeProviderCatalog()
.then((list) => {
if (!cancelled) setCatalog(list)
})
.catch(() => {
if (!cancelled) setCatalog([])
})
return () => {
cancelled = true
}
}, [])

// Latest-ref so the event subscription below is set up once, not torn down
// and re-established on every filter change (`load`'s identity tracks the
// filters). Same idiom as tasks-view-context.
Expand Down Expand Up @@ -561,6 +588,15 @@ export function TokenUsagePage() {

const totals = report?.totals
const cache = totals ? cacheHitRate(totals) : null
const rateIndex = useMemo(
() => (catalog && catalog.length > 0 ? buildRateIndex(catalog) : null),
[catalog]
)
const apiEstimate = useMemo(() => {
if (!report || !rateIndex) return null
const estimate = estimateReportCost(report.by_model, rateIndex)
return estimate.coverage > 0 && estimate.usd > 0 ? estimate : null
}, [report, rateIndex])
const heat = useMemo(
() => buildHeatMatrix(report?.heatmap ?? []),
[report?.heatmap]
Expand Down Expand Up @@ -687,13 +723,23 @@ export function TokenUsagePage() {
const { shown, other } = foldBreakdown(breakdownItems, BREAKDOWN_LIMIT)
const share = (v: number) =>
totals.total_tokens > 0 ? v / totals.total_tokens : null
const rows: RankedDatum[] = shown.map((it) => ({
key: it.key,
label: breakdownLabel(it.key, it.label),
value: it.total_tokens,
share: share(it.total_tokens),
hint: t("sessionsCount", { count: it.conversation_count }),
}))
const rows: RankedDatum[] = shown.map((it) => {
const sessions = t("sessionsCount", { count: it.conversation_count })
let hint = sessions
if (dim === "model" && rateIndex) {
const cost = estimateItemCost(it, resolveRate(rateIndex, it.key))
if (cost.usd != null && cost.usd > 0) {
hint = `${sessions} · ${formatUsd(cost.usd)}`
}
}
return {
key: it.key,
label: breakdownLabel(it.key, it.label),
value: it.total_tokens,
share: share(it.total_tokens),
hint,
}
})
if (other) {
rows.push({
key: other.key,
Expand All @@ -705,7 +751,7 @@ export function TokenUsagePage() {
})
}
return rows
}, [breakdownItems, breakdownLabel, totals, t])
}, [breakdownItems, breakdownLabel, totals, t, dim, rateIndex])

const onBreakdownSelect = useCallback(
(key: string) => {
Expand Down Expand Up @@ -1106,6 +1152,22 @@ export function TokenUsagePage() {
/>
)}
</div>
{apiEstimate && (
<div className="mt-2 text-sm text-muted-foreground">
<span title={t("apiListEstimateHint")}>
{apiEstimate.coverage < 0.99
? t("apiListEstimatePartial", {
amount: formatUsd(apiEstimate.usd),
percent: Math.floor(
apiEstimate.coverage * 100
),
})
: t("apiListEstimate", {
amount: formatUsd(apiEstimate.usd),
})}
</span>
</div>
)}
{archetype && totals.total_tokens > 0 && (
// The outer wrapper owns the spacing: mt-auto pins
// the strip to the card's bottom edge on wide
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "يقرأ codeg أعداد الرموز مباشرة من سجل كل وكيل. حدّث لتُحسب الجلسات الموجودة على هذا الجهاز.",
"emptyAction": "احسب جلساتي",
"loadFailed": "تعذّر تحميل بيانات الاستهلاك",
"truncatedNotice": "هذا النطاق واسع جدًا — الأرقام تغطي أحدث جزء منه فقط."
"truncatedNotice": "هذا النطاق واسع جدًا — الأرقام تغطي أحدث جزء منه فقط.",
"apiListEstimate": "≈ {amount} قائمة API",
"apiListEstimatePartial": "≈ {amount} قائمة API · {percent}٪ من الرموز",
"apiListEstimateHint": "أسعار قائمة API العامة من models.dev، وتشمل قراءة وكتابة الذاكرة المؤقتة. خطط الاشتراك تُحاسب بشكل مختلف."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "Codeg liest die Token-Zahlen direkt aus dem Protokoll jedes Agenten. Aktualisiere, um zu zählen, was schon auf diesem Rechner liegt.",
"emptyAction": "Meine Sitzungen zählen",
"loadFailed": "Verbrauch konnte nicht geladen werden",
"truncatedNotice": "Dieser Zeitraum ist sehr groß — die Zahlen decken nur seinen jüngsten Abschnitt ab."
"truncatedNotice": "Dieser Zeitraum ist sehr groß — die Zahlen decken nur seinen jüngsten Abschnitt ab.",
"apiListEstimate": "≈ {amount} API-Liste",
"apiListEstimatePartial": "≈ {amount} API-Liste · {percent}% der Tokens",
"apiListEstimateHint": "Öffentliche API-Listenpreise von models.dev, inklusive Cache-Lesen und -Schreiben. Abo-Tarife rechnen anders ab."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "Codeg reads token counts straight from each agent's own transcript. Run a refresh to count what's already on this machine.",
"emptyAction": "Count my sessions",
"loadFailed": "Could not load usage",
"truncatedNotice": "This range is very large — the numbers cover only the most recent slice of it."
"truncatedNotice": "This range is very large — the numbers cover only the most recent slice of it.",
"apiListEstimate": "≈ {amount} API list",
"apiListEstimatePartial": "≈ {amount} API list · {percent}% of tokens",
"apiListEstimateHint": "Public API list rates from models.dev, including cache reads and writes. Subscription plans bill differently."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "Codeg lee los tokens directamente de la transcripción de cada agente. Actualiza para contabilizar lo que ya hay en esta máquina.",
"emptyAction": "Contabilizar mis sesiones",
"loadFailed": "No se pudo cargar el uso",
"truncatedNotice": "Este periodo es muy amplio: las cifras cubren solo su tramo más reciente."
"truncatedNotice": "Este periodo es muy amplio: las cifras cubren solo su tramo más reciente.",
"apiListEstimate": "≈ {amount} tarifa API",
"apiListEstimatePartial": "≈ {amount} tarifa API · {percent}% de tokens",
"apiListEstimateHint": "Tarifas públicas de API de models.dev, con lecturas y escrituras de caché. Los planes de suscripción facturan distinto."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "Codeg lit les tokens directement dans la transcription de chaque agent. Actualisez pour compter ce qui est déjà sur cette machine.",
"emptyAction": "Compter mes sessions",
"loadFailed": "Impossible de charger la consommation",
"truncatedNotice": "Cette période est très large — les chiffres ne couvrent que sa portion la plus récente."
"truncatedNotice": "Cette période est très large — les chiffres ne couvrent que sa portion la plus récente.",
"apiListEstimate": "≈ {amount} tarif API",
"apiListEstimatePartial": "≈ {amount} tarif API · {percent}% des jetons",
"apiListEstimateHint": "Tarifs publics API issus de models.dev, lectures et écritures de cache comprises. Les abonnements facturent autrement."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "codeg は各エージェント自身のセッション記録から直接トークン数を読み取ります。更新すると、このマシンにある記録を集計します。",
"emptyAction": "セッションを集計",
"loadFailed": "使用量を読み込めませんでした",
"truncatedNotice": "期間が非常に長いため、表示中の数値は直近の一部のみを対象としています。"
"truncatedNotice": "期間が非常に長いため、表示中の数値は直近の一部のみを対象としています。",
"apiListEstimate": "≈ {amount} APIリスト",
"apiListEstimatePartial": "≈ {amount} APIリスト · トークンの {percent}%",
"apiListEstimateHint": "models.dev の公開 API リスト料金です。キャッシュの読み書きを含みます。サブスクリプションの課金とは異なります。"
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "codeg는 각 에이전트의 세션 기록에서 토큰 수를 직접 읽습니다. 새로 고침하면 이 컴퓨터에 있는 기록을 집계합니다.",
"emptyAction": "내 세션 집계",
"loadFailed": "사용량을 불러오지 못했습니다",
"truncatedNotice": "기간이 매우 길어 표시된 수치는 가장 최근 구간만 반영합니다."
"truncatedNotice": "기간이 매우 길어 표시된 수치는 가장 최근 구간만 반영합니다.",
"apiListEstimate": "≈ {amount} API 정가",
"apiListEstimatePartial": "≈ {amount} API 정가 · 토큰 {percent}%",
"apiListEstimateHint": "models.dev의 공개 API 정가이며 캐시 읽기/쓰기를 포함합니다. 구독 요금과는 다릅니다."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "O codeg lê os tokens direto da transcrição de cada agente. Atualize para contabilizar o que já existe nesta máquina.",
"emptyAction": "Contabilizar minhas sessões",
"loadFailed": "Não foi possível carregar o uso",
"truncatedNotice": "Este período é muito amplo — os números cobrem apenas o trecho mais recente."
"truncatedNotice": "Este período é muito amplo — os números cobrem apenas o trecho mais recente.",
"apiListEstimate": "≈ {amount} lista da API",
"apiListEstimatePartial": "≈ {amount} lista da API · {percent}% dos tokens",
"apiListEstimateHint": "Preços públicos da API em models.dev, incluindo leituras e escritas de cache. Planos de assinatura cobram diferente."
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "codeg 直接从各智能体自己的会话记录里读取 Token 数。点一下刷新,把本机已有的会话统计进来。",
"emptyAction": "统计我的会话",
"loadFailed": "用量加载失败",
"truncatedNotice": "该范围过大 —— 下面的数字只覆盖了其中最近的一段。"
"truncatedNotice": "该范围过大 —— 下面的数字只覆盖了其中最近的一段。",
"apiListEstimate": "≈ {amount} API 标价",
"apiListEstimatePartial": "≈ {amount} API 标价 · {percent}% 的 token",
"apiListEstimateHint": "来自 models.dev 的公开 API 标价,含缓存读写。订阅套餐的计费方式不同。"
}
}
5 changes: 4 additions & 1 deletion src/i18n/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -4951,6 +4951,9 @@
"emptyHint": "codeg 直接從各智慧代理自己的工作階段紀錄讀取 Token 數。點一下重新整理,把本機已有的紀錄統計進來。",
"emptyAction": "統計我的工作階段",
"loadFailed": "用量載入失敗",
"truncatedNotice": "此範圍過大 —— 下面的數字只涵蓋其中最近的一段。"
"truncatedNotice": "此範圍過大 —— 下面的數字只涵蓋其中最近的一段。",
"apiListEstimate": "≈ {amount} API 標價",
"apiListEstimatePartial": "≈ {amount} API 標價 · {percent}% 的 token",
"apiListEstimateHint": "來自 models.dev 的公開 API 標價,含快取讀寫。訂閱方案的計費方式不同。"
}
}
Loading
Loading