From 3be9139527a1187ced2d518810f69b322d1aeb72 Mon Sep 17 00:00:00 2001 From: salema97 Date: Thu, 10 Sep 2026 22:26:29 -0500 Subject: [PATCH 1/5] feat: add Meta Muse Spark provider via Model API key validation Key validation through GET /v1/models; usage has no public REST endpoint so the snapshot reports reachable muse-spark models only. Cost stays unknown, never synthesized. Closes #478 --- .../src/components/providers/providerIcons.ts | 7 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../surfaces/settings/tabs/ProvidersTab.tsx | 1 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/core/provider.rs | 49 +++- rust/src/core/provider_factory.rs | 15 +- rust/src/core/token_accounts.rs | 3 +- rust/src/providers/metaspark/mod.rs | 251 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + rust/src/settings/api_keys.rs | 9 + rust/src/settings/tests.rs | 1 + 11 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 rust/src/providers/metaspark/mod.rs diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 9bd27919c9..5c5fb5d4b6 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -219,6 +219,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, + metaspark: { id: "metaspark", brandColor: "#0467DF", fallbackLetter: "M" }, }; const ALIASES: Record = { @@ -303,6 +304,12 @@ const ALIASES: Record = { "lite-llm": "litellm", "zed ai": "zed", "zed-ai": "zed", + meta: "metaspark", + "meta-spark": "metaspark", + musespark: "metaspark", + "muse-spark": "metaspark", + "muse spark": "metaspark", + "meta muse spark": "metaspark", }; function normalize(id: string): string { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 68b1e758c8..63b0ce4b92 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -32,7 +32,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", + "xai", "zai", "fireworks", "metaspark", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index bd4f589123..e8cbb074a5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -250,6 +250,7 @@ function providerSourceHintShort( case "llmproxy": case "xai": case "fireworks": + case "metaspark": return t("ProviderSourceApiShort"); case "kiro": return t("ProviderSourceKiroEnvShort"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index c74a80ab40..67f910f2f9 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -67,4 +67,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["sub2api", "sub2api"], ["qwencloud", "Qwen Cloud"], ["notion", "Notion AI"], + ["metaspark", "Meta Muse Spark"], ]; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 8a3ba042a9..44d20cf727 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -82,6 +82,7 @@ pub enum ProviderId { Notion, Xai, Fireworks, + MetaSpark, } impl ProviderId { @@ -158,6 +159,7 @@ impl ProviderId { ProviderId::Notion, ProviderId::Xai, ProviderId::Fireworks, + ProviderId::MetaSpark, ] } @@ -200,6 +202,7 @@ impl ProviderId { ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", + ProviderId::MetaSpark => "metaspark", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -277,6 +280,7 @@ impl ProviderId { ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", + ProviderId::MetaSpark => "Meta Muse Spark", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -367,6 +371,7 @@ impl ProviderId { ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, ProviderId::Fireworks => None, + ProviderId::MetaSpark => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -440,6 +445,8 @@ impl ProviderId { "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), + "metaspark" | "meta-spark" | "meta" | "muse-spark" | "musespark" + | "meta muse spark" => Some(ProviderId::MetaSpark), "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), "windsurf" | "codeium" => Some(ProviderId::Windsurf), "manus" => Some(ProviderId::Manus), @@ -737,6 +744,11 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("di", ProviderId::DeepInfra); map.insert("fireworks-ai", ProviderId::Fireworks); map.insert("fw", ProviderId::Fireworks); + map.insert("meta-spark", ProviderId::MetaSpark); + map.insert("meta", ProviderId::MetaSpark); + map.insert("muse-spark", ProviderId::MetaSpark); + map.insert("musespark", ProviderId::MetaSpark); + map.insert("meta muse spark", ProviderId::MetaSpark); map.insert("ai&", ProviderId::AiAnd); map.insert("ai-and", ProviderId::AiAnd); map.insert("codeium", ProviderId::Windsurf); @@ -864,6 +876,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Notion => "#337EA9", ProviderId::Xai => "#8E8E93", ProviderId::Fireworks => "#F25B1C", + ProviderId::MetaSpark => "#0467DF", } } @@ -874,7 +887,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 70); + assert_eq!(all.len(), 71); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Fireworks)); @@ -925,6 +938,7 @@ mod tests { assert!(all.contains(&ProviderId::QwenCloud)); assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); + assert!(all.contains(&ProviderId::MetaSpark)); } #[test] @@ -1142,6 +1156,39 @@ mod tests { ); } + #[test] + fn test_provider_id_metaspark() { + assert_eq!(ProviderId::MetaSpark.cli_name(), "metaspark"); + assert_eq!(ProviderId::MetaSpark.display_name(), "Meta Muse Spark"); + assert_eq!(ProviderId::MetaSpark.cookie_domain(), None); + assert_eq!( + ProviderId::from_cli_name("metaspark"), + Some(ProviderId::MetaSpark) + ); + assert_eq!( + ProviderId::from_cli_name("meta-spark"), + Some(ProviderId::MetaSpark) + ); + assert_eq!( + ProviderId::from_cli_name("meta"), + Some(ProviderId::MetaSpark) + ); + assert_eq!( + ProviderId::from_cli_name("muse-spark"), + Some(ProviderId::MetaSpark) + ); + assert_eq!( + ProviderId::from_cli_name("musespark"), + Some(ProviderId::MetaSpark) + ); + // Display name round-trips (also covered by the generic alias test). + assert_eq!( + ProviderId::from_cli_name("Meta Muse Spark"), + Some(ProviderId::MetaSpark) + ); + assert_eq!(brand_color(ProviderId::MetaSpark), "#0467DF"); + } + #[test] fn test_provider_id_xai() { assert_eq!(ProviderId::Xai.cli_name(), "xai"); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index efba0661f9..cbe5286760 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -14,13 +14,13 @@ use crate::providers::{ DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, DoubaoProvider, ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, - KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MiMoProvider, - MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, - OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, - PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, SakanaProvider, - StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, VertexAIProvider, - WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, - ZenMuxProvider, ZoomMateProvider, + KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, + MetaSparkProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, + NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, + OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, + QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, + VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, + XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -99,6 +99,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Notion => Box::new(NotionProvider::new()), ProviderId::Xai => Box::new(XaiProvider::new()), ProviderId::Fireworks => Box::new(FireworksProvider::new()), + ProviderId::MetaSpark => Box::new(MetaSparkProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index fa68ef5156..16a96571e1 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -351,7 +351,8 @@ impl TokenAccountSupport { | ProviderId::LongCat | ProviderId::Wayfinder | ProviderId::QwenCloud - | ProviderId::Fireworks => None, + | ProviderId::Fireworks + | ProviderId::MetaSpark => None, } } diff --git a/rust/src/providers/metaspark/mod.rs b/rust/src/providers/metaspark/mod.rs new file mode 100644 index 0000000000..8784ab90a7 --- /dev/null +++ b/rust/src/providers/metaspark/mod.rs @@ -0,0 +1,251 @@ +//! Meta Muse Spark provider implementation. +//! +//! Meta's Model API (`https://api.meta.ai/v1`) is OpenAI-compatible and +//! exposes no public billing or usage REST endpoint — usage lives in the web +//! dashboard. This provider therefore validates the API key with +//! `GET /v1/models` and reports the reachable `muse-spark-*` models as an +//! informational snapshot. Cost stays unknown (never synthesized as $0). + +use async_trait::async_trait; +use reqwest::{Client, Url}; +use serde::Deserialize; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; + +const METASPARK_API_BASE: &str = "https://api.meta.ai/v1"; +const METASPARK_CREDENTIAL_TARGET: &str = "codexbar-metaspark"; +const METASPARK_ENV_KEYS: &[&str] = &["MODEL_API_KEY", "META_API_KEY"]; + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + #[serde(default)] + id: Option, +} + +pub struct MetaSparkProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl MetaSparkProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::MetaSpark, + display_name: "Meta Muse Spark", + session_label: "Status", + weekly_label: "Models", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://dev.meta.ai/docs"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + async fn probe_models(&self, api_key: &str) -> Result { + let url = api_base_url() + .join("models") + .map_err(|e| ProviderError::Other(format!("Invalid Meta Muse Spark URL: {e}")))?; + let response = self + .client + .get(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .send() + .await?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED + || response.status() == reqwest::StatusCode::FORBIDDEN + { + return Err(ProviderError::AuthRequired); + } + if !response.status().is_success() { + return Err(ProviderError::Other(format!( + "Meta Muse Spark API returned status {}", + response.status() + ))); + } + + let body = response.text().await.map_err(|e| { + ProviderError::Parse(format!("Could not read Meta Muse Spark models: {e}")) + })?; + Ok(snapshot_from_models(&parse_muse_spark_models(&body)?)) + } +} + +impl Default for MetaSparkProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for MetaSparkProvider { + fn id(&self) -> ProviderId { + ProviderId::MetaSpark + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => { + let api_key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + METASPARK_CREDENTIAL_TARGET, + METASPARK_ENV_KEYS, + )?; + Ok(ProviderFetchResult::new( + self.probe_models(&api_key).await?, + "api", + )) + } + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +fn api_base_url() -> Url { + std::env::var("METASPARK_API_URL") + .ok() + .and_then(|raw| crate::providers::validated_https_url(&raw, "Meta Muse Spark API").ok()) + .unwrap_or_else(|| Url::parse(METASPARK_API_BASE).expect("static Meta URL is valid")) +} + +/// Parse the `muse-spark-*` model ids from a `GET /v1/models` payload. +/// +/// The upstream shape is OpenAI-style (`{"data": [{"id": ...}]}`); entries +/// without an id and non-`muse-spark-*` models are ignored. +fn parse_muse_spark_models(body: &str) -> Result, ProviderError> { + let response: ModelsResponse = serde_json::from_str(body).map_err(|e| { + ProviderError::Parse(format!("Could not parse Meta Muse Spark models: {e}")) + })?; + let mut models: Vec = response + .data + .into_iter() + .filter_map(|entry| entry.id) + .map(|id| id.trim().to_string()) + .filter(|id| id.starts_with("muse-spark-")) + .collect(); + models.sort(); + models.dedup(); + Ok(models) +} + +fn snapshot_from_models(models: &[String]) -> UsageSnapshot { + // No usage percentages exist: Meta publishes no billing/usage REST API, + // so the primary window stays at 0% and carries the reachable model + // list as its note. Cost is left absent (unknown, never $0). + let note = if models.is_empty() { + "Key valid, no muse-spark models listed".to_string() + } else { + format!("Key valid: {}", models.join(", ")) + }; + let primary = RateWindow::with_details(0.0, None, None, Some(note)); + UsageSnapshot::new(primary).with_login_method("Meta Model API") +} + +#[cfg(test)] +mod tests { + use super::*; + + const MODELS_FIXTURE: &str = r#"{ + "object": "list", + "data": [ + {"id": "muse-spark-1.3", "object": "model", "owned_by": "meta"}, + {"id": "muse-spark-1.3-contributor", "object": "model", "owned_by": "meta"}, + {"id": "muse-spark-1.2", "object": "model", "owned_by": "meta"}, + {"id": "muse-spark-1.1", "object": "model", "owned_by": "meta"}, + {"id": "other-model", "object": "model", "owned_by": "meta"}, + {"object": "model", "owned_by": "meta"} + ] + }"#; + + #[test] + fn parses_and_filters_muse_spark_models() { + let models = parse_muse_spark_models(MODELS_FIXTURE).unwrap(); + assert_eq!( + models, + vec![ + "muse-spark-1.1".to_string(), + "muse-spark-1.2".to_string(), + "muse-spark-1.3".to_string(), + "muse-spark-1.3-contributor".to_string(), + ] + ); + } + + #[test] + fn empty_model_list_parses() { + let models = parse_muse_spark_models(r#"{"data": []}"#).unwrap(); + assert!(models.is_empty()); + + let snapshot = snapshot_from_models(&models); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("Key valid, no muse-spark models listed") + ); + } + + #[test] + fn malformed_payload_is_a_parse_error() { + let err = parse_muse_spark_models("not json").unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn snapshot_lists_models_without_cost() { + let snapshot = + snapshot_from_models(&["muse-spark-1.3".to_string(), "muse-spark-1.2".to_string()]); + // Sorted by the parser in practice; this unit path preserves order. + assert_eq!(snapshot.primary.used_percent, 0.0); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("Key valid: muse-spark-1.3, muse-spark-1.2") + ); + assert_eq!(snapshot.login_method.as_deref(), Some("Meta Model API")); + } + + #[test] + fn metadata_matches_descriptor() { + let provider = MetaSparkProvider::new(); + assert_eq!(provider.id(), ProviderId::MetaSpark); + assert_eq!(provider.metadata().display_name, "Meta Muse Spark"); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://dev.meta.ai/docs") + ); + assert_eq!(provider.metadata().status_page_url, None); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::OAuth] + ); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index fda7a6f8c9..19a48e99ea 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -46,6 +46,7 @@ pub mod litellm; pub mod llmproxy; pub mod longcat; pub mod manus; +pub mod metaspark; pub mod mimo; pub mod minimax; pub mod mistral; @@ -119,6 +120,7 @@ pub use litellm::LiteLLMProvider; pub use llmproxy::LLMProxyProvider; pub use longcat::LongCatProvider; pub use manus::ManusProvider; +pub use metaspark::MetaSparkProvider; pub use mimo::MiMoProvider; pub use minimax::{MiniMaxProvider, MiniMaxRegion}; pub use mistral::MistralProvider; diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index ac71ba0dbc..29a5166802 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -568,5 +568,14 @@ pub fn get_api_key_providers() -> Vec { config_file_path: Some("%USERPROFILE%\\.factory\\.env"), dashboard_url: Some("https://app.factory.ai/settings/api-keys"), }, + ProviderConfigInfo { + id: ProviderId::MetaSpark, + name: "Meta Muse Spark", + requires_api_key: true, + api_key_env_var: Some("MODEL_API_KEY / META_API_KEY"), + api_key_help: Some("Create key in Meta Model API dashboard"), + config_file_path: None, + dashboard_url: Some("https://dev.meta.ai/docs"), + }, ] } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 1a468a939f..06e3ac979b 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -498,6 +498,7 @@ fn test_api_key_provider_catalog_includes_token_providers() { ProviderId::Groq, ProviderId::LLMProxy, ProviderId::Xai, + ProviderId::MetaSpark, ] { assert!( providers.iter().any(|provider| provider.id == id), From ecb60a0b6dd304ada2ed330f37e511fe645b4810 Mon Sep 17 00:00:00 2001 From: salema97 Date: Thu, 10 Sep 2026 22:32:51 -0500 Subject: [PATCH 2/5] refactor!: rename metaspark provider to meta Provider identity is now Meta (company) with cli_name 'meta' and display name 'Meta'. Model IDs stay muse-spark-* (muse-spark-1.3 default). Backwards compat: 'metaspark', 'meta-spark', 'muse-spark', 'musespark', 'meta muse spark' CLI aliases, serde alias, legacy 'codexbar-metaspark' keyring target and METASPARK_API_URL env override still resolve. --- .../src/components/providers/providerIcons.ts | 14 ++-- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../surfaces/settings/tabs/ProvidersTab.tsx | 2 +- .../desktop-tauri/src/test/providerCatalog.ts | 2 +- rust/src/core/provider.rs | 58 +++++++------- rust/src/core/provider_factory.rs | 16 ++-- rust/src/core/token_accounts.rs | 2 +- rust/src/providers/{metaspark => meta}/mod.rs | 78 ++++++++++++------- rust/src/providers/mod.rs | 4 +- rust/src/settings/api_keys.rs | 4 +- rust/src/settings/tests.rs | 2 +- 11 files changed, 102 insertions(+), 82 deletions(-) rename rust/src/providers/{metaspark => meta}/mod.rs (77%) diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 5c5fb5d4b6..7d17e60c1d 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -219,7 +219,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, - metaspark: { id: "metaspark", brandColor: "#0467DF", fallbackLetter: "M" }, + meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M" }, }; const ALIASES: Record = { @@ -304,12 +304,12 @@ const ALIASES: Record = { "lite-llm": "litellm", "zed ai": "zed", "zed-ai": "zed", - meta: "metaspark", - "meta-spark": "metaspark", - musespark: "metaspark", - "muse-spark": "metaspark", - "muse spark": "metaspark", - "meta muse spark": "metaspark", + metaspark: "meta", + "meta-spark": "meta", + musespark: "meta", + "muse-spark": "meta", + "muse spark": "meta", + "meta muse spark": "meta", }; function normalize(id: string): string { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 63b0ce4b92..05c4156ce0 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -32,7 +32,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "metaspark", + "xai", "zai", "fireworks", "meta", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index e8cbb074a5..afa1517431 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -250,7 +250,7 @@ function providerSourceHintShort( case "llmproxy": case "xai": case "fireworks": - case "metaspark": + case "meta": return t("ProviderSourceApiShort"); case "kiro": return t("ProviderSourceKiroEnvShort"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 67f910f2f9..3281d8c070 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -67,5 +67,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["sub2api", "sub2api"], ["qwencloud", "Qwen Cloud"], ["notion", "Notion AI"], - ["metaspark", "Meta Muse Spark"], + ["meta", "Meta"], ]; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 44d20cf727..fff25d5e9e 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -82,7 +82,8 @@ pub enum ProviderId { Notion, Xai, Fireworks, - MetaSpark, + #[serde(alias = "metaspark")] + Meta, } impl ProviderId { @@ -159,7 +160,7 @@ impl ProviderId { ProviderId::Notion, ProviderId::Xai, ProviderId::Fireworks, - ProviderId::MetaSpark, + ProviderId::Meta, ] } @@ -202,7 +203,7 @@ impl ProviderId { ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", - ProviderId::MetaSpark => "metaspark", + ProviderId::Meta => "meta", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -280,7 +281,7 @@ impl ProviderId { ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", - ProviderId::MetaSpark => "Meta Muse Spark", + ProviderId::Meta => "Meta", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -371,7 +372,7 @@ impl ProviderId { ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, ProviderId::Fireworks => None, - ProviderId::MetaSpark => None, + ProviderId::Meta => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -445,8 +446,8 @@ impl ProviderId { "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), - "metaspark" | "meta-spark" | "meta" | "muse-spark" | "musespark" - | "meta muse spark" => Some(ProviderId::MetaSpark), + "meta" | "metaspark" | "meta-spark" | "muse-spark" | "musespark" | "muse spark" + | "meta muse spark" => Some(ProviderId::Meta), "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), "windsurf" | "codeium" => Some(ProviderId::Windsurf), "manus" => Some(ProviderId::Manus), @@ -744,11 +745,12 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("di", ProviderId::DeepInfra); map.insert("fireworks-ai", ProviderId::Fireworks); map.insert("fw", ProviderId::Fireworks); - map.insert("meta-spark", ProviderId::MetaSpark); - map.insert("meta", ProviderId::MetaSpark); - map.insert("muse-spark", ProviderId::MetaSpark); - map.insert("musespark", ProviderId::MetaSpark); - map.insert("meta muse spark", ProviderId::MetaSpark); + map.insert("metaspark", ProviderId::Meta); + map.insert("meta-spark", ProviderId::Meta); + map.insert("muse-spark", ProviderId::Meta); + map.insert("musespark", ProviderId::Meta); + map.insert("muse spark", ProviderId::Meta); + map.insert("meta muse spark", ProviderId::Meta); map.insert("ai&", ProviderId::AiAnd); map.insert("ai-and", ProviderId::AiAnd); map.insert("codeium", ProviderId::Windsurf); @@ -876,7 +878,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Notion => "#337EA9", ProviderId::Xai => "#8E8E93", ProviderId::Fireworks => "#F25B1C", - ProviderId::MetaSpark => "#0467DF", + ProviderId::Meta => "#0467DF", } } @@ -938,7 +940,7 @@ mod tests { assert!(all.contains(&ProviderId::QwenCloud)); assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); - assert!(all.contains(&ProviderId::MetaSpark)); + assert!(all.contains(&ProviderId::Meta)); } #[test] @@ -1157,36 +1159,36 @@ mod tests { } #[test] - fn test_provider_id_metaspark() { - assert_eq!(ProviderId::MetaSpark.cli_name(), "metaspark"); - assert_eq!(ProviderId::MetaSpark.display_name(), "Meta Muse Spark"); - assert_eq!(ProviderId::MetaSpark.cookie_domain(), None); + fn test_provider_id_meta() { + assert_eq!(ProviderId::Meta.cli_name(), "meta"); + assert_eq!(ProviderId::Meta.display_name(), "Meta"); + assert_eq!(ProviderId::Meta.cookie_domain(), None); + assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); assert_eq!( ProviderId::from_cli_name("metaspark"), - Some(ProviderId::MetaSpark) + Some(ProviderId::Meta) ); assert_eq!( ProviderId::from_cli_name("meta-spark"), - Some(ProviderId::MetaSpark) - ); - assert_eq!( - ProviderId::from_cli_name("meta"), - Some(ProviderId::MetaSpark) + Some(ProviderId::Meta) ); + assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); assert_eq!( ProviderId::from_cli_name("muse-spark"), - Some(ProviderId::MetaSpark) + Some(ProviderId::Meta) ); assert_eq!( ProviderId::from_cli_name("musespark"), - Some(ProviderId::MetaSpark) + Some(ProviderId::Meta) ); // Display name round-trips (also covered by the generic alias test). + assert_eq!(ProviderId::from_cli_name("Meta"), Some(ProviderId::Meta)); + // Backwards-compat aliases still resolve. assert_eq!( ProviderId::from_cli_name("Meta Muse Spark"), - Some(ProviderId::MetaSpark) + Some(ProviderId::Meta) ); - assert_eq!(brand_color(ProviderId::MetaSpark), "#0467DF"); + assert_eq!(brand_color(ProviderId::Meta), "#0467DF"); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index cbe5286760..093b5cf089 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -14,13 +14,13 @@ use crate::providers::{ DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, DoubaoProvider, ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, - KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, - MetaSparkProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, - NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, - OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, - QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, - VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, - XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, + KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MetaProvider, + MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, + NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, + OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, + SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, + VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, + ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -99,7 +99,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Notion => Box::new(NotionProvider::new()), ProviderId::Xai => Box::new(XaiProvider::new()), ProviderId::Fireworks => Box::new(FireworksProvider::new()), - ProviderId::MetaSpark => Box::new(MetaSparkProvider::new()), + ProviderId::Meta => Box::new(MetaProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 16a96571e1..a8ae172b17 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -352,7 +352,7 @@ impl TokenAccountSupport { | ProviderId::Wayfinder | ProviderId::QwenCloud | ProviderId::Fireworks - | ProviderId::MetaSpark => None, + | ProviderId::Meta => None, } } diff --git a/rust/src/providers/metaspark/mod.rs b/rust/src/providers/meta/mod.rs similarity index 77% rename from rust/src/providers/metaspark/mod.rs rename to rust/src/providers/meta/mod.rs index 8784ab90a7..8863cd3b0b 100644 --- a/rust/src/providers/metaspark/mod.rs +++ b/rust/src/providers/meta/mod.rs @@ -1,4 +1,4 @@ -//! Meta Muse Spark provider implementation. +//! Meta provider implementation. //! //! Meta's Model API (`https://api.meta.ai/v1`) is OpenAI-compatible and //! exposes no public billing or usage REST endpoint — usage lives in the web @@ -15,9 +15,11 @@ use crate::core::{ RateWindow, SourceMode, UsageSnapshot, }; -const METASPARK_API_BASE: &str = "https://api.meta.ai/v1"; -const METASPARK_CREDENTIAL_TARGET: &str = "codexbar-metaspark"; -const METASPARK_ENV_KEYS: &[&str] = &["MODEL_API_KEY", "META_API_KEY"]; +const META_API_BASE: &str = "https://api.meta.ai/v1"; +const META_CREDENTIAL_TARGET: &str = "codexbar-meta"; +/// Previous credential target, kept as a read fallback for existing installs. +const LEGACY_METASPARK_CREDENTIAL_TARGET: &str = "codexbar-metaspark"; +const META_ENV_KEYS: &[&str] = &["MODEL_API_KEY", "META_API_KEY"]; #[derive(Debug, Deserialize)] struct ModelsResponse { @@ -31,17 +33,17 @@ struct ModelEntry { id: Option, } -pub struct MetaSparkProvider { +pub struct MetaProvider { metadata: ProviderMetadata, client: Client, } -impl MetaSparkProvider { +impl MetaProvider { pub fn new() -> Self { Self { metadata: ProviderMetadata { - id: ProviderId::MetaSpark, - display_name: "Meta Muse Spark", + id: ProviderId::Meta, + display_name: "Meta", session_label: "Status", weekly_label: "Models", supports_opus: false, @@ -61,7 +63,7 @@ impl MetaSparkProvider { async fn probe_models(&self, api_key: &str) -> Result { let url = api_base_url() .join("models") - .map_err(|e| ProviderError::Other(format!("Invalid Meta Muse Spark URL: {e}")))?; + .map_err(|e| ProviderError::Other(format!("Invalid Meta API URL: {e}")))?; let response = self .client .get(url) @@ -77,28 +79,29 @@ impl MetaSparkProvider { } if !response.status().is_success() { return Err(ProviderError::Other(format!( - "Meta Muse Spark API returned status {}", + "Meta API returned status {}", response.status() ))); } - let body = response.text().await.map_err(|e| { - ProviderError::Parse(format!("Could not read Meta Muse Spark models: {e}")) - })?; + let body = response + .text() + .await + .map_err(|e| ProviderError::Parse(format!("Could not read Meta models: {e}")))?; Ok(snapshot_from_models(&parse_muse_spark_models(&body)?)) } } -impl Default for MetaSparkProvider { +impl Default for MetaProvider { fn default() -> Self { Self::new() } } #[async_trait] -impl Provider for MetaSparkProvider { +impl Provider for MetaProvider { fn id(&self) -> ProviderId { - ProviderId::MetaSpark + ProviderId::Meta } fn metadata(&self) -> &ProviderMetadata { @@ -108,11 +111,7 @@ impl Provider for MetaSparkProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { - let api_key = crate::providers::resolve_api_key( - ctx.api_key.as_deref(), - METASPARK_CREDENTIAL_TARGET, - METASPARK_ENV_KEYS, - )?; + let api_key = resolve_meta_api_key(ctx.api_key.as_deref())?; Ok(ProviderFetchResult::new( self.probe_models(&api_key).await?, "api", @@ -129,11 +128,31 @@ impl Provider for MetaSparkProvider { } } +/// Resolve the Meta API key, falling back to the legacy `codexbar-metaspark` +/// credential target for existing installs. +fn resolve_meta_api_key(explicit: Option<&str>) -> Result { + match crate::providers::resolve_api_key(explicit, META_CREDENTIAL_TARGET, META_ENV_KEYS) { + Ok(key) => Ok(key), + Err(first_err) => { + if explicit.is_some_and(|key| !key.trim().is_empty()) { + return Err(first_err); + } + crate::providers::resolve_api_key( + None, + LEGACY_METASPARK_CREDENTIAL_TARGET, + META_ENV_KEYS, + ) + .or(Err(first_err)) + } + } +} + fn api_base_url() -> Url { - std::env::var("METASPARK_API_URL") + std::env::var("META_API_URL") + .or_else(|_| std::env::var("METASPARK_API_URL")) .ok() - .and_then(|raw| crate::providers::validated_https_url(&raw, "Meta Muse Spark API").ok()) - .unwrap_or_else(|| Url::parse(METASPARK_API_BASE).expect("static Meta URL is valid")) + .and_then(|raw| crate::providers::validated_https_url(&raw, "Meta API").ok()) + .unwrap_or_else(|| Url::parse(META_API_BASE).expect("static Meta URL is valid")) } /// Parse the `muse-spark-*` model ids from a `GET /v1/models` payload. @@ -141,9 +160,8 @@ fn api_base_url() -> Url { /// The upstream shape is OpenAI-style (`{"data": [{"id": ...}]}`); entries /// without an id and non-`muse-spark-*` models are ignored. fn parse_muse_spark_models(body: &str) -> Result, ProviderError> { - let response: ModelsResponse = serde_json::from_str(body).map_err(|e| { - ProviderError::Parse(format!("Could not parse Meta Muse Spark models: {e}")) - })?; + let response: ModelsResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("Could not parse Meta models: {e}")))?; let mut models: Vec = response .data .into_iter() @@ -233,9 +251,9 @@ mod tests { #[test] fn metadata_matches_descriptor() { - let provider = MetaSparkProvider::new(); - assert_eq!(provider.id(), ProviderId::MetaSpark); - assert_eq!(provider.metadata().display_name, "Meta Muse Spark"); + let provider = MetaProvider::new(); + assert_eq!(provider.id(), ProviderId::Meta); + assert_eq!(provider.metadata().display_name, "Meta"); assert_eq!( provider.metadata().dashboard_url, Some("https://dev.meta.ai/docs") diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 19a48e99ea..1e32aaecd5 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -46,7 +46,7 @@ pub mod litellm; pub mod llmproxy; pub mod longcat; pub mod manus; -pub mod metaspark; +pub mod meta; pub mod mimo; pub mod minimax; pub mod mistral; @@ -120,7 +120,7 @@ pub use litellm::LiteLLMProvider; pub use llmproxy::LLMProxyProvider; pub use longcat::LongCatProvider; pub use manus::ManusProvider; -pub use metaspark::MetaSparkProvider; +pub use meta::MetaProvider; pub use mimo::MiMoProvider; pub use minimax::{MiniMaxProvider, MiniMaxRegion}; pub use mistral::MistralProvider; diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 29a5166802..36402a93e6 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -569,8 +569,8 @@ pub fn get_api_key_providers() -> Vec { dashboard_url: Some("https://app.factory.ai/settings/api-keys"), }, ProviderConfigInfo { - id: ProviderId::MetaSpark, - name: "Meta Muse Spark", + id: ProviderId::Meta, + name: "Meta", requires_api_key: true, api_key_env_var: Some("MODEL_API_KEY / META_API_KEY"), api_key_help: Some("Create key in Meta Model API dashboard"), diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 06e3ac979b..5640285319 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -498,7 +498,7 @@ fn test_api_key_provider_catalog_includes_token_providers() { ProviderId::Groq, ProviderId::LLMProxy, ProviderId::Xai, - ProviderId::MetaSpark, + ProviderId::Meta, ] { assert!( providers.iter().any(|provider| provider.id == id), From c4f84770251630cdfd07d8ad47fc7766b30c403f Mon Sep 17 00:00:00 2001 From: salema97 Date: Thu, 10 Sep 2026 22:35:28 -0500 Subject: [PATCH 3/5] feat: add Meta brand icon for meta provider --- .../src/components/providers/icons/ProviderIcon-meta.svg | 3 +++ apps/desktop-tauri/src/components/providers/providerIcons.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-meta.svg diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-meta.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-meta.svg new file mode 100644 index 0000000000..0b5e76608d --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-meta.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 7d17e60c1d..0b554eba9b 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -38,6 +38,7 @@ import kimi from "./icons/ProviderIcon-kimi.svg?raw"; import kiro from "./icons/ProviderIcon-kiro.svg?raw"; import llmproxy from "./icons/ProviderIcon-llmproxy.svg?raw"; import manus from "./icons/ProviderIcon-manus.svg?raw"; +import meta from "./icons/ProviderIcon-meta.svg?raw"; import mimo from "./icons/ProviderIcon-mimo.svg?raw"; import minimax from "./icons/ProviderIcon-minimax.svg?raw"; import mistral from "./icons/ProviderIcon-mistral.svg?raw"; @@ -120,6 +121,7 @@ const RAW: Record = { kiro: tint(kiro), llmproxy: tint(llmproxy), manus: tint(manus), + meta: tint(meta), mimo: tint(mimo), minimax: tint(minimax), notion: tint(notion), @@ -219,7 +221,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, - meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M" }, + meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M", svgPath: RAW.meta }, }; const ALIASES: Record = { From 58a34ca594386dc4d40609cfce0e06f4d66d53ee Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:42:29 +0700 Subject: [PATCH 4/5] Fix Meta models endpoint and mark snapshot informational --- rust/src/providers/meta/mod.rs | 71 +++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/rust/src/providers/meta/mod.rs b/rust/src/providers/meta/mod.rs index 8863cd3b0b..7959c1ce21 100644 --- a/rust/src/providers/meta/mod.rs +++ b/rust/src/providers/meta/mod.rs @@ -61,9 +61,7 @@ impl MetaProvider { } async fn probe_models(&self, api_key: &str) -> Result { - let url = api_base_url() - .join("models") - .map_err(|e| ProviderError::Other(format!("Invalid Meta API URL: {e}")))?; + let url = models_url(&api_base_url())?; let response = self .client .get(url) @@ -155,6 +153,21 @@ fn api_base_url() -> Url { .unwrap_or_else(|| Url::parse(META_API_BASE).expect("static Meta URL is valid")) } +/// Resolve the `GET /v1/models` endpoint from a configured API base. +/// +/// The base is treated as a directory, so both `https://api.meta.ai/v1` and +/// `https://api.meta.ai/v1/` resolve to `https://api.meta.ai/v1/models` +/// instead of `Url::join` dropping the `v1` segment for the no-trailing-slash +/// form. A bare host resolves to `https://api.meta.ai/models`. +fn models_url(base: &Url) -> Result { + let mut url = base.clone(); + url.path_segments_mut() + .map_err(|_| ProviderError::Other("Meta API URL cannot be used as a base URL".to_string()))? + .pop_if_empty() + .push("models"); + Ok(url) +} + /// Parse the `muse-spark-*` model ids from a `GET /v1/models` payload. /// /// The upstream shape is OpenAI-style (`{"data": [{"id": ...}]}`); entries @@ -177,13 +190,15 @@ fn parse_muse_spark_models(body: &str) -> Result, ProviderError> { fn snapshot_from_models(models: &[String]) -> UsageSnapshot { // No usage percentages exist: Meta publishes no billing/usage REST API, // so the primary window stays at 0% and carries the reachable model - // list as its note. Cost is left absent (unknown, never $0). + // list as its note. It is marked informational so consumers never treat + // the placeholder as real quota usage. Cost is left absent (unknown, + // never $0). let note = if models.is_empty() { "Key valid, no muse-spark models listed".to_string() } else { format!("Key valid: {}", models.join(", ")) }; - let primary = RateWindow::with_details(0.0, None, None, Some(note)); + let primary = RateWindow::informational(note); UsageSnapshot::new(primary).with_login_method("Meta Model API") } @@ -224,6 +239,7 @@ mod tests { let snapshot = snapshot_from_models(&models); assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(snapshot.primary.is_informational); assert_eq!( snapshot.primary.reset_description.as_deref(), Some("Key valid, no muse-spark models listed") @@ -242,11 +258,56 @@ mod tests { snapshot_from_models(&["muse-spark-1.3".to_string(), "muse-spark-1.2".to_string()]); // Sorted by the parser in practice; this unit path preserves order. assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(snapshot.primary.is_informational); assert_eq!( snapshot.primary.reset_description.as_deref(), Some("Key valid: muse-spark-1.3, muse-spark-1.2") ); assert_eq!(snapshot.login_method.as_deref(), Some("Meta Model API")); + + // Cost stays absent through the fetch result: no quota API exists, so + // a synthesized $0 would be a false reading. + let result = ProviderFetchResult::new(snapshot, "api"); + assert!(result.cost.is_none()); + assert!(result.usage.primary.is_informational); + } + + #[test] + fn default_models_url_preserves_v1_path() { + let base = Url::parse(META_API_BASE).unwrap(); + assert_eq!( + models_url(&base).unwrap().as_str(), + "https://api.meta.ai/v1/models" + ); + } + + #[test] + fn models_url_treats_configured_base_as_directory() { + for (base, expected) in [ + ("https://api.meta.ai/v1", "https://api.meta.ai/v1/models"), + ("https://api.meta.ai/v1/", "https://api.meta.ai/v1/models"), + ("https://api.meta.ai", "https://api.meta.ai/models"), + ("https://api.meta.ai/", "https://api.meta.ai/models"), + ( + "https://gateway.example.com/meta/v1", + "https://gateway.example.com/meta/v1/models", + ), + ( + "https://gateway.example.com/meta/v1/", + "https://gateway.example.com/meta/v1/models", + ), + ( + "https://gateway.example.com/proxy", + "https://gateway.example.com/proxy/models", + ), + ] { + let parsed = Url::parse(base).unwrap(); + assert_eq!( + models_url(&parsed).unwrap().as_str(), + expected, + "base {base}" + ); + } } #[test] From 48ae53005c239390a3803ad81e0362b02fe15b67 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:14:07 +0700 Subject: [PATCH 5/5] fix(meta): reject invalid endpoints and malformed models --- rust/src/providers/meta/mod.rs | 112 ++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/rust/src/providers/meta/mod.rs b/rust/src/providers/meta/mod.rs index 7959c1ce21..b405e4aa17 100644 --- a/rust/src/providers/meta/mod.rs +++ b/rust/src/providers/meta/mod.rs @@ -23,7 +23,6 @@ const META_ENV_KEYS: &[&str] = &["MODEL_API_KEY", "META_API_KEY"]; #[derive(Debug, Deserialize)] struct ModelsResponse { - #[serde(default)] data: Vec, } @@ -61,7 +60,7 @@ impl MetaProvider { } async fn probe_models(&self, api_key: &str) -> Result { - let url = models_url(&api_base_url())?; + let url = models_url(&api_base_url()?)?; let response = self .client .get(url) @@ -145,12 +144,29 @@ fn resolve_meta_api_key(explicit: Option<&str>) -> Result } } -fn api_base_url() -> Url { - std::env::var("META_API_URL") - .or_else(|_| std::env::var("METASPARK_API_URL")) - .ok() - .and_then(|raw| crate::providers::validated_https_url(&raw, "Meta API").ok()) - .unwrap_or_else(|| Url::parse(META_API_BASE).expect("static Meta URL is valid")) +fn api_base_url() -> Result { + let preferred = std::env::var("META_API_URL").ok(); + let legacy = std::env::var("METASPARK_API_URL").ok(); + resolve_api_base_url(preferred.as_deref(), legacy.as_deref()) +} + +/// Resolve the configured Meta API base. +/// +/// Precedence is preferred (`META_API_URL`) then legacy (`METASPARK_API_URL`), +/// falling back to the production default only when neither variable is set. +/// A variable that *is* set but invalid is an error: an invalid preferred value +/// never silently falls through to the legacy value or the default. +fn resolve_api_base_url( + preferred: Option<&str>, + legacy: Option<&str>, +) -> Result { + if let Some(raw) = preferred { + return crate::providers::validated_https_url(raw, "Meta API"); + } + if let Some(raw) = legacy { + return crate::providers::validated_https_url(raw, "Meta API"); + } + Ok(Url::parse(META_API_BASE).expect("static Meta URL is valid")) } /// Resolve the `GET /v1/models` endpoint from a configured API base. @@ -252,6 +268,40 @@ mod tests { assert!(matches!(err, ProviderError::Parse(_))); } + #[test] + fn empty_top_level_object_is_rejected() { + let err = parse_muse_spark_models("{}").unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn missing_data_field_is_rejected() { + let err = parse_muse_spark_models(r#"{"object": "list"}"#).unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn wrong_data_type_is_rejected() { + let err = parse_muse_spark_models(r#"{"data": {"id": "muse-spark-1.3"}}"#).unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + + let err = parse_muse_spark_models(r#"{"data": "muse-spark-1.3"}"#).unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn minimal_valid_response_parses() { + assert!( + parse_muse_spark_models(r#"{"data": []}"#) + .unwrap() + .is_empty() + ); + assert_eq!( + parse_muse_spark_models(r#"{"data": [{"id": "muse-spark-1.3"}]}"#).unwrap(), + vec!["muse-spark-1.3".to_string()] + ); + } + #[test] fn snapshot_lists_models_without_cost() { let snapshot = @@ -310,6 +360,52 @@ mod tests { } } + #[test] + fn default_api_base_used_when_neither_env_is_set() { + let base = resolve_api_base_url(None, None).unwrap(); + assert_eq!(base.as_str(), META_API_BASE); + assert_eq!( + models_url(&base).unwrap().as_str(), + "https://api.meta.ai/v1/models" + ); + } + + #[test] + fn preferred_api_url_wins_over_legacy() { + let base = resolve_api_base_url( + Some("https://preferred.example.com/meta/v1"), + Some("https://legacy.example.com/meta/v1"), + ) + .unwrap(); + assert_eq!(base.as_str(), "https://preferred.example.com/meta/v1"); + assert_eq!( + models_url(&base).unwrap().as_str(), + "https://preferred.example.com/meta/v1/models" + ); + } + + #[test] + fn legacy_api_url_used_when_preferred_is_absent() { + let base = resolve_api_base_url(None, Some("https://legacy.example.com/meta/v1")).unwrap(); + assert_eq!(base.as_str(), "https://legacy.example.com/meta/v1"); + } + + #[test] + fn invalid_preferred_url_errors_instead_of_falling_through() { + for bad in ["", "http://preferred.example.com/v1", "not a url"] { + let result = + resolve_api_base_url(Some(bad), Some("https://legacy.example.com/meta/v1")); + assert!(result.is_err(), "expected {bad:?} to be rejected"); + assert!(matches!(result.unwrap_err(), ProviderError::Other(_))); + } + } + + #[test] + fn invalid_legacy_url_errors_instead_of_defaulting() { + let err = resolve_api_base_url(None, Some("ftp://legacy.example.com")).unwrap_err(); + assert!(matches!(err, ProviderError::Other(_))); + } + #[test] fn metadata_matches_descriptor() { let provider = MetaProvider::new();