From 58598da3a33ba17a55ea099773197b5f60e879c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:26:59 -0700 Subject: [PATCH] feat(cost): LLM pricing + context-window catalog, pre-fills model registry (#4124) --- .../settings/panels/ModelHealthPanel.tsx | 30 +- .../__tests__/ModelHealthPanel.test.tsx | 15 + .../api/__tests__/aiSettingsApi.test.ts | 20 +- app/src/services/api/aiSettingsApi.ts | 35 +- app/src/utils/tauriCommands/config.ts | 9 + src/openhuman/agent/cost.rs | 20 +- src/openhuman/config/ops/loader.rs | 116 +++- src/openhuman/config/ops_tests.rs | 2 + src/openhuman/config/schema/types.rs | 20 +- src/openhuman/cost/catalog.rs | 554 ++++++++++++++++++ src/openhuman/cost/mod.rs | 5 + src/openhuman/dashboard/ops.rs | 5 + src/openhuman/dashboard/schemas.rs | 20 + src/openhuman/dashboard/types.rs | 15 +- src/openhuman/inference/model_context.rs | 3 + .../config_auth_app_state_connectivity_e2e.rs | 58 ++ 16 files changed, 906 insertions(+), 21 deletions(-) create mode 100644 src/openhuman/cost/catalog.rs diff --git a/app/src/components/settings/panels/ModelHealthPanel.tsx b/app/src/components/settings/panels/ModelHealthPanel.tsx index 0c54b399e..853c3b120 100644 --- a/app/src/components/settings/panels/ModelHealthPanel.tsx +++ b/app/src/components/settings/panels/ModelHealthPanel.tsx @@ -12,7 +12,11 @@ const log = debug('openhuman:model-health'); interface ModelEntry { id: string; provider: string; + /** USD per 1M input tokens (0/absent ⇒ unknown). */ + cost_per_1m_input?: number; cost_per_1m_output: number; + /** Max context window in tokens (0/absent ⇒ unknown). */ + context_window?: number; vision: boolean; quality_score: number | null; hallucination_rate: number | null; @@ -20,6 +24,17 @@ interface ModelEntry { tasks_evaluated: number; } +/** Compact token-count label, e.g. 1_000_000 → "1M", 128_000 → "128K". */ +function formatContextWindow(tokens: number | undefined): string { + if (!tokens) return '—'; + if (tokens >= 1_000_000) { + const m = tokens / 1_000_000; + return `${Number.isInteger(m) ? m : m.toFixed(1)}M`; + } + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`; + return String(tokens); +} + interface HealthConfig { hallucination_threshold: number; min_tasks_for_rating: number; @@ -244,7 +259,14 @@ const ModelHealthPanel = () => {
{m.id}
-
{m.provider}
+
+ {m.provider} + {m.context_window ? ( + + · {formatContextWindow(m.context_window)} ctx + + ) : null} +
{qualityStars(m.quality_score)} @@ -261,7 +283,11 @@ const ModelHealthPanel = () => { '—' )} - ${m.cost_per_1m_output.toFixed(2)} + + {m.cost_per_1m_input + ? `$${m.cost_per_1m_input.toFixed(2)} / $${m.cost_per_1m_output.toFixed(2)}` + : `$${m.cost_per_1m_output.toFixed(2)}`} + {m.agents_using} { expect(screen.getByText('bad-model')).toBeTruthy(); }); + it('shows context window and input/output pricing', async () => { + await mockRpc(MOCK_RESPONSE); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('deepseek-v3')).toBeTruthy(); + }); + // Context window rendered as a compact token-count label next to provider. + expect(screen.getByText(/128K ctx/)).toBeTruthy(); + // Cost cell shows "input / output" when input pricing is present. + expect(screen.getByText(/\$0\.14 \/ \$0\.33/)).toBeTruthy(); + }); + it('shows correct status badges', async () => { await mockRpc(MOCK_RESPONSE); renderWithProviders(); diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 3f08d351c..e16047b14 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -741,7 +741,15 @@ describe('saveAISettings', () => { await saveAISettings(prev, next); const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0]; expect(patch.model_registry).toEqual([ - { id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true }, + { + id: 'my-llava', + provider: 'openai', + cost_per_1m_input: 0, + cost_per_1m_cached_input: 0, + cost_per_1m_output: 0, + context_window: 0, + vision: true, + }, ]); }); @@ -1049,7 +1057,15 @@ describe('model registry vision helpers', () => { it('upsertModelRegistryVision adds, flips, and removes entries', () => { const added = upsertModelRegistryVision([], 'openai', 'my-llava', true); expect(added).toEqual([ - { id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true }, + { + id: 'my-llava', + provider: 'openai', + cost_per_1m_input: 0, + cost_per_1m_cached_input: 0, + cost_per_1m_output: 0, + context_window: 0, + vision: true, + }, ]); // vision:false removes the entry (absence ⇒ no vision). const removed = upsertModelRegistryVision(reg, 'openai', 'gpt-4o', false); diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index 3e4eed9f4..54e55ebb3 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -196,7 +196,15 @@ export function upsertModelRegistryVision( const existing = base.find(e => e.provider === provider && e.id === id); return [ ...without, - { id, provider, cost_per_1m_output: existing?.cost_per_1m_output ?? 0, vision: true }, + { + id, + provider, + cost_per_1m_input: existing?.cost_per_1m_input ?? 0, + cost_per_1m_cached_input: existing?.cost_per_1m_cached_input ?? 0, + cost_per_1m_output: existing?.cost_per_1m_output ?? 0, + context_window: existing?.context_window ?? 0, + vision: true, + }, ]; } @@ -391,10 +399,24 @@ export async function saveAISettings(prev: AISettings, next: AISettings): Promis // Per-model registry (vision flags): any change → send the full list. if (!modelRegistriesEqual(prev.modelRegistry, next.modelRegistry)) { patch.model_registry = next.modelRegistry.map( - ({ id, provider, cost_per_1m_output, vision }) => ({ + ({ id, provider, + cost_per_1m_input, + cost_per_1m_cached_input, cost_per_1m_output, + context_window, + vision, + }) => ({ + id, + provider, + // Preserve catalog-prefilled prices + context window through the + // round-trip; omitting them would let the Rust serde defaults zero + // them out. + cost_per_1m_input: cost_per_1m_input ?? 0, + cost_per_1m_cached_input: cost_per_1m_cached_input ?? 0, + cost_per_1m_output, + context_window: context_window ?? 0, vision, }) ); @@ -415,7 +437,14 @@ function modelRegistriesEqual(a: ModelRegistryEntry[], b: ModelRegistryEntry[]): const bByKey = new Map(b.map(e => [key(e), e])); return a.every(e => { const m = bByKey.get(key(e)); - return !!m && m.vision === e.vision && m.cost_per_1m_output === e.cost_per_1m_output; + return ( + !!m && + m.vision === e.vision && + m.cost_per_1m_output === e.cost_per_1m_output && + (m.cost_per_1m_input ?? 0) === (e.cost_per_1m_input ?? 0) && + (m.cost_per_1m_cached_input ?? 0) === (e.cost_per_1m_cached_input ?? 0) && + (m.context_window ?? 0) === (e.context_window ?? 0) + ); }); } diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 2776ec484..bab210aac 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -57,7 +57,16 @@ export interface CloudProviderCreds { export interface ModelRegistryEntry { id: string; provider: string; + /** USD per 1M input tokens. `0`/absent ⇒ unknown. Pre-filled for known + * vendor models from the Rust pricing catalog (`cost/catalog.rs`). */ + cost_per_1m_input?: number; + /** USD per 1M cached-prefix input tokens. `0`/absent ⇒ unknown. */ + cost_per_1m_cached_input?: number; cost_per_1m_output: number; + /** Max context window in tokens. `0`/absent ⇒ unknown. Pre-filled for known + * vendor models from the Rust pricing catalog. Used to budget prompts and + * trigger compaction — providers differ widely (128K–1M+). */ + context_window?: number; vision: boolean; } diff --git a/src/openhuman/agent/cost.rs b/src/openhuman/agent/cost.rs index 0fc103f77..0a4c5c6ff 100644 --- a/src/openhuman/agent/cost.rs +++ b/src/openhuman/agent/cost.rs @@ -111,13 +111,27 @@ pub const PRICING_TABLE: &[ModelPricing] = &[ /// Look up pricing for a model name, falling back to [`FALLBACK_PRICING`]. /// -/// Matching is exact on the canonical tier name and case-insensitive on -/// concrete vendor names (so `"claude-opus"` still hits the -/// reasoning-tier row when callers pass an underlying model string). +/// Resolution order: +/// 1. Exact match on a canonical OpenHuman tier name (`agentic-v1`, …). +/// 2. The concrete-vendor-model pricing catalog +/// ([`crate::openhuman::cost::catalog`]) — accurate per-model rates for +/// `claude-*`, `gpt-*`, `gemini-*`, `deepseek-*`, `kimi-*`, `qwen-*`, +/// `mistral-*`, including OpenRouter-style `vendor/model` ids. +/// 3. Coarse case-insensitive vendor-name heuristics (so an unrecognised +/// `"…opus…"` string still maps to the reasoning tier). +/// 4. [`FALLBACK_PRICING`]. pub fn lookup_pricing(model: &str) -> ModelPricing { if let Some(row) = PRICING_TABLE.iter().find(|row| row.model == model) { return *row; } + if let Some(price) = crate::openhuman::cost::catalog::lookup(model) { + return ModelPricing { + model: price.model_id, + input_per_mtok_usd: price.input_per_mtok_usd, + cached_input_per_mtok_usd: price.cached_input_per_mtok_usd, + output_per_mtok_usd: price.output_per_mtok_usd, + }; + } let lower = model.to_ascii_lowercase(); let by_tier = |tier: &str| { PRICING_TABLE diff --git a/src/openhuman/config/ops/loader.rs b/src/openhuman/config/ops/loader.rs index 51b21866c..ad2e6021e 100644 --- a/src/openhuman/config/ops/loader.rs +++ b/src/openhuman/config/ops/loader.rs @@ -81,12 +81,52 @@ pub async fn reload_config_snapshot_with_timeout(snapshot: &Config) -> Result 0 { + log::debug!("[config] backfilled pricing on {filled} model_registry entries from catalog"); + } } /// Returns the default workspace directory fallback (~/.openhuman/workspace). @@ -294,7 +334,10 @@ pub fn client_config_json(config: &Config) -> serde_json::Value { serde_json::json!({ "id": m.id, "provider": m.provider, + "cost_per_1m_input": m.cost_per_1m_input, + "cost_per_1m_cached_input": m.cost_per_1m_cached_input, "cost_per_1m_output": m.cost_per_1m_output, + "context_window": m.context_window, "vision": m.vision, }) }) @@ -591,6 +634,67 @@ pub async fn get_data_paths() -> Result, String> { )) } +#[cfg(test)] +mod model_registry_seed_tests { + use super::*; + + #[test] + fn seeds_empty_registry_from_catalog() { + let mut config = Config { + model_registry: Vec::new(), + ..Default::default() + }; + seed_and_enrich_model_registry(&mut config); + assert!( + !config.model_registry.is_empty(), + "empty registry should be seeded from the catalog" + ); + // Every seeded entry carries pricing + a context window. + for entry in &config.model_registry { + assert!(entry.cost_per_1m_input > 0.0, "{}", entry.id); + assert!(entry.cost_per_1m_output > 0.0, "{}", entry.id); + assert!(entry.context_window > 0, "{}", entry.id); + } + } + + #[test] + fn backfills_existing_entries_but_preserves_user_values_and_count() { + let mut config = Config { + model_registry: vec![ + // Known model, missing prices → backfilled. + crate::openhuman::config::schema::ModelRegistryEntry { + id: "claude-opus-4-8".to_string(), + provider: "anthropic".to_string(), + cost_per_1m_output: 99.0, // user override — must survive + vision: true, + ..Default::default() + }, + // Unknown model → left untouched. + crate::openhuman::config::schema::ModelRegistryEntry { + id: "my-byok-model".to_string(), + provider: "custom".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + seed_and_enrich_model_registry(&mut config); + + assert_eq!( + config.model_registry.len(), + 2, + "must not seed when non-empty" + ); + let opus = &config.model_registry[0]; + assert_eq!(opus.cost_per_1m_input, 5.00, "backfilled"); + assert_eq!(opus.context_window, 1_000_000, "backfilled"); + assert_eq!(opus.cost_per_1m_output, 99.0, "user value preserved"); + let byok = &config.model_registry[1]; + assert_eq!(byok.cost_per_1m_input, 0.0, "unknown model untouched"); + assert_eq!(byok.context_window, 0); + } +} + #[cfg(test)] mod loader_io_chain_tests { use super::*; diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index da16ccbe2..fb4a035ab 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -658,6 +658,7 @@ async fn apply_model_settings_replaces_model_registry_when_some_and_keeps_when_n provider: "openai".into(), cost_per_1m_output: 0.0, vision: true, + ..Default::default() }]), ..Default::default() }; @@ -707,6 +708,7 @@ async fn apply_model_settings_trims_model_registry_ids() { provider: "openai".into(), cost_per_1m_output: 0.0, vision: true, + ..Default::default() }]), ..Default::default() }; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index c8ba71ecf..2917210cb 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -36,12 +36,30 @@ pub const DEFAULT_MEMORY_SYNC_INTERVAL_SECS: u64 = 86_400; /// "Manual only" is represented separately by `Some(0)`. See issue #3302. pub const MEMORY_SYNC_INTERVAL_PRESETS_SECS: [u64; 3] = [14_400, 43_200, 86_400]; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct ModelRegistryEntry { pub id: String, pub provider: String, + /// Standard prompt rate, USD per **million input tokens**. Used (together + /// with [`Self::cost_per_1m_output`]) to estimate request cost when the + /// provider doesn't echo an authoritative `charged_amount_usd`. `0.0` means + /// "unknown" — callers fall back to the tier/catalog estimate. Pre-filled + /// for known vendor models from [`crate::openhuman::cost::catalog`]. + #[serde(default)] + pub cost_per_1m_input: f64, + /// Cached-prefix prompt rate, USD per million cached input tokens (KV-cache + /// read hits on supporting backends). `0.0` means "unknown". + #[serde(default)] + pub cost_per_1m_cached_input: f64, + /// Completion rate, USD per **million output tokens**. #[serde(default)] pub cost_per_1m_output: f64, + /// Maximum context window in tokens (published max input). `0` means + /// "unknown". Providers differ widely (128K–1M+); callers use this to + /// budget prompts, trigger compaction, and route work. Pre-filled for known + /// vendor models from [`crate::openhuman::cost::catalog`]. + #[serde(default)] + pub context_window: u32, #[serde(default)] pub vision: bool, } diff --git a/src/openhuman/cost/catalog.rs b/src/openhuman/cost/catalog.rs new file mode 100644 index 000000000..1d2ef2dc9 --- /dev/null +++ b/src/openhuman/cost/catalog.rs @@ -0,0 +1,554 @@ +//! Static pricing + context-window catalog for known LLM models. +//! +//! This is the single source of truth for **pre-filled** per-model metadata of +//! the default models the product can route to: +//! +//! - per-token pricing (input + cached-input + output, USD per million tokens), +//! - the model's **context window** (max input tokens) — different providers +//! ship very different windows, and callers need it to budget prompts, +//! trigger compaction, and route work. +//! +//! It exists so the client can estimate request cost and reason about context +//! limits for any provider, used to: +//! +//! - pre-fill [`crate::openhuman::config::schema::ModelRegistryEntry`] rows so +//! the Model Health dashboard shows real numbers instead of zeros, and +//! - power the fallback estimate in +//! [`crate::openhuman::agent::cost::lookup_pricing`] when a backend doesn't +//! echo an authoritative `charged_amount_usd`. +//! +//! ## Authority & freshness +//! +//! These are **best-effort published values** captured at [`PRICING_AS_OF`]. +//! The provider-reported `charged_amount_usd` always wins for cost when +//! present; the catalog is only a floor estimate. Context windows are the +//! published maximums and may differ from what a given deployment/tier exposes. +//! Prices and windows drift — when a provider changes them or a new default +//! model ships, update the matching row here (and bump [`PRICING_AS_OF`]). The +//! table is intentionally a plain `const` slice with no I/O so it's cheap to +//! consult on every lookup. +//! +//! ## Matching +//! +//! [`lookup`] resolves a concrete model string to a row. It normalises case, +//! strips a leading `vendor/` segment (OpenRouter-style ids like +//! `anthropic/claude-opus-4-8`) and trailing decorations (`:tag`, `@date`, +//! `[1m]`), and finally does a longest-substring match so dated/suffixed ids +//! (`claude-opus-4-8[1m]`, `gpt-5.4-2026-05-01`) still resolve. + +use crate::openhuman::config::schema::ModelRegistryEntry; + +/// Month the published values below were last verified. Bump when refreshing. +pub const PRICING_AS_OF: &str = "2026-06"; + +/// A single model's published per-million-token rates (USD) and context window. +#[derive(Debug, Clone, Copy)] +pub struct ModelPrice { + /// Canonical provider slug, matching the `cloud_providers` type strings + /// (`anthropic`, `openai`, `google`, `deepseek`, `moonshot`, `qwen`, + /// `mistral`). Used as the `provider` field when pre-filling registry rows. + pub provider: &'static str, + /// Canonical, lower-case model id used for matching. Keep these distinctive + /// (no bare `gpt-5`) so substring matching stays unambiguous. + pub model_id: &'static str, + /// USD per million standard (cache-miss) input tokens. + pub input_per_mtok_usd: f64, + /// USD per million cached-prefix input tokens. Best-effort: exact where the + /// provider publishes it, otherwise the provider's typical cache discount. + pub cached_input_per_mtok_usd: f64, + /// USD per million output tokens. + pub output_per_mtok_usd: f64, + /// Maximum context window in tokens (published max input). Providers differ + /// widely (128K–1M+); callers budget prompts / trigger compaction off this. + pub context_window: u32, +} + +/// Published list prices and context windows for the default models the product +/// can route to. +/// +/// Sources (captured [`PRICING_AS_OF`]): vendor pricing/model pages. Anthropic +/// price rows are authoritative (cached = 0.1× input, the documented cache-read +/// rate); other providers' cached rates use the published discount where known +/// and a conservative provider-typical fraction otherwise. Context windows are +/// the published maximums. +pub const KNOWN_MODEL_PRICING: &[ModelPrice] = &[ + // ── Anthropic (authoritative prices; cache read = 0.1× input) ──────────── + ModelPrice { + provider: "anthropic", + model_id: "claude-fable-5", + input_per_mtok_usd: 10.00, + cached_input_per_mtok_usd: 1.00, + output_per_mtok_usd: 50.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-opus-4-8", + input_per_mtok_usd: 5.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 25.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-opus-4-7", + input_per_mtok_usd: 5.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 25.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-opus-4-6", + input_per_mtok_usd: 5.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 25.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-opus-4-5", + input_per_mtok_usd: 5.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 25.00, + context_window: 200_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-sonnet-4-6", + input_per_mtok_usd: 3.00, + cached_input_per_mtok_usd: 0.30, + output_per_mtok_usd: 15.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-sonnet-4-5", + input_per_mtok_usd: 3.00, + cached_input_per_mtok_usd: 0.30, + output_per_mtok_usd: 15.00, + context_window: 200_000, + }, + ModelPrice { + provider: "anthropic", + model_id: "claude-haiku-4-5", + input_per_mtok_usd: 1.00, + cached_input_per_mtok_usd: 0.10, + output_per_mtok_usd: 5.00, + context_window: 200_000, + }, + // ── OpenAI (cache read ≈ 0.25× input — published 75% off) ──────────────── + ModelPrice { + provider: "openai", + model_id: "gpt-5.5", + input_per_mtok_usd: 5.00, + cached_input_per_mtok_usd: 1.25, + output_per_mtok_usd: 30.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "openai", + model_id: "gpt-5.4", + input_per_mtok_usd: 2.50, + cached_input_per_mtok_usd: 0.625, + output_per_mtok_usd: 15.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "openai", + model_id: "gpt-5.4-mini", + input_per_mtok_usd: 0.75, + cached_input_per_mtok_usd: 0.1875, + output_per_mtok_usd: 4.50, + context_window: 400_000, + }, + ModelPrice { + provider: "openai", + model_id: "gpt-5.4-nano", + input_per_mtok_usd: 0.20, + cached_input_per_mtok_usd: 0.05, + output_per_mtok_usd: 1.25, + context_window: 400_000, + }, + ModelPrice { + provider: "openai", + model_id: "gpt-4.1", + input_per_mtok_usd: 2.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 8.00, + context_window: 1_000_000, + }, + ModelPrice { + provider: "openai", + model_id: "gpt-4.1-mini", + input_per_mtok_usd: 0.40, + cached_input_per_mtok_usd: 0.10, + output_per_mtok_usd: 1.60, + context_window: 1_000_000, + }, + ModelPrice { + provider: "openai", + model_id: "o3", + input_per_mtok_usd: 2.00, + cached_input_per_mtok_usd: 0.50, + output_per_mtok_usd: 8.00, + context_window: 200_000, + }, + // ── Google Gemini (cache read ≈ 0.25× input; 1M-token windows) ─────────── + ModelPrice { + provider: "google", + model_id: "gemini-2.5-pro", + input_per_mtok_usd: 1.25, + cached_input_per_mtok_usd: 0.3125, + output_per_mtok_usd: 10.00, + context_window: 1_048_576, + }, + ModelPrice { + provider: "google", + model_id: "gemini-2.5-flash", + input_per_mtok_usd: 0.30, + cached_input_per_mtok_usd: 0.075, + output_per_mtok_usd: 2.50, + context_window: 1_048_576, + }, + ModelPrice { + provider: "google", + model_id: "gemini-2.5-flash-lite", + input_per_mtok_usd: 0.10, + cached_input_per_mtok_usd: 0.025, + output_per_mtok_usd: 0.40, + context_window: 1_048_576, + }, + // ── DeepSeek (cache hit = 0.1× input, published) ───────────────────────── + ModelPrice { + provider: "deepseek", + model_id: "deepseek-chat", + input_per_mtok_usd: 0.14, + cached_input_per_mtok_usd: 0.014, + output_per_mtok_usd: 0.28, + context_window: 128_000, + }, + ModelPrice { + provider: "deepseek", + model_id: "deepseek-reasoner", + input_per_mtok_usd: 0.55, + cached_input_per_mtok_usd: 0.055, + output_per_mtok_usd: 2.19, + context_window: 128_000, + }, + // ── Moonshot Kimi (cache hit published) ────────────────────────────────── + ModelPrice { + provider: "moonshot", + model_id: "kimi-k2.6", + input_per_mtok_usd: 0.95, + cached_input_per_mtok_usd: 0.16, + output_per_mtok_usd: 4.00, + context_window: 256_000, + }, + ModelPrice { + provider: "moonshot", + model_id: "kimi-k2.5", + input_per_mtok_usd: 0.60, + cached_input_per_mtok_usd: 0.10, + output_per_mtok_usd: 3.00, + context_window: 256_000, + }, + // ── Qwen / Alibaba (cache read ≈ 0.1× input) ───────────────────────────── + ModelPrice { + provider: "qwen", + model_id: "qwen3-max", + input_per_mtok_usd: 1.20, + cached_input_per_mtok_usd: 0.12, + output_per_mtok_usd: 6.00, + context_window: 256_000, + }, + ModelPrice { + provider: "qwen", + model_id: "qwen-max", + input_per_mtok_usd: 1.20, + cached_input_per_mtok_usd: 0.12, + output_per_mtok_usd: 6.00, + context_window: 256_000, + }, + ModelPrice { + provider: "qwen", + model_id: "qwen-plus", + input_per_mtok_usd: 0.40, + cached_input_per_mtok_usd: 0.04, + output_per_mtok_usd: 1.20, + context_window: 256_000, + }, + ModelPrice { + provider: "qwen", + model_id: "qwen-flash", + input_per_mtok_usd: 0.05, + cached_input_per_mtok_usd: 0.005, + output_per_mtok_usd: 0.40, + context_window: 256_000, + }, + // ── Mistral (cache read ≈ 0.1× input) ──────────────────────────────────── + ModelPrice { + provider: "mistral", + model_id: "mistral-large", + input_per_mtok_usd: 2.00, + cached_input_per_mtok_usd: 0.20, + output_per_mtok_usd: 6.00, + context_window: 128_000, + }, + ModelPrice { + provider: "mistral", + model_id: "mistral-medium", + input_per_mtok_usd: 0.40, + cached_input_per_mtok_usd: 0.04, + output_per_mtok_usd: 2.00, + context_window: 128_000, + }, + ModelPrice { + provider: "mistral", + model_id: "mistral-small", + input_per_mtok_usd: 0.20, + cached_input_per_mtok_usd: 0.02, + output_per_mtok_usd: 0.60, + context_window: 128_000, + }, + ModelPrice { + provider: "mistral", + model_id: "codestral", + input_per_mtok_usd: 0.30, + cached_input_per_mtok_usd: 0.03, + output_per_mtok_usd: 0.90, + context_window: 256_000, + }, + ModelPrice { + provider: "mistral", + model_id: "ministral-8b", + input_per_mtok_usd: 0.10, + cached_input_per_mtok_usd: 0.01, + output_per_mtok_usd: 0.10, + context_window: 128_000, + }, +]; + +/// Normalise a model string for matching: lower-case, trim, drop a trailing +/// `:tag` / `@date` decoration and a `[...]` suffix. +fn normalize(model: &str) -> String { + let mut s = model.trim().to_ascii_lowercase(); + // Strip a `[1m]`-style context-window suffix. + if let Some(idx) = s.find('[') { + s.truncate(idx); + } + // Strip `:tag` (e.g. ollama-style) and `@date` (Vertex-style) decorations. + for sep in [':', '@'] { + if let Some(idx) = s.find(sep) { + s.truncate(idx); + } + } + s.trim().to_string() +} + +/// Resolve a concrete model string to its catalogued row, if known. +/// +/// Match order: exact canonical id → id with a leading `vendor/` segment +/// stripped → longest canonical id that is a substring of the normalised +/// request (handles dated/suffixed ids). Returns `None` for unknown models — +/// callers should fall back to a tier estimate. +pub fn lookup(model: &str) -> Option<&'static ModelPrice> { + let norm = normalize(model); + if norm.is_empty() { + return None; + } + if let Some(p) = KNOWN_MODEL_PRICING.iter().find(|p| p.model_id == norm) { + return Some(p); + } + let bare = norm.rsplit('/').next().unwrap_or(norm.as_str()); + if let Some(p) = KNOWN_MODEL_PRICING.iter().find(|p| p.model_id == bare) { + return Some(p); + } + KNOWN_MODEL_PRICING + .iter() + .filter(|p| norm.contains(p.model_id) || bare.contains(p.model_id)) + .max_by_key(|p| p.model_id.len()) +} + +/// Published maximum context window (tokens) for a model, if catalogued. +/// +/// Convenience wrapper over [`lookup`] for callers that only need the window +/// to budget prompts / trigger compaction / pick a route. `None` ⇒ unknown. +pub fn context_window(model: &str) -> Option { + lookup(model).map(|p| p.context_window) +} + +/// Build a default registry, one [`ModelRegistryEntry`] per catalogued model +/// with prices and context window pre-filled. Used to seed an empty +/// `config.model_registry`. +pub fn default_registry_entries() -> Vec { + KNOWN_MODEL_PRICING + .iter() + .map(|p| ModelRegistryEntry { + id: p.model_id.to_string(), + provider: p.provider.to_string(), + cost_per_1m_input: p.input_per_mtok_usd, + cost_per_1m_cached_input: p.cached_input_per_mtok_usd, + cost_per_1m_output: p.output_per_mtok_usd, + context_window: p.context_window, + vision: false, + }) + .collect() +} + +/// Pre-fill any **missing** (zero) price or context-window field on a registry +/// entry from the catalog, matching on its `id`. Leaves user-supplied non-zero +/// values and the `vision` flag untouched. Returns `true` when a field was +/// filled in. +pub fn enrich_entry(entry: &mut ModelRegistryEntry) -> bool { + let Some(price) = lookup(&entry.id) else { + return false; + }; + let mut changed = false; + if entry.cost_per_1m_input == 0.0 { + entry.cost_per_1m_input = price.input_per_mtok_usd; + changed = true; + } + if entry.cost_per_1m_cached_input == 0.0 { + entry.cost_per_1m_cached_input = price.cached_input_per_mtok_usd; + changed = true; + } + if entry.cost_per_1m_output == 0.0 { + entry.cost_per_1m_output = price.output_per_mtok_usd; + changed = true; + } + if entry.context_window == 0 { + entry.context_window = price.context_window; + changed = true; + } + changed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_lookup_resolves_canonical_ids() { + let p = lookup("claude-opus-4-8").expect("anthropic row"); + assert_eq!(p.provider, "anthropic"); + assert_eq!(p.input_per_mtok_usd, 5.00); + assert_eq!(p.output_per_mtok_usd, 25.00); + assert_eq!(p.cached_input_per_mtok_usd, 0.50); + assert_eq!(p.context_window, 1_000_000); + } + + #[test] + fn lookup_is_case_insensitive() { + assert_eq!(lookup("GPT-4.1").unwrap().model_id, "gpt-4.1"); + } + + #[test] + fn lookup_strips_vendor_prefix_openrouter_style() { + assert_eq!( + lookup("anthropic/claude-sonnet-4-6").unwrap().model_id, + "claude-sonnet-4-6" + ); + assert_eq!( + lookup("deepseek/deepseek-chat").unwrap().model_id, + "deepseek-chat" + ); + assert_eq!(lookup("qwen/qwen3-max").unwrap().model_id, "qwen3-max"); + } + + #[test] + fn lookup_strips_context_and_tag_decorations() { + assert_eq!( + lookup("claude-opus-4-8[1m]").unwrap().model_id, + "claude-opus-4-8" + ); + assert_eq!(lookup("kimi-k2.6:turbo").unwrap().model_id, "kimi-k2.6"); + assert_eq!( + lookup("claude-opus-4-5@20251101").unwrap().model_id, + "claude-opus-4-5" + ); + } + + #[test] + fn lookup_longest_substring_wins_for_suffixed_ids() { + // A dated/suffixed id should resolve to the most specific row. + assert_eq!( + lookup("gpt-5.4-mini-2026-05-01").unwrap().model_id, + "gpt-5.4-mini" + ); + } + + #[test] + fn lookup_returns_none_for_unknown() { + assert!(lookup("totally-made-up-model").is_none()); + assert!(lookup("").is_none()); + assert!( + lookup("agentic-v1").is_none(), + "abstract tiers aren't vendor models" + ); + } + + #[test] + fn context_window_helper_resolves_known_models() { + assert_eq!(context_window("claude-opus-4-8"), Some(1_000_000)); + assert_eq!(context_window("openai/gpt-4.1-mini"), Some(1_000_000)); + assert_eq!(context_window("deepseek-chat"), Some(128_000)); + assert_eq!(context_window("totally-made-up"), None); + } + + #[test] + fn default_registry_entries_are_fully_populated() { + let entries = default_registry_entries(); + assert_eq!(entries.len(), KNOWN_MODEL_PRICING.len()); + for e in &entries { + assert!(e.cost_per_1m_input > 0.0, "{} missing input price", e.id); + assert!(e.cost_per_1m_output > 0.0, "{} missing output price", e.id); + assert!(e.context_window > 0, "{} missing context window", e.id); + assert!(!e.provider.is_empty()); + } + } + + #[test] + fn enrich_fills_zeros_but_preserves_user_values() { + let mut e = ModelRegistryEntry { + id: "claude-opus-4-8".to_string(), + provider: "anthropic".to_string(), + cost_per_1m_input: 0.0, + cost_per_1m_cached_input: 0.0, + cost_per_1m_output: 99.0, // user override — must survive + context_window: 0, + vision: true, + }; + assert!(enrich_entry(&mut e)); + assert_eq!(e.cost_per_1m_input, 5.00); + assert_eq!(e.cost_per_1m_cached_input, 0.50); + assert_eq!(e.cost_per_1m_output, 99.0, "user value preserved"); + assert_eq!(e.context_window, 1_000_000); + assert!(e.vision, "vision flag untouched"); + } + + #[test] + fn enrich_unknown_model_is_noop() { + let mut e = ModelRegistryEntry { + id: "unknown-model".to_string(), + ..Default::default() + }; + assert!(!enrich_entry(&mut e)); + assert_eq!(e.cost_per_1m_input, 0.0); + assert_eq!(e.context_window, 0); + } + + #[test] + fn every_row_has_sane_values() { + for p in KNOWN_MODEL_PRICING { + assert!(p.input_per_mtok_usd > 0.0, "{}", p.model_id); + assert!(p.output_per_mtok_usd > 0.0, "{}", p.model_id); + assert!(p.context_window > 0, "{}", p.model_id); + assert!( + p.cached_input_per_mtok_usd <= p.input_per_mtok_usd, + "{} cached should not exceed input", + p.model_id + ); + } + } +} diff --git a/src/openhuman/cost/mod.rs b/src/openhuman/cost/mod.rs index df659e346..812e643e7 100644 --- a/src/openhuman/cost/mod.rs +++ b/src/openhuman/cost/mod.rs @@ -1,3 +1,4 @@ +pub mod catalog; mod global; mod rpc; mod schemas; @@ -5,6 +6,10 @@ pub mod tools; pub mod tracker; pub mod types; +pub use catalog::{ + context_window as model_context_window, default_registry_entries, enrich_entry, + lookup as lookup_model_price, ModelPrice, +}; pub use global::{init_global, record_provider_usage, try_global}; pub use schemas::{ all_controller_schemas as all_cost_controller_schemas, diff --git a/src/openhuman/dashboard/ops.rs b/src/openhuman/dashboard/ops.rs index a7fc41edf..cebf8022a 100644 --- a/src/openhuman/dashboard/ops.rs +++ b/src/openhuman/dashboard/ops.rs @@ -28,7 +28,10 @@ pub fn model_health(config: &Config) -> Result, .map(|entry| ModelHealthEntry { id: entry.id.clone(), provider: entry.provider.clone(), + cost_per_1m_input: entry.cost_per_1m_input, + cost_per_1m_cached_input: entry.cost_per_1m_cached_input, cost_per_1m_output: entry.cost_per_1m_output, + context_window: entry.context_window, vision: entry.vision, // Placeholder metrics — see module-level docs. quality_score: None, @@ -70,12 +73,14 @@ mod tests { provider: "SiliconFlow".to_string(), cost_per_1m_output: 0.33, vision: false, + ..Default::default() }, crate::openhuman::config::schema::ModelRegistryEntry { id: "qwen-2.5-8b".to_string(), provider: "OpenRouter".to_string(), cost_per_1m_output: 0.09, vision: true, + ..Default::default() }, ]; cfg diff --git a/src/openhuman/dashboard/schemas.rs b/src/openhuman/dashboard/schemas.rs index 23eaa45f5..dcb12b2fe 100644 --- a/src/openhuman/dashboard/schemas.rs +++ b/src/openhuman/dashboard/schemas.rs @@ -80,12 +80,32 @@ fn model_health_entry_fields() -> Vec { comment: "Provider label, e.g. SiliconFlow, OpenRouter.", required: true, }, + FieldSchema { + name: "cost_per_1m_input", + ty: TypeSchema::F64, + comment: "USD cost per 1M input tokens (0 when unknown). Pre-filled \ + from the pricing catalog for known vendor models.", + required: true, + }, + FieldSchema { + name: "cost_per_1m_cached_input", + ty: TypeSchema::F64, + comment: "USD cost per 1M cached-prefix input tokens (0 when unknown).", + required: true, + }, FieldSchema { name: "cost_per_1m_output", ty: TypeSchema::F64, comment: "USD cost per 1M output tokens from local config.", required: true, }, + FieldSchema { + name: "context_window", + ty: TypeSchema::U64, + comment: "Maximum context window in tokens (0 when unknown). \ + Pre-filled from the pricing catalog for known vendor models.", + required: true, + }, FieldSchema { name: "vision", ty: TypeSchema::Bool, diff --git a/src/openhuman/dashboard/types.rs b/src/openhuman/dashboard/types.rs index 3ff420d0a..3015faa36 100644 --- a/src/openhuman/dashboard/types.rs +++ b/src/openhuman/dashboard/types.rs @@ -5,15 +5,22 @@ use serde::{Deserialize, Serialize}; /// One row in the model health comparison table. /// /// Mirrors the JSON shape consumed by the frontend -/// `ModelHealthPanel` — `id`/`provider`/`cost_per_1m_output`/`vision` -/// come from `Config::model_registry`; the four metric fields are -/// emitted as placeholder (`null` / `0`) values until a local telemetry -/// pipeline is wired in. +/// `ModelHealthPanel` — `id`/`provider`/`cost_per_1m_input`/ +/// `cost_per_1m_cached_input`/`cost_per_1m_output`/`vision` come from +/// `Config::model_registry` (pricing pre-filled from the cost catalog); +/// the four metric fields are emitted as placeholder (`null` / `0`) +/// values until a local telemetry pipeline is wired in. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ModelHealthEntry { pub id: String, pub provider: String, + /// USD per 1M input tokens (`0.0` when unknown). + pub cost_per_1m_input: f64, + /// USD per 1M cached-prefix input tokens (`0.0` when unknown). + pub cost_per_1m_cached_input: f64, pub cost_per_1m_output: f64, + /// Maximum context window in tokens (`0` when unknown). + pub context_window: u32, pub vision: bool, pub quality_score: Option, pub hallucination_rate: Option, diff --git a/src/openhuman/inference/model_context.rs b/src/openhuman/inference/model_context.rs index 519ad13df..de9af34b8 100644 --- a/src/openhuman/inference/model_context.rs +++ b/src/openhuman/inference/model_context.rs @@ -314,12 +314,14 @@ mod tests { provider: "openai".into(), cost_per_1m_output: 0.0, vision: true, + ..Default::default() }, ModelRegistryEntry { id: "text-only".into(), provider: "openai".into(), cost_per_1m_output: 0.0, vision: false, + ..Default::default() }, ]; assert!(model_vision_enabled("my-llava", &config)); @@ -337,6 +339,7 @@ mod tests { provider: "openai".into(), cost_per_1m_output: 0.0, vision: true, + ..Default::default() }]; // `reasoning-v1` is the one vision-capable managed tier; the rest are not. assert!(model_supports_vision("reasoning-v1", &config)); diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 364058173..2c4d1ec03 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2968,6 +2968,64 @@ async fn config_controller_mutations_round_trip_over_json_rpc() { "client config must not echo local API keys: {client_payload}" ); + // The model registry is seeded + price/context-window-enriched from the + // static pricing catalog on load (in-memory), so a fresh workspace surfaces + // real numbers over RPC. Validates the full path: catalog → seed-on-load → + // client_config_json → JSON-RPC. (The earlier update_model_settings call did + // not include `model_registry`, so the seeded registry is left intact.) + let registry = client_payload + .get("model_registry") + .and_then(Value::as_array) + .expect("client config should expose model_registry"); + assert!( + !registry.is_empty(), + "model_registry should be auto-seeded from the pricing catalog: {client_payload}" + ); + let opus = registry + .iter() + .find(|m| m.get("id").and_then(Value::as_str) == Some("claude-opus-4-8")) + .unwrap_or_else(|| panic!("seeded registry should contain claude-opus-4-8: {registry:?}")); + assert_eq!( + opus.get("provider").and_then(Value::as_str), + Some("anthropic") + ); + assert_eq!( + opus.get("cost_per_1m_input").and_then(Value::as_f64), + Some(5.0), + "input price should be pre-filled from the catalog: {opus:?}" + ); + assert_eq!( + opus.get("cost_per_1m_output").and_then(Value::as_f64), + Some(25.0), + "output price should be pre-filled from the catalog: {opus:?}" + ); + assert_eq!( + opus.get("context_window").and_then(Value::as_u64), + Some(1_000_000), + "context window should be pre-filled from the catalog: {opus:?}" + ); + // Every seeded entry carries non-zero pricing + context window — the whole + // point of the catalog pre-fill. + for entry in registry { + let id = entry.get("id").and_then(Value::as_str).unwrap_or(""); + assert!( + entry + .get("cost_per_1m_output") + .and_then(Value::as_f64) + .unwrap_or(0.0) + > 0.0, + "{id} missing output price: {entry:?}" + ); + assert!( + entry + .get("context_window") + .and_then(Value::as_u64) + .unwrap_or(0) + > 0, + "{id} missing context window: {entry:?}" + ); + } + let memory = rpc( &harness.rpc_base, 10_004,