From 5b423a1357606ce564757245b0f298463694568f Mon Sep 17 00:00:00 2001 From: Rik van Riel Date: Thu, 6 Aug 2026 23:03:31 -0400 Subject: [PATCH] Add Meta muse-spark models config support Add support for Meta's muse-spark models. These use an OpenAI compatible API, through the same agent pipeline used by existing API models. Reuses the existing AI model infrastructure and templates. Signed-off-by: Rik van Riel --- configs/models/meta.json | 25 +++++++++++++++++ docs/configuration.md | 23 ++++++++++++++-- kres-agents/src/config.rs | 12 +++++++++ kres-llm/src/client.rs | 56 ++++++++++++++++++++++++++++++++++++--- kres-llm/src/model.rs | 38 +++++++++++++++++++++++++- setup.sh | 16 +++++++---- 6 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 configs/models/meta.json diff --git a/configs/models/meta.json b/configs/models/meta.json new file mode 100644 index 0000000..217352b --- /dev/null +++ b/configs/models/meta.json @@ -0,0 +1,25 @@ +{ + "provider": "meta", + "base_url": "https://api.meta.ai/v1", + "api_key": "@API_KEY@", + "models": { + "muse-spark-1.2": { + "max_tokens": 131072, + "max_input_tokens": 900000, + "rate_limit": 2000000, + "thinking": {"type": "adaptive", "effort": "medium"} + }, + "muse-spark-1.2-contributor": { + "max_tokens": 131072, + "max_input_tokens": 900000, + "rate_limit": 2000000, + "thinking": {"type": "adaptive", "effort": "medium"} + }, + "muse-spark-1.1": { + "max_tokens": 131072, + "max_input_tokens": 900000, + "rate_limit": 2000000, + "thinking": {"type": "adaptive", "effort": "medium"} + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 3936173..ff847cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,6 +117,7 @@ matching role selectors to `settings.json`: | `openai` | `openai.json` | Required Azure `--api-key` | GPT-5.5 for every role | | `claude` | `claude-codes.json` | Claude CLI login | Sonnet 5 fast/main/todo/classifier, Opus 4.8 slow | | `codex` | `codex-codes.json` | Codex CLI login | GPT-5.6-sol for every role | +| `meta` | `meta.json` | Required `--api-key` | Muse Spark 1.2 for every role | The OpenAI stub uses the Azure API Management endpoint and API version shipped in `configs/models/openai.json`. A custom OpenAI-compatible connection may use @@ -135,7 +136,6 @@ in `configs/models/openai.json`. A custom OpenAI-compatible connection may use }} } ``` - Azure or Azure API Management connections use the same `api_key` field plus `host`: @@ -150,10 +150,29 @@ Azure or Azure API Management connections use the same `api_key` field plus } ``` +Meta uses `provider: "meta"` with `base_url` defaulting to +`https://api.meta.ai/v1` and is likewise OpenAI-compatible. It uses the same +`api_key` field: + +```json +{ + "provider": "meta", + "api_key": "...", + "models": {"muse-spark-1.2": { + "max_tokens": 131072, + "max_input_tokens": 900000, + "rate_limit": 2000000, + "thinking": {"type": "adaptive", "effort": "medium"} + }} +} +``` + GPT-5/o-series calls use the Responses API. `thinking` maps to OpenAI `reasoning.effort`, and kres sends text verbosity `medium` by default. Explicit thinking budgets are mapped onto OpenAI effort -tiers; adaptive `low` / `medium` / `high` are sent directly. +tiers; adaptive `low` / `medium` / `high` are sent directly. Meta +models use the same mapping — effort values `minimal|low|medium|high|xhigh` +are supported, `minimal` being Meta-specific. ## Codex Codes diff --git a/kres-agents/src/config.rs b/kres-agents/src/config.rs index e93b93d..5d093c0 100644 --- a/kres-agents/src/config.rs +++ b/kres-agents/src/config.rs @@ -155,6 +155,7 @@ pub enum AgentThinkingConfig { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentThinkingEffort { + Minimal, Low, Medium, High, @@ -181,6 +182,7 @@ impl AgentThinkingConfig { impl From for Effort { fn from(value: AgentThinkingEffort) -> Self { match value { + AgentThinkingEffort::Minimal => Effort::Minimal, AgentThinkingEffort::Low => Effort::Low, AgentThinkingEffort::Medium => Effort::Medium, AgentThinkingEffort::High => Effort::High, @@ -342,6 +344,9 @@ impl AgentConfig { if matches!(provider.as_deref(), Some("openai" | "open_ai")) || self.model_is_openai() { return Ok(LlmCredentials::openai(api_key, self.base_url.clone())); } + if matches!(provider.as_deref(), Some("meta")) || self.model_is_meta() { + return Ok(LlmCredentials::meta(api_key, self.base_url.clone())); + } match self.base_url.as_deref() { Some(base_url) => Ok(LlmCredentials::anthropic_with_base_url(api_key, base_url)), None => Ok(LlmCredentials::anthropic(api_key)), @@ -441,6 +446,13 @@ impl AgentConfig { .map(|id| Model::from_id(id).provider() == Provider::OpenAi) .unwrap_or(false) } + + fn model_is_meta(&self) -> bool { + self.model + .as_deref() + .map(|id| Model::from_id(id).provider() == Provider::Meta) + .unwrap_or(false) + } } fn split_config_selector(path: &Path) -> (PathBuf, Option) { diff --git a/kres-llm/src/client.rs b/kres-llm/src/client.rs index 431c9a5..7c7f4a5 100644 --- a/kres-llm/src/client.rs +++ b/kres-llm/src/client.rs @@ -26,6 +26,7 @@ use crate::{ const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1"; const DEFAULT_OPENAI_API_VERSION: &str = "2025-04-01-preview"; const ANTHROPIC_VERSION: &str = "2023-06-01"; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(300); @@ -58,6 +59,10 @@ pub enum LlmCredentials { api_key: String, base_url: String, }, + Meta { + api_key: String, + base_url: String, + }, AzureOpenAi { host: String, api_key: String, @@ -90,6 +95,13 @@ impl LlmCredentials { } } + pub fn meta(api_key: impl Into, base_url: Option) -> Self { + Self::Meta { + api_key: api_key.into(), + base_url: base_url.unwrap_or_else(|| DEFAULT_META_BASE_URL.to_string()), + } + } + pub fn vertex_dummy( api_key: impl Into, project_id: impl Into, @@ -194,6 +206,9 @@ impl LlmCredentials { LlmCredentials::OpenAi { api_key, base_url } => { format!("openai:{}:{api_key}", normalize_url(base_url)) } + LlmCredentials::Meta { api_key, base_url } => { + format!("meta:{}:{api_key}", normalize_url(base_url)) + } LlmCredentials::AzureOpenAi { host, api_key, .. } => { format!("azure-openai:{}:{api_key}", normalize_url(host)) } @@ -207,6 +222,7 @@ impl LlmCredentials { LlmCredentials::CodexCodes { api_key, .. } => api_key.as_deref().unwrap_or(""), LlmCredentials::ClaudeCodes { api_key, .. } => api_key.as_deref().unwrap_or(""), LlmCredentials::OpenAi { api_key, .. } => api_key, + LlmCredentials::Meta { api_key, .. } => api_key, LlmCredentials::AzureOpenAi { api_key, .. } => api_key, } } @@ -224,6 +240,7 @@ impl LlmCredentials { .map(normalize_url) .unwrap_or_else(|| DEFAULT_ANTHROPIC_BASE_URL.to_string()), LlmCredentials::OpenAi { base_url, .. } => normalize_url(base_url), + LlmCredentials::Meta { base_url, .. } => normalize_url(base_url), LlmCredentials::AzureOpenAi { host, .. } => normalize_url(host), } } @@ -238,6 +255,7 @@ impl LlmCredentials { Self::CodexCodes { .. } => Provider::CodexCodes, Self::ClaudeCodes { .. } => Provider::ClaudeCodes, Self::OpenAi { .. } | Self::AzureOpenAi { .. } => Provider::OpenAi, + Self::Meta { .. } => Provider::Meta, Self::Anthropic { .. } => Provider::Anthropic, } } @@ -412,7 +430,10 @@ impl Client { if self.credentials.provider() == Provider::ClaudeCodes { return self.claude_codes_messages(cfg, messages).await; } - if self.credentials.provider() == Provider::OpenAi { + if matches!( + self.credentials.provider(), + Provider::OpenAi | Provider::Meta + ) { return self.openai_messages(cfg, messages).await; } const MAX_RETRIES: u32 = 20; @@ -644,7 +665,10 @@ impl Client { if self.credentials.provider() == Provider::ClaudeCodes { return self.claude_codes_messages(cfg, messages).await; } - if self.credentials.provider() == Provider::OpenAi { + if matches!( + self.credentials.provider(), + Provider::OpenAi | Provider::Meta + ) { return self.openai_messages(cfg, messages).await; } const MAX_RETRIES: u32 = 20; @@ -1423,7 +1447,10 @@ fn response_text(resp: &MessagesResponse) -> String { fn use_openai_responses_api(model_id: &str) -> bool { let id = model_id.to_ascii_lowercase(); - id.starts_with("gpt-5") || id.starts_with('o') + id.starts_with("gpt-5") + || id.starts_with('o') + || id.starts_with("muse-spark") + || id.starts_with("meta-") } fn openai_reasoning_effort(thinking: crate::model::ThinkingBudget) -> Option<&'static str> { @@ -2677,6 +2704,29 @@ mod tests { assert!(headers.get("api-key").is_none()); } + #[test] + fn official_meta_uses_bearer_header_and_meta_base_url() { + let client = Client::builder(LlmCredentials::meta("secret", None)) + .build() + .unwrap(); + assert_eq!( + client.openai_responses_url(), + "https://api.meta.ai/v1/responses" + ); + let headers = client.openai_headers(); + assert_eq!( + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()), + Some("Bearer secret") + ); + assert!(headers.get("api-key").is_none()); + // Meta model detection + assert!(use_openai_responses_api("muse-spark-1.2")); + assert!(use_openai_responses_api("muse-spark-1.1")); + assert!(use_openai_responses_api("meta-llama-4")); + } + #[test] fn azure_openai_uses_azure_url_and_api_key_headers() { let client = Client::builder(LlmCredentials::azure_openai( diff --git a/kres-llm/src/model.rs b/kres-llm/src/model.rs index 4c90c39..f6d4f2a 100644 --- a/kres-llm/src/model.rs +++ b/kres-llm/src/model.rs @@ -11,6 +11,7 @@ pub enum Provider { CodexCodes, ClaudeCodes, OpenAi, + Meta, } /// A model id paired with its known output-token ceiling. @@ -43,6 +44,7 @@ impl Model { let max_output_tokens = match id.as_str() { "claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" => 128_000, id if is_openai_model(id) => 128_000, + id if is_meta_model(id) => 131_072, _ => 64_000, }; Self { @@ -54,12 +56,19 @@ impl Model { pub fn provider(&self) -> Provider { if is_openai_model(&self.id) { Provider::OpenAi + } else if is_meta_model(&self.id) { + Provider::Meta } else { Provider::Anthropic } } } +fn is_meta_model(id: &str) -> bool { + let id = id.to_ascii_lowercase(); + id.starts_with("muse-spark") || id.starts_with("meta-") +} + fn is_openai_model(id: &str) -> bool { let id = id.to_ascii_lowercase(); id.starts_with("gpt-") || id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4") @@ -92,6 +101,7 @@ pub enum ThinkingBudget { /// Effort bias passed to adaptive thinking via `output_config.effort`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Effort { + Minimal, Low, Medium, High, @@ -101,6 +111,7 @@ pub enum Effort { impl Effort { pub fn as_str(&self) -> &'static str { match self { + Effort::Minimal => "minimal", Effort::Low => "low", Effort::Medium => "medium", Effort::High => "high", @@ -118,7 +129,7 @@ impl ThinkingBudget { /// (medium). /// - Everything else uses an explicit budget sized for the output cap. pub fn default_for_model(model_id: &str, max_tokens: u32) -> Self { - if is_openai_model(model_id) { + if is_openai_model(model_id) || is_meta_model(model_id) { return ThinkingBudget::Adaptive(Effort::Medium); } // Model families that require adaptive schema. Keep this list @@ -238,6 +249,7 @@ mod tests { #[test] fn effort_strings() { + assert_eq!(Effort::Minimal.as_str(), "minimal"); assert_eq!(Effort::Low.as_str(), "low"); assert_eq!(Effort::Medium.as_str(), "medium"); assert_eq!(Effort::High.as_str(), "high"); @@ -282,4 +294,28 @@ mod tests { let m = Model::from_id("claude-future-model-x"); assert_eq!(m.max_output_tokens, 64_000); } + + #[test] + fn meta_models_use_meta_provider_and_131k_ceiling() { + let cases = [ + "muse-spark-latest", + "Meta-Muse-Spark-Preview", + "meta-llama-4", + "meta-llama-3.2-90b", + ]; + for id in cases { + let m = Model::from_id(id); + assert_eq!(m.provider(), Provider::Meta, "id={id}"); + assert_eq!(m.max_output_tokens, 131_072, "id={id}"); + } + } + + #[test] + fn meta_models_use_medium_effort() { + let b = ThinkingBudget::default_for_model("muse-spark-latest", 131_072); + assert_eq!(b, ThinkingBudget::Adaptive(Effort::Medium)); + + let b2 = ThinkingBudget::default_for_model("meta-llama-4", 131_072); + assert_eq!(b2, ThinkingBudget::Adaptive(Effort::Medium)); + } } diff --git a/setup.sh b/setup.sh index f283040..59f89c6 100755 --- a/setup.sh +++ b/setup.sh @@ -18,14 +18,14 @@ set -euo pipefail usage() { cat <&2 + echo "error: unsupported provider '${PROVIDER}'; expected anthropic, openai, claude, codex, or meta" >&2 exit 2 ;; esac case "${PROVIDER}" in - anthropic|openai) + anthropic|openai|meta) if [[ -z "${API_KEY}" ]]; then echo "error: --api-key is required for provider '${PROVIDER}'" >&2 exit 2