Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions configs/models/meta.json
Original file line number Diff line number Diff line change
@@ -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"}
}
}
}
23 changes: 21 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`:

Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions kres-agents/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -181,6 +182,7 @@ impl AgentThinkingConfig {
impl From<AgentThinkingEffort> 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,
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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<String>) {
Expand Down
56 changes: 53 additions & 3 deletions kres-llm/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -90,6 +95,13 @@ impl LlmCredentials {
}
}

pub fn meta(api_key: impl Into<String>, base_url: Option<String>) -> 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<String>,
project_id: impl Into<String>,
Expand Down Expand Up @@ -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))
}
Expand All @@ -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,
}
}
Expand All @@ -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),
}
}
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 37 additions & 1 deletion kres-llm/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub enum Provider {
CodexCodes,
ClaudeCodes,
OpenAi,
Meta,
}

/// A model id paired with its known output-token ceiling.
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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));
}
}
16 changes: 11 additions & 5 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ set -euo pipefail

usage() {
cat <<USAGE
Usage: $0 --provider {anthropic,openai,claude,codex} [--api-key KEY]
Usage: $0 --provider {anthropic,openai,claude,codex,meta} [--api-key KEY]
[--slow MODEL] [--model MODEL]
[--semcode PATH] [--review-prompts PATH] [--overwrite]

Options:
--dest DIR Destination directory (default: \$HOME/.kres)
--provider NAME Required provider: anthropic, openai, claude, or codex
--api-key KEY API key literal. Required for anthropic and openai;
--provider NAME Required provider: anthropic, openai, claude, codex, or meta
--api-key KEY API key literal. Required for anthropic, openai, and meta;
rejected for claude and codex, which use CLI auth.
--slow MODEL Override the provider's default slow model selector
--model MODEL Override the provider's default fast/main/todo selector
Expand Down Expand Up @@ -114,14 +114,20 @@ case "${PROVIDER}" in
: "${SLOW_MODEL:=codex-codes.json:gpt-5.6-sol}"
CLASSIFIER_MODEL="codex-codes.json:gpt-5.6-sol"
;;
meta)
MODEL_CONFIGS=(meta.json)
: "${MODEL:=meta.json:muse-spark-1.2}"
: "${SLOW_MODEL:=meta.json:muse-spark-1.2}"
CLASSIFIER_MODEL="meta.json:muse-spark-1.2"
;;
*)
echo "error: unsupported provider '${PROVIDER}'; expected anthropic, openai, claude, or codex" >&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
Expand Down