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),