harness-providers: models.dev catalog + cost display wiring (M3)

- modelsdev.rs: ModelCatalog parses models.dev api.json → ModelInfo keyed by
  (provider, model); 24h file cache at ~/.cache/ai-harness/models.json with a
  baked assets/models-snapshot.json fallback so cost/limits work offline.
  load_cached_or_baked (no network) + refresh/refresh_default_cache (background).
- App: loads the catalog at init (cached-or-baked, never blocks), warms the cache
  in a background task, and passes the session model's pricing into RunConfig.cost
  so session cost accrues for real.
- TUI: AppState tracks session_cost/session_tokens from Session events; status bar
  shows "<tokens> · $<cost>". Snapshots updated.
- 7 modelsdev tests (parse, defaults, unknown-model, baked snapshot, cache TTL,
  cached-vs-baked load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:51:23 +02:00
co-authored by Claude Opus 4.8
parent 2f3dfe2305
commit 9301d387fa
16 changed files with 428 additions and 19 deletions
+20 -4
View File
@@ -19,7 +19,7 @@ use harness_core::tool::ToolRegistry;
use harness_core::types::{
Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId,
};
use harness_providers::{AnthropicProvider, OpenAiProvider, ProviderRegistry};
use harness_providers::{AnthropicProvider, ModelCatalog, OpenAiProvider, ProviderRegistry};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@@ -83,6 +83,7 @@ struct EngineInner {
permissions: Arc<PermissionService>,
tools: ToolRegistry,
providers: ProviderRegistry,
catalog: ModelCatalog,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -97,7 +98,9 @@ impl EngineHandle {
pub fn init(cwd: PathBuf) -> Result<Self, AppError> {
let path = db_path(&cwd);
let store = Store::open(&path)?;
Self::new(cwd, store)
let handle = Self::new(cwd, store)?;
handle.spawn_catalog_refresh();
Ok(handle)
}
/// In-memory store — useful for tests and ephemeral sessions.
@@ -143,6 +146,10 @@ impl EngineHandle {
.join("ai-harness")
.join("tool-output");
// No network at construction: use the cached copy if fresh, else the baked snapshot.
// `init` warms the cache in the background for the next launch.
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
Ok(Self {
inner: Arc::new(EngineInner {
config,
@@ -151,6 +158,7 @@ impl EngineHandle {
permissions,
tools,
providers,
catalog,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -158,6 +166,15 @@ impl EngineHandle {
})
}
/// Fire-and-forget refresh of the models.dev cache so the next launch has current pricing.
fn spawn_catalog_refresh(&self) {
tokio::spawn(async {
if let Err(e) = ModelCatalog::refresh_default_cache().await {
tracing::debug!(error = %e, "models.dev refresh failed; using cached/baked metadata");
}
});
}
pub fn bus(&self) -> EventBus {
self.inner.bus.clone()
}
@@ -254,8 +271,7 @@ impl EngineHandle {
temperature: None,
max_steps: DEFAULT_MAX_STEPS,
instructions: self.inner.config.instructions.clone(),
// Populated from models.dev metadata in the follow-up increment.
cost: None,
cost: Some(self.inner.catalog.cost(provider_id, model_id)),
};
// Reserve the run slot *before* spawning. If we inserted after spawning, a run that