fix(models): read the resolved catalog, and stop the transport seam erasing sourced facts (#6396 items 5 and 11)

Two named defects from the models.dev repair checklist, both about consumers
reading a different set of facts than the rest of the product.

Item 11 — `codewhale model list` built its own `ModelRegistry::default()`,
which is the bundled reviewed seed and nothing else: no models.dev live rows,
no per-provider roster, no cloud overlay. The plural `codewhale models` path
consumes the provider lake. The singular command now reads
`all_catalog_models_for_provider` — the same reader the picker, subagent model
validation and fleet command already use — after the same
`maybe_load_persisted_cache()` hydration the product's own models path
performs. Two crate-root re-exports expose those two items rather than making
the 4,360-line `provider_lake` module public.
`ModelCommand::Resolve`/`Set` keep `ModelRegistry`: they need registry and
config semantics (route hints, rejection metadata, route-preference writes),
not the catalog.

Item 5 — the runtime catalog resolver let the curated transport seam overwrite
whole rows. 108 of the 123 curated transport rows state only provider, id,
endpoint_key and default_for_provider, and `ReviewedTransport::to_offering`
hardcodes `UnknownOrStale` pricing, so inserting them whole erased the limits,
capabilities, canonical identity and price of any models.dev row for the same
`(provider, wire_model_id)` — while the picker kept reading the models.dev row,
so picker and runtime disagreed about the same model. The insert is now
field-aware: the transport row still wins every fact it states (`false` stays
`false`), and genuine absence is filled from the offering already in the map.
This is what `RouteResolver::new` already does for opencode-go — "The transport
seam must not erase already sourced Go limits/prices" — generalized to the
route resolver instead of carved out for one provider. Pricing is deliberately
not filled: opencode-zen has a competing provider-owned rate authority, which
is item 6's call and landed separately in 22a6695ae.

The third site the audit named, `provider_lake.rs:956`'s neighbouring insert,
was already correct: `row.complete_from(lower, Some(base_url))` runs first, and
`complete_facts_from` fills only `None` fields. `CatalogCompiler::compile`'s
bundled→live insert is left alone deliberately: both layers are the same
authority, `complete_from` refuses to complete a `ModelsDevLive` self row by
design (filling a fresh upstream row from the stale seed would re-assert facts
upstream removed, which item 3 forbids), and `compile` has no production caller.

Evidence:
- nextest -p codewhale-config -p codewhale-tui -p codewhale-cli with the
  provider_lake, models_dev_live, pricing, route::conformance, catalog,
  model_command and provider_catalog_live selectors:
    Summary [6.246s] 519 tests run: 519 passed, 0 failed
- nextest -p codewhale-cli -E 'test(model_command) | test(model_set_)':
    5 tests run, 5 passed (proves the new re-exports resolve and the CLI builds)

Still open on #6396: item 9 (context/output budgeting through the runtime),
item 13 (shrink catalog_corrections.json), the `app-server` projection still
building its own registry, the docs that still describe the static registry,
and the installed acceptance.
This commit is contained in:
CodeWhale Bot committed 2026-10-09 05:23:27 -07:00
1 parent c9b8e40991
commit 7a2ec8f7ee
3 files changed
+63 -7

No files matched your search

+10 -6
View File
@@ -5694,19 +5694,23 @@ fn run_model_command(
top_level_provider: Option<ProviderKind>,
resolved_runtime: &ResolvedRuntimeOptions,
) -> Result<()> {
let registry = ModelRegistry::default();
match command {
ModelCommand::List { provider } => {
let filter = model_command_provider_hint(provider, top_level_provider);
for model in registry.list().into_iter().filter(|m| match filter {
Some(p) => m.provider == p,
None => true,
}) {
println!("{} ({})", model.id, model.provider.as_str());
codewhale_tui::maybe_load_persisted_cache();
let providers: &[ProviderKind] = match &filter {
Some(provider) => std::slice::from_ref(provider),
None => ProviderKind::all(),
};
for provider in providers {
for model in codewhale_tui::all_catalog_models_for_provider(*provider) {
println!("{model} ({})", provider.as_str());
}
}
Ok(())
}
ModelCommand::Resolve { model, provider } => {
let registry = ModelRegistry::default();
// Only `model resolve --provider X` is a hypothetical. The
// top-level `--provider` is the route this process is actually on,
// and it is already folded into `resolved_runtime` — treating it as
+2
View File
@@ -73,6 +73,7 @@ mod model_profile;
mod model_relevance;
mod model_routing;
mod models_dev_live;
pub use models_dev_live::maybe_load_persisted_cache;
mod network_policy;
mod notify;
mod oauth;
@@ -86,6 +87,7 @@ mod project_context_cache;
mod prompts;
mod provider_catalog_live;
mod provider_lake;
pub use provider_lake::all_catalog_models_for_provider;
mod provider_readiness;
mod purge;
pub mod reasoning_preference;
+51 -1
View File
@@ -785,6 +785,51 @@ fn offering_key(offering: &ProviderModelOffering) -> (String, String) {
)
}
fn offering_with_absent_facts_filled(
mut offering: ProviderModelOffering,
lower: &ProviderModelOffering,
) -> ProviderModelOffering {
if offering.canonical_model.is_none() {
offering.canonical_model.clone_from(&lower.canonical_model);
}
if offering.limits.context_tokens.is_none() {
offering.limits.context_tokens = lower.limits.context_tokens;
}
if offering.limits.input_tokens.is_none() {
offering.limits.input_tokens = lower.limits.input_tokens;
}
if offering.limits.output_tokens.is_none() {
offering.limits.output_tokens = lower.limits.output_tokens;
}
for (target, source) in [
(
&mut offering.capabilities.attachments,
lower.capabilities.attachments,
),
(
&mut offering.capabilities.image_input,
lower.capabilities.image_input,
),
(
&mut offering.capabilities.reasoning,
lower.capabilities.reasoning,
),
(
&mut offering.capabilities.native_tool_calls,
lower.capabilities.native_tool_calls,
),
(
&mut offering.capabilities.structured_output,
lower.capabilities.structured_output,
),
] {
if *target == codewhale_config::route::CapabilityState::Unknown {
*target = source;
}
}
offering
}
fn row_matches_endpoint_fingerprint(row: &CatalogOffering, fingerprint: &str) -> bool {
matches!(
&row.source,
@@ -903,7 +948,12 @@ pub(crate) fn runtime_catalog_resolver_for_identity(
// Curated transport facts win ordinary Models.dev collisions, exactly as
// in RouteResolver::new(). A fresh exact roster replaces its whole scope.
for offering in bundled_offerings() {
route_offerings.insert(offering_key(&offering), offering);
let key = offering_key(&offering);
let enriched = match route_offerings.get(&key) {
Some(lower) => offering_with_absent_facts_filled(offering, lower),
None => offering,
};
route_offerings.insert(key, enriched);
}
// Keep curated transport identity, applying only fields explicitly signed
// at the lower cloud layer. Hidden rows must not be resurrected here.