Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Demote the bundled Models.dev snapshot to an offline/stale fallback after
live catalog refresh (#4188). ProviderLake precedence is live Models.dev >
bundled seed > legacy hardcoded completion names; pickers, inventory, and
subagent validation stay catalog-backed, and CodeWhale-only providers keep
defaults when Models.dev has no rows.

### Added

- Workflow runs are now durable: every run appends to a
Expand Down
9 changes: 5 additions & 4 deletions crates/config/assets/models_dev.bundled.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"_meta": {
"about": "Bundled, network-free Models.dev-shaped catalog snapshot for CodeWhale (#3385).",
"about": "Offline/stale fallback Models.dev-shaped catalog snapshot for CodeWhale (#3385, demoted by #4188).",
"schema": "Matches crates/config/src/models_dev.rs ModelsDevCatalog ({ models, providers }).",
"source": "Curated from in-repo verified facts, NOT a live models.dev dump. Context windows and max-output are sourced from crates/tui/src/models.rs (context_window_for_model / max_output_tokens_for_model); USD-per-million pricing is sourced from crates/tui/src/pricing.rs. The public models.dev catalog tracks a different (real) model generation than CodeWhale's curated forward-dated model set, so a live transform would disagree with the repo's own model registry and tests. Curated-but-accurate is preferred per the issue.",
"role": "NOT a competing source of truth. Preferred metadata is the live Models.dev catalog published into ProviderLake (#4187). This asset is used only when live/cache rows are unavailable (offline startup, failed refresh, or empty cache).",
"source": "Compact offline seed of verified in-repo defaults (context/output from crates/tui/src/models.rs; USD pricing from crates/tui/src/pricing.rs) for providers CodeWhale ships with. It is intentionally smaller than a full Models.dev dump; live refresh supersedes these rows on (provider, wire_model_id) identity.",
"honesty": "Pricing is intentionally OMITTED where the repo does not publish a trustworthy per-token rate: DeepSeek-native rows (priced via the time-aware DeepSeek table elsewhere, kept UnknownOrStale at the route layer), aggregator-hosted DeepSeek rows (aggregator account terms, not DeepSeek Platform pricing), Anthropic rows (no in-repo per-token table), and Xiaomi MiMo Token-Plan rows (credit/quota based). Absent pricing surfaces as PricingSku::UnknownOrStale, never a fabricated zero.",
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement.",
"coverage": "13 providers, 31 chat offerings."
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
"coverage": "13 providers, 31 chat offerings (offline seed only)."
},
"models": {
"deepseek-v4-pro": {
Expand Down
36 changes: 20 additions & 16 deletions crates/config/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
//! refresh) and live [`ProviderCatalogDelta`]s; the HTTP `/models` fetch layer
//! lives above this module. Nothing here performs I/O or reads credentials.
//!
//! Layering (lowest precedence first):
//! Layering (lowest precedence first; #4188):
//!
//! ```text
//! bundled Models.dev snapshot / built-in seeds
//! < provider live `/models` cache (scoped per provider + base-URL fingerprint)
//! bundled Models.dev snapshot (offline/stale fallback only — not competing truth)
//! < live Models.dev / provider `/models` cache
//! < user / custom overrides (custom endpoints, pinned models, explicit facts)
//! ```
//!
//! After #4187, live Models.dev rows are preferred whenever present. The bundled
//! asset remains so offline startup and failed refreshes still resolve defaults.
//!
//! Invariants preserved from #2608 / #3497:
//! - A catalog row is **not** an executable route. Rows still compile through
//! `RouteResolver` into a `ReadyRouteCandidate` before execution.
Expand Down Expand Up @@ -41,7 +44,8 @@ use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, Wire
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CatalogSource {
/// Bundled, network-free seed (a Models.dev snapshot or built-in defaults).
/// Offline/stale bundled seed (Models.dev-shaped snapshot). Not competing
/// truth — live Models.dev rows override this layer (#4188).
#[default]
Bundled,
/// A provider live `/models` row, scoped to a base-URL fingerprint and the
Expand Down Expand Up @@ -146,15 +150,15 @@ impl CatalogOffering {
}
}

/// The committed, network-free Models.dev-shaped catalog snapshot (#3385).
/// Committed offline/stale Models.dev-shaped catalog snapshot (#3385 / #4188).
///
/// Curated from in-repo verified model facts (context windows / output caps from
/// `crates/tui/src/models.rs`, USD pricing from `crates/tui/src/pricing.rs`)
/// rather than a live models.dev dump, because the public catalog tracks a
/// different real model generation than CodeWhale's curated forward-dated set.
/// This is the default bundled layer feeding [`crate::route::RouteResolver::new`].
/// See the asset's `_meta` block for sourcing and the honesty rule on omitted
/// pricing (`UnknownOrStale`, never a fabricated zero).
/// This is **not** a competing curated source of truth. Preferred metadata comes
/// from the live Models.dev catalog (#4187). The bundled asset is a compact
/// network-free seed of verified in-repo defaults (context/output from
/// `crates/tui/src/models.rs`, USD pricing from `crates/tui/src/pricing.rs`) so
/// [`crate::route::RouteResolver::new`] and pickers still work offline or after
/// a failed refresh. See the asset's `_meta.role` / `_meta.source` and the
/// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero).
pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json");

/// Parse the committed bundled Models.dev snapshot.
Expand All @@ -169,11 +173,11 @@ pub fn bundled_models_dev_catalog() -> ModelsDevCatalog {
.expect("committed bundled Models.dev asset must be valid JSON")
}

/// The bundled-layer [`CatalogOffering`] rows from the committed snapshot.
/// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188).
///
/// This is the real-data source for the default resolver: every text-chat row
/// from [`BUNDLED_MODELS_DEV_JSON`], tagged [`CatalogSource::Bundled`], with
/// honest limits and pricing.
/// Lowest-precedence catalog layer: every text-chat row from
/// [`BUNDLED_MODELS_DEV_JSON`], tagged [`CatalogSource::Bundled`]. Live Models.dev
/// rows override these on `(provider, wire_model_id)` when available.
#[must_use]
pub fn bundled_catalog_offerings() -> Vec<CatalogOffering> {
bundled_offerings_from_models_dev(&bundled_models_dev_catalog())
Expand Down
26 changes: 25 additions & 1 deletion crates/config/src/catalog/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ fn snapshot_feeds_route_resolver_offerings() {
}

// ---------------------------------------------------------------------------
// #3385: the committed bundled Models.dev asset.
// #3385 / #4188: the committed offline/stale bundled Models.dev asset.
// ---------------------------------------------------------------------------

#[test]
Expand All @@ -539,6 +539,30 @@ fn bundled_asset_parses() {
assert_eq!(bundled_models_dev_catalog(), catalog);
}

#[test]
fn bundled_asset_meta_describes_offline_fallback_not_competing_truth() {
// #4188: the asset must document itself as offline/stale fallback, not a
// competing curated source of truth alongside live Models.dev.
let raw: serde_json::Value =
serde_json::from_str(BUNDLED_MODELS_DEV_JSON).expect("bundled JSON");
let meta = raw
.get("_meta")
.and_then(|m| m.as_object())
.expect("_meta object");
let role = meta
.get("role")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
role.to_ascii_lowercase().contains("not a competing"),
"_meta.role must demote the bundled asset: {role}"
);
assert!(
role.to_ascii_lowercase().contains("live"),
"_meta.role must point at live Models.dev preference: {role}"
);
}

#[test]
fn bundled_asset_yields_real_chat_offerings_for_key_models() {
let rows = bundled_catalog_offerings();
Expand Down
9 changes: 5 additions & 4 deletions crates/config/src/model_reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,11 @@ impl ModelReferenceDatabase {
Self::from_offerings(&snapshot.offerings)
}

/// Build from CodeWhale's bundled, network-free catalog snapshot.
/// Build from CodeWhale's offline/stale bundled catalog snapshot (#4188).
///
/// This is the curated reference set every install carries; it needs no
/// credentials or network and is always available.
/// Prefer a live/compiled [`CatalogSnapshot`] when available. The bundled
/// set needs no credentials or network and remains the offline fallback
/// every install carries.
#[must_use]
pub fn bundled() -> Self {
Self::from_offerings(&bundled_catalog_offerings())
Expand Down Expand Up @@ -492,7 +493,7 @@ mod tests {
assert!(!db.is_empty());
assert!(
db.len() >= 20,
"bundled snapshot should carry the curated offerings, got {}",
"bundled offline snapshot should carry seed offerings, got {}",
db.len()
);

Expand Down
8 changes: 8 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Demote the bundled Models.dev snapshot to an offline/stale fallback after
live catalog refresh (#4188). ProviderLake precedence is live Models.dev >
bundled seed > legacy hardcoded completion names; pickers, inventory, and
subagent validation stay catalog-backed, and CodeWhale-only providers keep
defaults when Models.dev has no rows.

### Added

- Workflow runs are now durable: every run appends to a
Expand Down
8 changes: 8 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,14 @@ pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String {
normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string())
}

/// Hardcoded per-provider model id list used **only as a compatibility
/// fallback** (#4188).
///
/// Preferred sources are the live Models.dev catalog and the offline bundled
/// snapshot via [`crate::provider_lake`]. Call this directly only for
/// CodeWhale-only / local providers Models.dev does not represent, or when
/// probing the fallback table in tests. Picker, inventory, and subagent
/// surfaces must go through the provider lake.
#[must_use]
pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {
match provider {
Expand Down
13 changes: 9 additions & 4 deletions crates/tui/src/models_dev_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,8 +588,8 @@ mod tests {
clear_live_snapshot();
}

#[tokio::test]
async fn network_failure_keeps_prior_rows() {
#[test]
fn network_failure_keeps_prior_rows() {
let _lock = lock_test_env();
clear_live_snapshot();
let dir = tempfile::tempdir().expect("tempdir");
Expand All @@ -598,15 +598,20 @@ mod tests {

let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
let count = refresh(true).await.expect("seed from path");

let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let count = rt.block_on(refresh(true)).expect("seed from path");
assert!(count >= 2);

// Point at a dead URL and force network (clear path override).
let _path = EnvVarGuard::remove(ENV_MODELS_DEV_PATH);
let _disable = EnvVarGuard::remove(ENV_DISABLE_FETCH);
let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "http://127.0.0.1:1");

let err = refresh(true).await.expect_err("dead URL");
let err = rt.block_on(refresh(true)).expect_err("dead URL");
assert!(matches!(err, ModelsDevRefreshError::Network(_)));

let together = all_catalog_models_for_provider(ApiProvider::Together);
Expand Down
Loading
Loading