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
16 changes: 15 additions & 1 deletion crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` | |
Expand All @@ -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. |
Expand Down Expand Up @@ -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).
Expand Down
103 changes: 102 additions & 1 deletion crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand Down Expand Up @@ -751,8 +782,9 @@ impl Config {
pub fn from_env() -> Result<Self, String> {
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(),
)?;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
)),
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Arc<dyn TokenSource>, 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 => {
Expand Down Expand Up @@ -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![
Expand Down