From 4aa8c473bdc60a630207ebf0412ad44623155e48 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Mon, 5 Oct 2026 23:16:49 +0800 Subject: [PATCH 1/3] fix(models): resolve snapshot and variant ids to their reviewed rows A custom gateway serves DeepSeek V4 under snapshot and variant ids the reviewed catalog does not enumerate (`deepseek-v4-pro-0813`, `deepseek-v4-flash-vision`). They denote the same rows as their base ids, but every resolver (context window, max output, reasoning capability) fell through to `None`, so the UI showed the 128K unknown shape. `reviewed_snapshot_model` now normalizes one layer at a time - a trailing `-MMDD` / `-YYYY-MM-DD` stamp, or an explicit `-vision` / `-exp` marker - and accepts a shortening only when the remaining id is an exact reviewed intrinsic row. The compact `-YYYYMMDD` shape stays un-inferred: the sibling-metadata contract rejects it. `model_is_openai_reasoning_family` now requires the resolved target to be an OpenAI reasoning row, so a DeepSeek snapshot is not relabelled as one. --- crates/models/src/lib.rs | 152 +++++++++++++++++++++++++++++++++++---- 1 file changed, 138 insertions(+), 14 deletions(-) diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index cce29969d8..0a36fcf503 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -222,10 +222,13 @@ pub fn effective_muse_wire_id(model: &str) -> &str { #[must_use] pub fn model_is_openai_reasoning_family(model: &str) -> bool { let lower = model.to_ascii_lowercase(); - codewhale_config::catalog::reviewed::bundled_reviewed() - .openai_reasoning_ids - .contains_key(&lower) - || reviewed_snapshot_model(&lower).is_some() + let reviewed = codewhale_config::catalog::reviewed::bundled_reviewed(); + reviewed.openai_reasoning_ids.contains_key(&lower) + // Snapshot resolution spans every reviewed family; the resolved + // target must itself be an OpenAI reasoning row, or a DeepSeek + // snapshot would be relabelled as one. + || reviewed_snapshot_model(&lower) + .is_some_and(|target| reviewed.openai_reasoning_ids.contains_key(target)) } pub fn is_openai_gpt_56_api_model(model_lower: &str) -> bool { @@ -249,6 +252,100 @@ pub fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool { .all(|(idx, byte)| idx == 4 || idx == 7 || byte.is_ascii_digit()) } +/// Resolve a snapshot or variant model id to the reviewed intrinsic row it +/// denotes, without inventing facts for names the catalog does not own. +/// +/// Two documented contracts, applied in order: +/// - the authored `snapshot_prefixes` rows (`gpt-5.5-`); +/// - a trailing date stamp (`-YYYY-MM-DD` or `-MMDD`) or an explicit variant +/// marker (`-vision-exp`, `-vision`, `-exp`), accepted only when stripping +/// it leaves the exact id of a reviewed intrinsic row +/// (`deepseek-v4-pro-0813` denotes `deepseek-v4-pro`; +/// `deepseek-v4-flash-vision` denotes `deepseek-v4-flash`). +/// The compact `-YYYYMMDD` shape is deliberately not inferred: the +/// sibling-metadata contract rejects it. +/// +/// Every layer is resolved against the bundled reviewed catalog only. An +/// unrecognized name still returns `None`: the unknown stays observable. +fn reviewed_snapshot_model(model: &str) -> Option<&'static str> { + let reviewed = codewhale_config::catalog::reviewed::bundled_reviewed(); + let via_authored_contract = |candidate: &str| { + reviewed + .snapshot_prefixes + .iter() + .find_map(|(prefix, target)| { + has_date_snapshot_suffix(candidate, prefix).then_some(target.as_str()) + }) + }; + let mut candidate = model.to_ascii_lowercase(); + for _ in 0..3 { + if let Some(target) = via_authored_contract(&candidate) { + return Some(target); + } + let stripped = strip_snapshot_or_variant_suffix(&candidate)?; + if let Some((key, _)) = reviewed.intrinsic.get_key_value(&stripped) { + return Some(key.as_str()); + } + candidate = stripped; + } + None +} + +/// One snapshot/variant normalization layer, longest marker first. The +/// caller re-resolves the shortened id against the reviewed catalog and +/// never accepts a shortening on its own. +fn strip_snapshot_or_variant_suffix(id: &str) -> Option { + for marker in ["-vision-exp", "-vision", "-exp"] { + if let Some(base) = id.strip_suffix(marker) + && !base.is_empty() + { + return Some(base.to_string()); + } + } + strip_date_stamp(id) +} + +/// Strip one trailing date stamp: `-YYYY-MM-DD` or `-MMDD` (the reviewed +/// snapshot conventions). The compact `-YYYYMMDD` form is deliberately not +/// inferred: the sibling-metadata contract rejects that shape (see +/// `unrecognized_deepseek_models_do_not_inherit_sibling_metadata`). +fn strip_date_stamp(id: &str) -> Option { + // `-YYYY-MM-DD`: a ten-character tail preceded by its own dash. + if id.len() > 11 { + let (head, tail) = id.split_at(id.len() - 10); + if let Some(head) = head.strip_suffix('-') + && !head.is_empty() + && has_date_snapshot_suffix(tail, "") + { + return Some(head.to_string()); + } + } + let (head, tail) = id.rsplit_once('-')?; + if head.is_empty() || tail.is_empty() { + return None; + } + let digits = tail.as_bytes(); + if digits.len() == 4 + && digits.iter().all(u8::is_ascii_digit) + && valid_month_day(&digits[0..2], &digits[2..4]) + { + return Some(head.to_string()); + } + None +} + +/// Validate a two-digit month/day pair (ranges only, no calendar math). +fn valid_month_day(month_digits: &[u8], day_digits: &[u8]) -> bool { + let two = |bytes: &[u8]| -> Option { + (bytes.len() == 2 && bytes.iter().all(u8::is_ascii_digit)) + .then(|| u32::from(bytes[0] - b'0') * 10 + u32::from(bytes[1] - b'0')) + }; + match (two(month_digits), two(day_digits)) { + (Some(month), Some(day)) => (1..=12).contains(&month) && (1..=31).contains(&day), + _ => false, + } +} + /// The context window a model name's `_Nk` suffix advertises, when the /// catalog does not already describe the model (#5441). /// @@ -257,16 +354,6 @@ pub fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool { /// from the name* — a naming convention the serving engine may ignore is not /// a fact about the route, and every surface that shows such a window must /// mark it unverified. -fn reviewed_snapshot_model(model: &str) -> Option<&'static str> { - let lower = model.to_ascii_lowercase(); - codewhale_config::catalog::reviewed::bundled_reviewed() - .snapshot_prefixes - .iter() - .find_map(|(prefix, target)| { - has_date_snapshot_suffix(&lower, prefix).then_some(target.as_str()) - }) -} - #[must_use] pub fn name_suffix_context_window_hint(model: &str) -> Option { if codewhale_config::catalog::reviewed::intrinsic_model(model) @@ -1179,6 +1266,43 @@ mod tests { assert_eq!(context_window_for_model("deepseek-v3.2-2k-preview"), None); } + /// 2026-10-05: custom gateways serve V4 under snapshot and variant ids + /// the reviewed catalog does not enumerate (`deepseek-v4-pro-0813`, + /// `deepseek-v4-flash-vision`). They denote the same rows as their base + /// ids and must inherit the base facts instead of falling back to the + /// 128K unknown shape. + #[test] + fn deepseek_v4_snapshot_and_variant_ids_inherit_reviewed_facts() { + for model in [ + "deepseek-v4-pro-0813", + "DeepSeek-V4-Pro-0813", + "deepseek-v4-pro-2025-08-13", + "deepseek-v4-flash-vision", + "deepseek-v4-flash-vision-0813", + ] { + assert_eq!(context_window_for_model(model), Some(1_000_000), "{model}"); + assert_eq!(max_output_tokens_for_model(model), Some(384_000), "{model}"); + assert!(model_supports_reasoning(model), "{model}"); + } + // A trailing number that is not a recognized date stamp, an + // eight-digit compact stamp, or an unknown base, stays unknown: the + // resolver never invents a row. + for model in [ + "not-a-model-0813", + "deepseek-v4-pro-9913", + "deepseek-v4-pro-08130", + "deepseek-v4-pro-x0813", + "deepseek-v4-pro-20250813", + ] { + assert_eq!(context_window_for_model(model), None, "{model}"); + assert_eq!(model_reasoning_capability(model), None, "{model}"); + } + // Snapshot resolution must not relabel a DeepSeek row as an OpenAI + // reasoning model, while the OpenAI snapshot contract keeps working. + assert!(!model_is_openai_reasoning_family("deepseek-v4-pro-0813")); + assert!(model_is_openai_reasoning_family("gpt-5.5-2026-06-01")); + } + #[test] fn compaction_threshold_scales_with_context_window() { assert_eq!( From a95bbec9ddcbad5958492b645b71953e0c08f88b Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Mon, 5 Oct 2026 23:16:50 +0800 Subject: [PATCH 2/3] feat(tui): probe custom providers' /v1/models for the model picker A custom OpenAI-compatible host (private relay, self-hosted router) is not in the Models.dev snapshot, so its `/model` picker stayed empty even though the chat route already talks to the same endpoint. Custom hosts are now included in the active-provider catalog refresh; the probe stays best-effort and non-fatal, and Baseten's `/models` dialect is still detected at fetch time. --- crates/tui/src/client.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 529333b079..6138412c10 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -3381,8 +3381,10 @@ impl CodewhaleClient { /// Activated for model-list authorities that are not satisfied by the /// cross-provider Models.dev snapshot: OpenRouter, named live gateways, /// and Baseten's account-scoped endpoint (no static snapshot can serve a - /// per-credential roster). Every other custom host is an ordinary - /// provider served by Models.dev plus its configured models (#6289). + /// per-credential roster). Custom OpenAI-compatible hosts are included + /// too: a private relay is not in the Models.dev snapshot, so without a + /// probe its `/model` picker stays empty even though the chat route + /// already talks to the same endpoint (#6289 widened). /// The refresh is non-fatal: on failure, persisted prior rows and static /// seeds remain available with a typed failed receipt. pub fn spawn_active_provider_catalog_refresh(config: &Config) { @@ -3395,10 +3397,11 @@ impl CodewhaleClient { return; }; let provider = identity.provider; - let is_baseten_endpoint = provider == ProviderKind::Custom - && codewhale_config::catalog::endpoint_is_baseten( - &config.base_url_for_route(&identity), - ); + // Custom hosts include Baseten (its `/models` dialect is detected + // at fetch time) and every other OpenAI-compatible gateway. The + // probe is the only way to learn a private roster, and a failed + // probe stays non-fatal. + let is_custom_host = provider == ProviderKind::Custom; if !matches!( provider, ProviderKind::Openrouter @@ -3408,7 +3411,7 @@ impl CodewhaleClient { | ProviderKind::Concentrate | ProviderKind::Codewhale | ProviderKind::Ollama - ) && !is_baseten_endpoint + ) && !is_custom_host { return; } From 9f1cb70521141e5324ae8ae38cbbb3b0477ad920 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Mon, 5 Oct 2026 23:40:50 +0800 Subject: [PATCH 3/3] fix(models,tui): repair the snapshot scan and align catalog gates (review round 1) Review findings from the two independent PR reviewers: - `strip_date_stamp` split at `len - 10` without a char-boundary guard; a multi-byte model id panicked in `split_at` (reproduced locally: `byte index 14 is not a char boundary`). Guard with `is_char_boundary` and pin it with a non-ASCII regression test. - The widened custom-host probe left the picker's freshness receipt behind: `provider_catalog_receipt_for_route` still gated on the old named-gateway set and its comment described the pre-widening behavior. Both the refresh spawn and the receipt now gate on one shared predicate, `provider_catalog_live::provider_owns_live_catalog`. - The refresh comment overclaimed "OpenAI-compatible only"; every custom host is probed, so the wording now says so. --- crates/models/src/lib.rs | 17 +++++++++++++++-- crates/tui/src/client.rs | 19 ++++--------------- crates/tui/src/provider_catalog_live.rs | 18 ++++++++++++++++++ crates/tui/src/tui/model_picker.rs | 15 ++++----------- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index 0a36fcf503..2e2854ee91 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -310,8 +310,10 @@ fn strip_snapshot_or_variant_suffix(id: &str) -> Option { /// inferred: the sibling-metadata contract rejects that shape (see /// `unrecognized_deepseek_models_do_not_inherit_sibling_metadata`). fn strip_date_stamp(id: &str) -> Option { - // `-YYYY-MM-DD`: a ten-character tail preceded by its own dash. - if id.len() > 11 { + // `-YYYY-MM-DD`: a ten-character tail preceded by its own dash. The split + // index is a byte offset, so it must land on a char boundary: a multi-byte + // model id would panic in `split_at` otherwise. + if id.len() > 11 && id.is_char_boundary(id.len() - 10) { let (head, tail) = id.split_at(id.len() - 10); if let Some(head) = head.strip_suffix('-') && !head.is_empty() @@ -1303,6 +1305,17 @@ mod tests { assert!(model_is_openai_reasoning_family("gpt-5.5-2026-06-01")); } + /// Multi-byte model ids must not panic the byte-indexed date scan: the + /// `-YYYY-MM-DD` layer splits at `len - 10`, which need not be a UTF-8 + /// char boundary (review finding from the PR #6 review). + #[test] + fn non_ascii_ids_do_not_panic_the_snapshot_scan() { + for model in ["模型模型模型模型", "ローカルモデル-2026", "モデル-0813"] { + assert_eq!(context_window_for_model(model), None, "{model}"); + assert_eq!(model_reasoning_capability(model), None, "{model}"); + } + } + #[test] fn compaction_threshold_scales_with_context_window() { assert_eq!( diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 6138412c10..58e1823f76 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -3398,21 +3398,10 @@ impl CodewhaleClient { }; let provider = identity.provider; // Custom hosts include Baseten (its `/models` dialect is detected - // at fetch time) and every other OpenAI-compatible gateway. The - // probe is the only way to learn a private roster, and a failed - // probe stays non-fatal. - let is_custom_host = provider == ProviderKind::Custom; - if !matches!( - provider, - ProviderKind::Openrouter - | ProviderKind::Telecomjs - | ProviderKind::Edenai - | ProviderKind::Zenmux - | ProviderKind::Concentrate - | ProviderKind::Codewhale - | ProviderKind::Ollama - ) && !is_custom_host - { + // at fetch time) and every other custom host. A private route is + // the only place its roster exists, and a failed probe stays + // non-fatal. + if !crate::provider_catalog_live::provider_owns_live_catalog(provider) { return; } diff --git a/crates/tui/src/provider_catalog_live.rs b/crates/tui/src/provider_catalog_live.rs index 92ba122b94..b9912e234a 100644 --- a/crates/tui/src/provider_catalog_live.rs +++ b/crates/tui/src/provider_catalog_live.rs @@ -511,6 +511,24 @@ fn storage_provider(kind: ProviderKind, identity: &str) -> String { format!("{}:{}", kind.as_str(), identity.trim()) } +/// Providers whose model list is owned by their own `/v1/models` roster +/// rather than the cross-provider Models.dev snapshot: the named live +/// gateways, plus custom hosts whose private roster no snapshot can serve +/// (#6289 widened). The active-provider refresh and the picker's freshness +/// receipt both gate on this one predicate, so they cannot drift apart. +pub(crate) fn provider_owns_live_catalog(provider: ProviderKind) -> bool { + matches!( + provider, + ProviderKind::Openrouter + | ProviderKind::Telecomjs + | ProviderKind::Edenai + | ProviderKind::Zenmux + | ProviderKind::Concentrate + | ProviderKind::Codewhale + | ProviderKind::Ollama + ) || provider == ProviderKind::Custom +} + /// Whether a catalog scope holds an account-scoped roster that must never be /// shared across credentials (#6289). /// diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index ce944956a3..8ea4125c92 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -2599,17 +2599,10 @@ fn provider_catalog_receipt_for_route( .ok()?; (admitted.provider == provider).then_some(())?; let identity = admitted.key.as_str(); - // A custom route owns its catalog only on Baseten's endpoint, whose - // account-scoped roster no snapshot can serve (#6289). - let owns_provider_catalog = matches!( - provider, - ProviderKind::Openrouter - | ProviderKind::Telecomjs - | ProviderKind::Edenai - | ProviderKind::Zenmux - ) || (provider == ProviderKind::Custom - && codewhale_config::catalog::endpoint_is_baseten(&config.base_url_for_route(&admitted))); - if !owns_provider_catalog { + // One predicate with the active-provider refresh: any route whose roster + // is probed (named live gateways and custom hosts) reports its freshness + // here, so a widened probe cannot leave this receipt behind. + if !crate::provider_catalog_live::provider_owns_live_catalog(provider) { return None; }