diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index a2504db451..307f93e96f 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -55,6 +55,13 @@ BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ DATABRICKS_MODEL=goose-claude-4-6-sonnet \ ./target/release/buzz-agent + +# Or MiniMax (global endpoint; use minimax-cn for the mainland-China host). +# MINIMAX_MODEL is optional and defaults to MiniMax-M3. +BUZZ_AGENT_PROVIDER=minimax \ +MINIMAX_API_KEY=... \ +MINIMAX_MODEL=MiniMax-M3 \ + ./target/release/buzz-agent ``` That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr. @@ -129,7 +136,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, `databricks_v2`, `minimax`, or `minimax-cn`. No implicit fallback — the agent errors at startup when this is unset. | | `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | | `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | @@ -138,6 +145,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. | | `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. | | `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. | +| `MINIMAX_API_KEY` | — | Required when provider=minimax or provider=minimax-cn. | +| `MINIMAX_MODEL` | `MiniMax-M3` | Optional. Defaults to the current flagship; `BUZZ_AGENT_MODEL` overrides it. | +| `MINIMAX_BASE_URL` | region default | Optional override. Defaults to `https://api.minimax.io/v1` for `minimax` and `https://api.minimaxi.com/v1` for `minimax-cn`. | | `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. | @@ -171,9 +181,13 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | +| MiniMax (global) | `minimax` | `POST {base}/chat/completions` | MiniMax-M3, MiniMax-M2.7 | +| MiniMax (China) | `minimax-cn` | `POST {base}/chat/completions` | MiniMax-M3, MiniMax-M2.7 | If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +`minimax` and `minimax-cn` are the same OpenAI-compatible Chat Completions transport pointed at MiniMax's global (`api.minimax.io`) and mainland-China (`api.minimaxi.com`) hosts; the region is just the default base URL and is overridable with `MINIMAX_BASE_URL`. Both require `MINIMAX_API_KEY` (sent as a bearer token) and default to the `MiniMax-M3` model. MiniMax also publishes an Anthropic-compatible Messages endpoint — reach it with `BUZZ_AGENT_PROVIDER=anthropic` and `ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic` (or the `api.minimaxi.com` host for China). + `provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format). By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway). diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 037b67b3cb..b48e7563cd 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -660,6 +660,30 @@ pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; +/// Default MiniMax text model when neither `BUZZ_AGENT_MODEL` nor +/// `MINIMAX_MODEL` is set — the current flagship (1M-token context window). +const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3"; +/// MiniMax OpenAI-compatible base URLs. The global service and the +/// mainland-China service share the same Chat Completions wire format and +/// differ only by host, so the region is expressed purely as the base URL. +const MINIMAX_GLOBAL_BASE_URL: &str = "https://api.minimax.io/v1"; +const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1"; + +/// Pick the default MiniMax OpenAI-compatible base URL for the requested +/// provider spelling. `minimax-cn` (aliases `minimaxi`, `minimax_cn`) selects +/// the mainland-China host; everything else — including the bare `minimax` — +/// selects the global host. Always overridable with `MINIMAX_BASE_URL`. +fn minimax_default_base_url(requested: Option<&str>) -> &'static str { + match requested + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("minimax-cn") | Some("minimaxi") | Some("minimax_cn") => MINIMAX_CN_BASE_URL, + _ => MINIMAX_GLOBAL_BASE_URL, + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Provider { Anthropic, @@ -671,6 +695,13 @@ pub enum Provider { /// Databricks AI Gateway v2. Routes by model family through the gateway's /// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths. DatabricksV2, + /// MiniMax hosted models over the OpenAI-compatible Chat Completions wire. + /// `minimax` targets the global endpoint (`https://api.minimax.io/v1`) and + /// `minimax-cn` the mainland-China endpoint (`https://api.minimaxi.com/v1`); + /// both authenticate with a static `MINIMAX_API_KEY` bearer. The region is + /// carried entirely by the base URL, so the request path reuses the OpenAI + /// Chat Completions body builder and parser unchanged — no new wire format. + MiniMax, } /// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` @@ -751,8 +782,9 @@ impl Config { pub fn from_env() -> Result { let databricks_host = env("DATABRICKS_HOST"); let databricks_model = env("DATABRICKS_MODEL"); + let provider_raw = env("BUZZ_AGENT_PROVIDER"); let provider = resolve_provider( - env("BUZZ_AGENT_PROVIDER").as_deref(), + provider_raw.as_deref(), env("ANTHROPIC_API_KEY").as_deref(), env("OPENAI_COMPAT_API_KEY").as_deref(), )?; @@ -797,6 +829,22 @@ impl Config { databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch ), + Provider::MiniMax => ( + req("MINIMAX_API_KEY")?, + // MiniMax ships a documented default model (MiniMax-M3), so — + // unlike the OpenAI-compatible provider — a bare `minimax` + // selection resolves without an explicit model. An explicit + // BUZZ_AGENT_MODEL / MINIMAX_MODEL still wins. + resolve_model(buzz_agent_model.as_deref(), env("MINIMAX_MODEL").as_deref()) + .unwrap_or_else(|| DEFAULT_MINIMAX_MODEL.to_string()), + env_or( + "MINIMAX_BASE_URL", + minimax_default_base_url(provider_raw.as_deref()), + ), + // MiniMax exposes Chat Completions, not the Responses API — pin + // it so the `auto` host heuristic never routes to `/responses`. + OpenAiApi::Chat, + ), }; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { (Some(_), Some(_)) => return Err( @@ -1017,6 +1065,10 @@ fn resolve_provider( ), "databricks" => Ok(Provider::Databricks), "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), + // MiniMax accepts a region-selecting spelling. The API key is + // validated later in `from_env` (like Databricks), so the + // requested value alone determines the provider here. + "minimax" | "minimax-cn" | "minimaxi" | "minimax_cn" => Ok(Provider::MiniMax), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), @@ -1282,6 +1334,55 @@ mod tests { assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); } + #[test] + fn resolve_provider_accepts_minimax_region_spellings() { + // Every accepted MiniMax spelling resolves to the one provider variant; + // the API key is validated later in from_env (like Databricks), so no + // key is needed here. Matching is case-insensitive. + for raw in [ + "minimax", + "minimax-cn", + "minimaxi", + "minimax_cn", + "MiniMax", + "MINIMAX-CN", + ] { + assert_eq!( + resolve_provider(Some(raw), None, None).unwrap(), + Provider::MiniMax, + "raw={raw:?}" + ); + } + } + + #[test] + fn minimax_default_base_url_selects_region_by_spelling() { + // The bare id and any unknown/global-ish spelling map to the global + // host; only the explicit -cn spellings select the mainland-China host. + assert_eq!( + minimax_default_base_url(Some("minimax")), + "https://api.minimax.io/v1" + ); + assert_eq!( + minimax_default_base_url(Some(" MiniMax ")), + "https://api.minimax.io/v1" + ); + assert_eq!( + minimax_default_base_url(Some("minimax-cn")), + "https://api.minimaxi.com/v1" + ); + assert_eq!( + minimax_default_base_url(Some("MINIMAXI")), + "https://api.minimaxi.com/v1" + ); + assert_eq!( + minimax_default_base_url(Some("minimax_cn")), + "https://api.minimaxi.com/v1" + ); + // Absent value defaults to the global host. + assert_eq!(minimax_default_base_url(None), "https://api.minimax.io/v1"); + } + #[test] fn is_openai_host_matrix() { // Lookalike-safe: `api.openai.com.evil.example` and malformed URLs diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f2359f9ba3..fadda4733c 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -146,7 +146,7 @@ impl Llm { .await?; parse_anthropic(v) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Databricks | Provider::MiniMax => { self.openai_request( cfg, effective_model, @@ -248,7 +248,7 @@ impl Llm { }); Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Databricks | Provider::MiniMax => { let r = self .openai_request( cfg, @@ -1638,13 +1638,14 @@ where /// never read for Anthropic requests (those go through `post_anthropic` with /// `x-api-key`), but Llm holds one to keep the field non-`Option`. /// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`. +/// - `Provider::MiniMax`: a static source over `MINIMAX_API_KEY`. /// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source. /// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC /// discovery URL. First request without a cached token triggers a browser /// flow; subsequent requests use the cache + refresh transparently. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi => { + Provider::Anthropic | Provider::OpenAi | Provider::MiniMax => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -1939,6 +1940,31 @@ mod tests { .collect() } + #[tokio::test] + async fn minimax_routes_over_openai_chat_completions() { + // MiniMax reuses the OpenAI Chat Completions wire: a MiniMax config must + // POST to /v1/chat/completions (never /v1/responses), echo the + // configured model in the request body, and parse the OpenAI-shaped + // response. This pins the request path for the region-agnostic transport. + let (base_url, captured) = + spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("hi from minimax"))]).await; + let mut config = cfg(Provider::MiniMax); + config.base_url = base_url; + let llm = Llm::new(&config).unwrap(); + + let reply = complete_model(&llm, &config, "MiniMax-M3").await.unwrap(); + assert_eq!(reply.text, "hi from minimax"); + + let requests = captured.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/chat/completions"); + assert_eq!( + requests[0].body.as_ref().and_then(|b| b["model"].as_str()), + Some("MiniMax-M3") + ); + } + #[tokio::test] async fn mesh_auto_requires_two_stable_catalog_observations() { let (base_url, captured) = spawn_sequence_stub(vec![