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 9bd27919c9..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,6 +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", svgPath: RAW.meta }, }; const ALIASES: Record = { @@ -303,6 +306,12 @@ const ALIASES: Record = { "lite-llm": "litellm", "zed ai": "zed", "zed-ai": "zed", + 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 68b1e758c8..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", + "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 bd4f589123..afa1517431 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 "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 c74a80ab40..3281d8c070 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"], + ["meta", "Meta"], ]; diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 8a3ba042a9..fff25d5e9e 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -82,6 +82,8 @@ pub enum ProviderId { Notion, Xai, Fireworks, + #[serde(alias = "metaspark")] + Meta, } impl ProviderId { @@ -158,6 +160,7 @@ impl ProviderId { ProviderId::Notion, ProviderId::Xai, ProviderId::Fireworks, + ProviderId::Meta, ] } @@ -200,6 +203,7 @@ impl ProviderId { ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", + ProviderId::Meta => "meta", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -277,6 +281,7 @@ impl ProviderId { ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", + ProviderId::Meta => "Meta", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -367,6 +372,7 @@ impl ProviderId { ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, ProviderId::Fireworks => None, + ProviderId::Meta => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -440,6 +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), + "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), @@ -737,6 +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("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); @@ -864,6 +878,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Notion => "#337EA9", ProviderId::Xai => "#8E8E93", ProviderId::Fireworks => "#F25B1C", + ProviderId::Meta => "#0467DF", } } @@ -874,7 +889,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 +940,7 @@ mod tests { assert!(all.contains(&ProviderId::QwenCloud)); assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); + assert!(all.contains(&ProviderId::Meta)); } #[test] @@ -1142,6 +1158,39 @@ mod tests { ); } + #[test] + 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::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("meta-spark"), + Some(ProviderId::Meta) + ); + assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); + assert_eq!( + ProviderId::from_cli_name("muse-spark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("musespark"), + 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::Meta) + ); + assert_eq!(brand_color(ProviderId::Meta), "#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..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, 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,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::Meta => Box::new(MetaProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index fa68ef5156..a8ae172b17 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::Meta => None, } } diff --git a/rust/src/providers/meta/mod.rs b/rust/src/providers/meta/mod.rs new file mode 100644 index 0000000000..b405e4aa17 --- /dev/null +++ b/rust/src/providers/meta/mod.rs @@ -0,0 +1,426 @@ +//! 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 +//! 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 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 { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + #[serde(default)] + id: Option, +} + +pub struct MetaProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl MetaProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Meta, + display_name: "Meta", + 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 = models_url(&api_base_url()?)?; + 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 API returned status {}", + response.status() + ))); + } + + 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 MetaProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for MetaProvider { + fn id(&self) -> ProviderId { + ProviderId::Meta + } + + 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 = resolve_meta_api_key(ctx.api_key.as_deref())?; + 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] + } +} + +/// 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() -> 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. +/// +/// 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 +/// 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 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. 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::informational(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!(snapshot.primary.is_informational); + 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 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 = + 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] + 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(); + 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") + ); + 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..1e32aaecd5 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 meta; 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 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 ac71ba0dbc..36402a93e6 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::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"), + 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..5640285319 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::Meta, ] { assert!( providers.iter().any(|provider| provider.id == id),