From 1a5a23e785c7e2d5d0da521d55a7ce982fac4560 Mon Sep 17 00:00:00 2001 From: aldia_mechdream Date: Tue, 14 Jul 2026 17:50:20 +0800 Subject: [PATCH] feat: add TelecomJS provider support with configuration and catalog integration --- crates/config/src/lib.rs | 25 +++++ crates/config/src/provider.rs | 19 +++- crates/config/src/provider_defaults.rs | 3 + crates/config/src/provider_kind.rs | 10 +- crates/tui/src/client.rs | 131 ++++++++++++++++++++++++- crates/tui/src/config.rs | 66 ++++++++++++- crates/tui/src/config/models.rs | 2 + crates/tui/src/config_persistence.rs | 1 + crates/tui/src/main.rs | 5 + crates/tui/src/provider_lake.rs | 27 +++++ crates/tui/src/tui/ui.rs | 2 + 11 files changed, 283 insertions(+), 8 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 67fd3ed40..8b5b54c10 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -266,6 +266,16 @@ pub struct ProvidersToml { alias = "grok" )] pub xai: ProviderConfigToml, + /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. + #[serde( + default, + skip_serializing_if = "ProviderConfigToml::is_empty", + alias = "telecom-js", + alias = "telecom_js", + alias = "telecomjs-cn", + alias = "tokenhub" + )] + pub telecomjs: ProviderConfigToml, /// Catch-all table for the dynamic OpenAI-compatible custom provider /// identity (#1519). Arbitrary `[providers.]` tables are handled by /// the tui-side flatten map; this named slot keeps the canonical @@ -374,6 +384,7 @@ impl ProvidersToml { ProviderKind::LongCat => &self.longcat, ProviderKind::Meta => &self.meta, ProviderKind::Xai => &self.xai, + ProviderKind::Telecomjs => &self.telecomjs, ProviderKind::Custom => &self.custom, } } @@ -412,6 +423,7 @@ impl ProvidersToml { ProviderKind::LongCat => &mut self.longcat, ProviderKind::Meta => &mut self.meta, ProviderKind::Xai => &mut self.xai, + ProviderKind::Telecomjs => &mut self.telecomjs, ProviderKind::Custom => &mut self.custom, } } @@ -2265,6 +2277,7 @@ impl ConfigToml { ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(), ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(), ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(), + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(), // The custom provider has no built-in endpoint; fall back to its // descriptor placeholder so the lookup is total. Real custom // routes always supply a configured base_url before this point. @@ -2846,6 +2859,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL, ProviderKind::Meta => DEFAULT_META_MODEL, ProviderKind::Xai => DEFAULT_XAI_MODEL, + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL, // No built-in default model; the registry placeholder keeps this total. ProviderKind::Custom => provider.provider().default_model(), } @@ -2885,6 +2899,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL, ProviderKind::Meta => DEFAULT_META_BASE_URL, ProviderKind::Xai => DEFAULT_XAI_BASE_URL, + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, // No built-in default base URL; the registry placeholder keeps this total. ProviderKind::Custom => provider.provider().default_base_url(), } @@ -4434,6 +4449,8 @@ struct EnvRuntimeOverrides { meta_model: Option, xai_base_url: Option, xai_model: Option, + telecomjs_base_url: Option, + telecomjs_model: Option, } impl EnvRuntimeOverrides { @@ -4700,6 +4717,12 @@ impl EnvRuntimeOverrides { xai_model: std::env::var("XAI_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + telecomjs_model: std::env::var("TELECOMJS_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), } } @@ -4754,6 +4777,7 @@ impl EnvRuntimeOverrides { ProviderKind::LongCat => self.longcat_base_url.clone(), ProviderKind::Meta => self.meta_base_url.clone(), ProviderKind::Xai => self.xai_base_url.clone(), + ProviderKind::Telecomjs => self.telecomjs_base_url.clone(), // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom // provider's base URL comes from its `[providers.]` table. ProviderKind::Custom => None, @@ -4787,6 +4811,7 @@ impl EnvRuntimeOverrides { ProviderKind::LongCat => self.longcat_model.clone(), ProviderKind::Meta => self.meta_model.clone(), ProviderKind::Xai => self.xai_model.clone(), + ProviderKind::Telecomjs => self.telecomjs_model.clone(), _ => None, }?; diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 7517ea8fa..165746a98 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -20,7 +20,8 @@ use super::{ DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, - DEFAULT_STEPFUN_MODEL, DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, + DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, + DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL, @@ -648,6 +649,18 @@ provider!( aliases: ["x-ai", "x_ai", "grok"] ); +provider!( + Telecomjs, + Telecomjs, + "telecomjs", + "Telecom JiangSu", + DEFAULT_TELECOMJS_BASE_URL, + DEFAULT_TELECOMJS_MODEL, + ["TELECOMJS_API_KEY"], + "telecomjs", + aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"] +); + /// User-defined OpenAI-compatible endpoint (#1519). /// /// A single dynamic provider identity for arbitrary `[providers.] @@ -733,9 +746,10 @@ static SAKANA: Sakana = Sakana; static LONGCAT: LongCat = LongCat; static META: Meta = Meta; static XAI: Xai = Xai; +static TELECOMJS: Telecomjs = Telecomjs; static CUSTOM: Custom = Custom; -static PROVIDER_REGISTRY: [&dyn Provider; 33] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 34] = [ &DEEPSEEK, &DEEPSEEK_ANTHROPIC, &NVIDIA_NIM, @@ -768,6 +782,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 33] = [ &LONGCAT, &META, &XAI, + &TELECOMJS, &CUSTOM, ]; diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 8d081cdff..c6263aff9 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -139,3 +139,6 @@ pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1"; // xAI / Grok API-key route defaults pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.5"; pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; +// TelecomJS (Jiangsu Telecom TokenHub) defaults +pub(crate) const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro"; +pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1"; diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 431e04f11..0559139f0 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -116,6 +116,13 @@ pub enum ProviderKind { Meta, #[serde(alias = "x-ai", alias = "x_ai", alias = "grok")] Xai, + /// Jiangsu Telecom TokenHub (OpenAI-compatible). + /// + /// An AI gateway operated by Jiangsu Telecom that speaks the OpenAI Chat + /// Completions wire protocol and serves a broad model catalog; each API key + /// may access a different subset of models. + #[serde(alias = "telecom-js", alias = "telecom_js", alias = "telecomjs-cn", alias = "tokenhub")] + Telecomjs, /// User-defined OpenAI-compatible endpoint (#1519). /// /// A single dynamic identity for arbitrary `[providers.] @@ -127,7 +134,7 @@ pub enum ProviderKind { } impl ProviderKind { - pub const ALL: [Self; 33] = [ + pub const ALL: [Self; 34] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -160,6 +167,7 @@ impl ProviderKind { Self::LongCat, Self::Meta, Self::Xai, + Self::Telecomjs, Self::Custom, ]; diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 7e20dbf95..bb6747a3f 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1269,6 +1269,74 @@ impl DeepSeekClient { openrouter_to_catalog_offering(item, &provider, &fingerprint, fetched_at) }) .collect() + } else if provider == "telecomjs" { + // TelecomJS TokenHub returns bare model IDs (e.g. "glm-5.2", + // "deepseek-v4-flash") that correspond to models also present in the + // bundled catalog under their home providers. Match against the + // bundled catalog (case-insensitive) to inherit metadata (context + // window, reasoning, tool_call, etc.) so the model picker shows + // rich capability information instead of empty rows. + let models = + parse_models_response(&body).map_err(|_| CatalogRefreshError::InvalidResponse)?; + if models.is_empty() { + return Err(CatalogRefreshError::EmptyList); + } + let bundled = codewhale_config::catalog::bundled_catalog_offerings(); + let default_model_id = codewhale_config::ProviderKind::Telecomjs + .provider() + .default_model(); + models + .into_iter() + .map(|model| { + let is_default = model.id == default_model_id; + // Find a bundled offering whose wire_model_id matches + // case-insensitively, regardless of provider. + let bundled_match = bundled.iter().find(|offering| { + offering + .wire_model_id + .eq_ignore_ascii_case(&model.id) + }); + if let Some(matched) = bundled_match { + CatalogOffering { + provider: provider.clone(), + wire_model_id: model.id, + canonical_model: matched.canonical_model.clone(), + endpoint_key: "chat".to_string(), + default_for_provider: is_default, + family: matched.family.clone(), + limit: matched.limit.clone(), + cost: matched.cost.clone(), + modalities: matched.modalities.clone(), + reasoning: matched.reasoning, + tool_call: matched.tool_call, + reasoning_options: matched.reasoning_options.clone(), + source: CatalogSource::Live { + base_url_fingerprint: fingerprint.clone(), + fetched_at, + }, + } + } else { + CatalogOffering { + provider: provider.clone(), + wire_model_id: model.id, + canonical_model: None, + endpoint_key: "chat".to_string(), + default_for_provider: is_default, + family: None, + limit: None, + cost: None, + modalities: None, + reasoning: None, + tool_call: None, + reasoning_options: Vec::new(), + source: CatalogSource::Live { + base_url_fingerprint: fingerprint.clone(), + fetched_at, + }, + } + } + }) + .collect() } else { let models = parse_models_response(&body).map_err(|_| CatalogRefreshError::InvalidResponse)?; @@ -1333,6 +1401,60 @@ impl DeepSeekClient { } } + /// Best-effort background refresh of the active provider's own `/v1/models` + /// catalog, merging results into the provider lake (#3385). + /// + /// Unlike [`models_dev_live::spawn_background_refresh`] (which fetches the + /// cross-provider Models.dev catalog), this calls the provider's own + /// `/v1/models` endpoint and merges the results into the existing live + /// snapshot via [`provider_lake::merge_live_offerings`], preserving rows + /// from other sources. + /// + /// Currently activated for providers whose model list is not covered by the + /// Models.dev catalog (e.g. TelecomJS TokenHub). The refresh is non-fatal: + /// on failure, existing/bundled rows remain available. + pub fn spawn_active_provider_catalog_refresh(config: &Config) { + let provider = config.api_provider(); + // Only refresh for providers that serve their own model list and are + // not already covered by the Models.dev catalog. + if !matches!(provider, ApiProvider::Telecomjs) { + return; + } + + let client = match DeepSeekClient::new(config) { + Ok(client) => client, + Err(err) => { + tracing::debug!( + target: "provider_catalog", + error = %err, + "skipping provider catalog refresh: client creation failed" + ); + return; + } + }; + + tokio::spawn(async move { + match client.fetch_catalog_delta().await { + Ok(delta) => { + let count = delta.offerings.len(); + crate::provider_lake::merge_live_offerings(delta.offerings); + tracing::debug!( + target: "provider_catalog", + offering_count = count, + "provider catalog refresh merged {count} offerings into provider lake" + ); + } + Err(err) => { + tracing::debug!( + target: "provider_catalog", + error = ?err, + "provider catalog refresh failed; keeping existing rows" + ); + } + } + }); + } + /// Generate speech with Xiaomi MiMo TTS models. /// /// The spoken text is placed in an `assistant` message because Xiaomi @@ -1952,7 +2074,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Deepinfra | ApiProvider::Together | ApiProvider::Atlascloud - | ApiProvider::Zai => { + | ApiProvider::Zai + | ApiProvider::Telecomjs => { body["thinking"] = json!({ "type": "disabled" }); } ApiProvider::OpenaiCodex => { @@ -2015,7 +2138,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Sglang | ApiProvider::Volcengine | ApiProvider::Deepinfra - | ApiProvider::Atlascloud => { + | ApiProvider::Atlascloud + | ApiProvider::Telecomjs => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -2106,7 +2230,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Sglang | ApiProvider::Volcengine | ApiProvider::Deepinfra - | ApiProvider::Atlascloud => { + | ApiProvider::Atlascloud + | ApiProvider::Telecomjs => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2ca44bfef..ad29b5613 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -73,6 +73,8 @@ pub enum ApiProvider { LongCat, Meta, Xai, + /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. + Telecomjs, /// User-defined OpenAI-compatible endpoint (#1519). /// /// Selected when `provider = ""` names a `[providers.] @@ -209,6 +211,7 @@ impl ApiProvider { Self::LongCat => "https://longcat.chat/platform", Self::Meta => "https://developer.meta.com/ai/", Self::Xai => "https://console.x.ai/", + Self::Telecomjs => "https://aigw.telecomjs.com/", Self::OpenaiCodex | Self::Sglang | Self::Vllm | Self::Ollama => return None, // Custom endpoints have no canonical credential page; the user // supplies the key via their own `api_key_env`. @@ -224,7 +227,7 @@ impl ApiProvider { /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. - const KIND_LOOKUP: [Option; 34] = [ + const KIND_LOOKUP: [Option; 35] = [ Some(codewhale_config::ProviderKind::Deepseek), None, // DeepseekCN Some(codewhale_config::ProviderKind::DeepseekAnthropic), @@ -258,11 +261,12 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::LongCat), Some(codewhale_config::ProviderKind::Meta), Some(codewhale_config::ProviderKind::Xai), + Some(codewhale_config::ProviderKind::Telecomjs), Some(codewhale_config::ProviderKind::Custom), ]; /// `ProviderKind` discriminant → `ApiProvider` lookup. - const FROM_KIND_LOOKUP: [Self; 33] = [ + const FROM_KIND_LOOKUP: [Self; 34] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -295,6 +299,7 @@ impl ApiProvider { Self::LongCat, Self::Meta, Self::Xai, + Self::Telecomjs, Self::Custom, ]; @@ -1197,6 +1202,19 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati XAI_GROK_4_20_0309_REASONING_MODEL, XAI_GROK_4_20_0309_NON_REASONING_MODEL, ], + ApiProvider::Telecomjs => vec![ + DEFAULT_TELECOMJS_MODEL, + "deepseek-v4-flash", + "DeepSeek-R1", + "qwen3.7-plus", + "qwen3-max", + "glm-5.2", + "glm-5.1", + "GLM-5.0", + "Minimax-M2.5", + "kimi-k2.7-code", + "Doubao-Seed-2.0-Pro", + ], // Custom endpoints expose no built-in completion names; the user // supplies their own model id (#1519). ApiProvider::Custom => Vec::new(), @@ -2660,6 +2678,14 @@ pub struct ProvidersConfig { pub meta: ProviderConfig, #[serde(default, alias = "x-ai", alias = "x_ai", alias = "grok")] pub xai: ProviderConfig, + #[serde( + default, + alias = "telecom-js", + alias = "telecom_js", + alias = "telecomjs-cn", + alias = "tokenhub" + )] + pub telecomjs: ProviderConfig, /// Arbitrary user-named custom providers (#1519). /// /// Captures every `[providers.]` table whose key is not one of the @@ -3088,6 +3114,7 @@ impl Config { ApiProvider::LongCat => &providers.longcat, ApiProvider::Meta => &providers.meta, ApiProvider::Xai => &providers.xai, + ApiProvider::Telecomjs => &providers.telecomjs, // Handled by the name-keyed early return above (#1519). ApiProvider::Custom => unreachable!("custom provider resolved by name above"), }) @@ -3151,6 +3178,7 @@ impl Config { ApiProvider::LongCat => &mut providers.longcat, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, // Handled by the name-keyed early return above (#1519). ApiProvider::Custom => unreachable!("custom provider resolved by name above"), } @@ -3346,6 +3374,7 @@ impl Config { ApiProvider::LongCat => DEFAULT_LONGCAT_MODEL, ApiProvider::Meta => DEFAULT_META_MODEL, ApiProvider::Xai => DEFAULT_XAI_MODEL, + ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL, // Custom endpoints have no built-in default model; pass through the // descriptor placeholder when nothing is configured (#1519). ApiProvider::Custom => codewhale_config::ProviderKind::Custom @@ -3402,6 +3431,7 @@ impl Config { | ApiProvider::LongCat | ApiProvider::Meta | ApiProvider::Xai + | ApiProvider::Telecomjs // Custom reads its base_url from the named `[providers.]` // table (via provider_base), never from the legacy root field. | ApiProvider::Custom => None, @@ -3465,6 +3495,7 @@ impl Config { ApiProvider::LongCat => DEFAULT_LONGCAT_BASE_URL, ApiProvider::Meta => DEFAULT_META_BASE_URL, ApiProvider::Xai => DEFAULT_XAI_BASE_URL, + ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, // No built-in endpoint; descriptor placeholder keeps the // fallback total. A real custom route configures // `[providers.] base_url` which wins above (#1519). @@ -4667,6 +4698,13 @@ fn apply_env_overrides(config: &mut Config) { .xai .base_url = Some(value); } + ApiProvider::Telecomjs => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .base_url = Some(value); + } // Custom resolves to the named `[providers.]` table; route the // override through the name-keyed mutable accessor (#1519). ApiProvider::Custom => { @@ -4865,6 +4903,16 @@ fn apply_env_overrides(config: &mut Config) { .xai .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Telecomjs) + && let Ok(value) = std::env::var("TELECOMJS_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .base_url = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_HTTP_HEADERS") && let Ok(headers) = parse_http_headers(&value) && !headers.is_empty() @@ -4919,6 +4967,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::LongCat => &mut providers.longcat, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, ApiProvider::Custom => providers .custom .entry(custom_key.expect("custom key captured for custom provider")) @@ -5092,6 +5141,16 @@ fn apply_env_overrides(config: &mut Config) { .xai .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Telecomjs) + && let Ok(value) = std::env::var("TELECOMJS_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .model = Some(value); + } if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") .ok() .or_else(|| { @@ -5165,6 +5224,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::LongCat => &mut providers.longcat, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, }; entry.model = Some(value); } @@ -5368,6 +5428,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { | ApiProvider::Huggingface | ApiProvider::Meta | ApiProvider::Xai + | ApiProvider::Telecomjs // Custom OpenAI-compatible endpoints preserve user-supplied model // ids verbatim (#1519); never normalize/rewrite them. | ApiProvider::Custom @@ -5948,6 +6009,7 @@ fn merge_providers( longcat: merge_provider_config(base.longcat, override_cfg.longcat), meta: merge_provider_config(base.meta, override_cfg.meta), xai: merge_provider_config(base.xai, override_cfg.xai), + telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs), custom: merge_custom_providers(base.custom, override_cfg.custom), }), } diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index 4ca57502b..0bcc8b7c9 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -176,3 +176,5 @@ pub const XAI_GROK_COMPOSER_2_5_FAST_MODEL: &str = "grok-composer-2.5-fast"; pub const XAI_GROK_4_20_0309_REASONING_MODEL: &str = "grok-4.20-0309-reasoning"; pub const XAI_GROK_4_20_0309_NON_REASONING_MODEL: &str = "grok-4.20-0309-non-reasoning"; pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; +pub const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro"; +pub const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1"; diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 218209ccd..6136fa181 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -382,6 +382,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::LongCat => Ok("longcat"), ApiProvider::Meta => Ok("meta"), ApiProvider::Xai => Ok("xai"), + ApiProvider::Telecomjs => Ok("telecomjs"), // Custom providers live under a user-chosen `[providers.]` table, // not a fixed key. Persisting base_url through this static-key path is // out of scope for the #1519 constrained slice; users edit the named diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a3695b0a2..bc76263e9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -7217,6 +7217,11 @@ async fn run_interactive( // Failures are quiet: bundled catalog rows always remain available. crate::models_dev_live::maybe_load_persisted_cache(); crate::models_dev_live::spawn_background_refresh(); + // Best-effort per-provider catalog refresh: fetches the active provider's + // own /v1/models endpoint and merges live rows into the provider lake + // alongside the Models.dev snapshot. Currently active for TelecomJS, whose + // model list is not covered by the Models.dev catalog. + crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(&config); // Boot janitors — snapshot prune (7-day default), spillover prune // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d904cf802..aee3c501e 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -48,6 +48,33 @@ pub fn clear_live_snapshot() { } } +/// Merge additional live offerings into the existing live snapshot (#4188). +/// +/// Unlike [`set_live_snapshot`] (which replaces the entire snapshot), this +/// merges new rows by `(provider, wire_model_id)` identity, preserving rows +/// from other sources (e.g. Models.dev) that were already published. This is +/// used by per-provider catalog refreshes (e.g. TelecomJS `/v1/models`) that +/// need to coexist with the cross-provider Models.dev live layer. +pub fn merge_live_offerings(new_offerings: Vec) { + if new_offerings.is_empty() { + return; + } + if let Ok(mut guard) = LIVE_SNAPSHOT.write() { + let existing = guard.take().unwrap_or_default(); + use std::collections::BTreeMap; + let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); + for row in &existing.offerings { + merged.insert((row.provider.clone(), row.wire_model_id.clone()), row.clone()); + } + for row in new_offerings { + merged.insert((row.provider.clone(), row.wire_model_id.clone()), row); + } + *guard = Some(CatalogSnapshot { + offerings: merged.into_values().collect(), + }); + } +} + /// The merged catalog snapshot: live rows override bundled rows on /// `(provider, wire_model_id)` identity (#4188). When no live snapshot is /// present, this is just the offline bundled snapshot. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 495720508..b88a55c80 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -11975,6 +11975,7 @@ fn mirror_saved_api_key_in_config(config: &mut Config, provider: ApiProvider, ap ApiProvider::LongCat => &mut providers.longcat, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, }; entry.api_key = Some(api_key); } @@ -12100,6 +12101,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, ApiProvider::LongCat => &mut providers.longcat, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, }; entry.auth_mode = Some(auth_mode); }