From 14c711fab5953e8e9b5012485404fa23e7313a7a Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:00:45 +0800 Subject: [PATCH] Add MiniMax M3 to provider catalog --- crates/openfang-cli/src/main.rs | 4 +- .../src/tui/screens/init_wizard.rs | 4 +- crates/openfang-cli/src/tui/screens/wizard.rs | 4 +- crates/openfang-kernel/src/metering.rs | 12 ++- .../openfang-runtime/src/drivers/anthropic.rs | 62 ++++++++++++++++ crates/openfang-runtime/src/model_catalog.rs | 73 +++++++++++++++++-- docs/configuration.md | 46 ++++++++++++ 7 files changed, 190 insertions(+), 15 deletions(-) diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 62e728ba97..f0244b8d71 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1476,7 +1476,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st "openrouter/google/gemini-2.5-flash", "OpenRouter", ), - ("minimax", "MINIMAX_API_KEY", "MiniMax-M2.7", "MiniMax"), + ("minimax", "MINIMAX_API_KEY", "MiniMax-M3", "MiniMax"), ] } @@ -1495,7 +1495,7 @@ mod provider_list_tests { ); let (_, env_var, model, display) = minimax.unwrap(); assert_eq!(env_var, "MINIMAX_API_KEY"); - assert_eq!(model, "MiniMax-M2.7"); + assert_eq!(model, "MiniMax-M3"); assert_eq!(display, "MiniMax"); } } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 53d9c557d6..93720662d7 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -152,7 +152,7 @@ const PROVIDERS: &[ProviderInfo] = &[ name: "minimax", display: "MiniMax", env_var: "MINIMAX_API_KEY", - default_model: "MiniMax-M2.7", + default_model: "MiniMax-M3", needs_key: true, hint: "", }, @@ -279,7 +279,7 @@ mod tests { ); let minimax = minimax.unwrap(); assert_eq!(minimax.env_var, "MINIMAX_API_KEY"); - assert_eq!(minimax.default_model, "MiniMax-M2.7"); + assert_eq!(minimax.default_model, "MiniMax-M3"); } } diff --git a/crates/openfang-cli/src/tui/screens/wizard.rs b/crates/openfang-cli/src/tui/screens/wizard.rs index 7b30bad8ca..ec2efc156a 100644 --- a/crates/openfang-cli/src/tui/screens/wizard.rs +++ b/crates/openfang-cli/src/tui/screens/wizard.rs @@ -88,7 +88,7 @@ const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "minimax", env_var: "MINIMAX_API_KEY", - default_model: "MiniMax-M2.7", + default_model: "MiniMax-M3", needs_key: true, }, ProviderInfo { @@ -707,6 +707,6 @@ mod tests { ); let minimax = minimax.unwrap(); assert_eq!(minimax.env_var, "MINIMAX_API_KEY"); - assert_eq!(minimax.default_model, "MiniMax-M2.7"); + assert_eq!(minimax.default_model, "MiniMax-M3"); } } diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 1671794edb..b7d8dd5a73 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -200,7 +200,9 @@ impl MeteringEngine { input_tokens: u64, output_tokens: u64, ) -> f64 { - let (input_per_m, output_per_m) = catalog.pricing(model).unwrap_or((1.0, 3.0)); + let (input_per_m, output_per_m) = catalog + .pricing_for_usage(model, input_tokens) + .unwrap_or((1.0, 3.0)); let input_cost = (input_tokens as f64 / 1_000_000.0) * input_per_m; let output_cost = (output_tokens as f64 / 1_000_000.0) * output_per_m; input_cost + output_cost @@ -792,6 +794,14 @@ mod tests { assert!((cost - 4.0).abs() < 0.01); } + #[test] + fn test_estimate_cost_with_catalog_minimax_m3_long_context() { + let catalog = openfang_runtime::model_catalog::ModelCatalog::new(); + let cost = + MeteringEngine::estimate_cost_with_catalog(&catalog, "MiniMax-M3", 600_000, 100_000); + assert!((cost - 0.60).abs() < 0.01); + } + #[test] fn test_get_summary() { let engine = setup(); diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index 10a606e6a7..b57b2c8a6d 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -826,6 +826,7 @@ fn convert_response(api: ApiResponse) -> CompletionResponse { #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] fn test_convert_message_text() { @@ -834,6 +835,67 @@ mod tests { assert_eq!(api_msg.role, "user"); } + #[tokio::test] + async fn test_compatible_base_url_appends_v1_messages() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (path_tx, path_rx) = tokio::sync::oneshot::channel(); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = socket.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let request_text = String::from_utf8(request).unwrap(); + let path = request_text + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap() + .to_string(); + path_tx.send(path).unwrap(); + + let body = r#"{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + let driver = AnthropicDriver::new( + "test-key".to_string(), + format!("http://{address}/anthropic"), + ); + let response = driver + .complete(CompletionRequest { + model: "MiniMax-M3".to_string(), + messages: vec![Message::user("Hello")], + tools: vec![], + max_tokens: 16, + temperature: 1.0, + system: None, + thinking: None, + }) + .await + .unwrap(); + + assert_eq!(response.text(), "ok"); + assert_eq!(path_rx.await.unwrap(), "/anthropic/v1/messages"); + server.await.unwrap(); + } + #[test] fn test_convert_response() { let api_response = ApiResponse { diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 4bf6fc7ec8..ca9e1cd619 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -278,7 +278,7 @@ impl ModelCatalog { /// Return the default model ID for a provider (first model in catalog order). pub fn default_model_for_provider(&self, provider: &str) -> Option { - // Check aliases first — e.g. "minimax" alias resolves to "MiniMax-M2.5" + // Check aliases first; for example, "minimax" resolves to "MiniMax-M3". if let Some(model_id) = self.aliases.get(provider) { return Some(model_id.clone()); } @@ -309,6 +309,19 @@ impl ModelCatalog { .map(|m| (m.input_cost_per_m, m.output_cost_per_m)) } + /// Get standard-tier pricing for a concrete input size. + /// + /// Most catalog entries use a flat rate. Models with input-length pricing + /// select the applicable standard tier here so metering does not collapse + /// tiered pricing into a single incorrect rate. + pub fn pricing_for_usage(&self, model_id: &str, input_tokens: u64) -> Option<(f64, f64)> { + let model = self.find_model(model_id)?; + if model.id == "MiniMax-M3" && input_tokens > 512_000 { + return Some((0.60, 2.40)); + } + Some((model.input_cost_per_m, model.output_cost_per_m)) + } + /// List all alias mappings. pub fn list_aliases(&self) -> &HashMap { &self.aliases @@ -1016,7 +1029,8 @@ fn builtin_aliases() -> HashMap { ("ernie", "ernie-4.5-8k"), ("kimi", "kimi-k2"), ("moonshot", "moonshot-v1-128k"), - ("minimax", "MiniMax-M2.7"), + ("minimax", "MiniMax-M3"), + ("minimax-m3", "MiniMax-M3"), ("minimax-m2.7", "MiniMax-M2.7"), ("minimax-m2.5", "MiniMax-M2.5"), ("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed"), @@ -3270,15 +3284,30 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // MiniMax (7) + // MiniMax (8) // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "MiniMax-M3".into(), + display_name: "MiniMax M3".into(), + provider: "minimax".into(), + tier: ModelTier::Frontier, + context_window: 1_000_000, + max_output_tokens: 524_288, + // The catalog stores the default standard tier for inputs up to 512K. + input_cost_per_m: 0.30, + output_cost_per_m: 1.20, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["minimax-m3".into()], + }, ModelCatalogEntry { id: "MiniMax-M2.7".into(), display_name: "MiniMax M2.7".into(), provider: "minimax".into(), tier: ModelTier::Frontier, context_window: 204_800, - max_output_tokens: 131_072, + max_output_tokens: 204_800, input_cost_per_m: 0.30, output_cost_per_m: 1.20, supports_tools: true, @@ -4164,6 +4193,19 @@ mod tests { assert!(catalog.pricing("nonexistent").is_none()); } + #[test] + fn test_minimax_m3_standard_pricing_tiers() { + let catalog = ModelCatalog::new(); + assert_eq!( + catalog.pricing_for_usage("MiniMax-M3", 512_000), + Some((0.30, 1.20)) + ); + assert_eq!( + catalog.pricing_for_usage("minimax-m3", 512_001), + Some((0.60, 2.40)) + ); + } + #[test] fn test_detect_auth_local_providers() { let mut catalog = ModelCatalog::new(); @@ -4325,16 +4367,31 @@ mod tests { assert!(catalog.find_model("codegeex").is_some()); assert!(catalog.find_model("ernie").is_some()); assert!(catalog.find_model("minimax").is_some()); - // MiniMax M2.7 — new flagship model + // MiniMax M3 is the current default model + let m3 = catalog.find_model("MiniMax-M3").unwrap(); + assert_eq!(m3.provider, "minimax"); + assert_eq!(m3.tier, ModelTier::Frontier); + assert_eq!(m3.context_window, 1_000_000); + assert_eq!(m3.max_output_tokens, 524_288); + assert_eq!(m3.input_cost_per_m, 0.30); + assert_eq!(m3.output_cost_per_m, 1.20); + assert!(m3.supports_vision); + assert!(m3.supports_tools); + assert!(catalog.find_model("minimax-m3").is_some()); + // Default "minimax" alias points to M3 + let default = catalog.find_model("minimax").unwrap(); + assert_eq!(default.id, "MiniMax-M3"); + // MiniMax M2.7 remains available let m27 = catalog.find_model("MiniMax-M2.7").unwrap(); assert_eq!(m27.provider, "minimax"); assert_eq!(m27.tier, ModelTier::Frontier); + assert_eq!(m27.context_window, 204_800); + assert_eq!(m27.max_output_tokens, 204_800); + assert_eq!(m27.input_cost_per_m, 0.30); + assert_eq!(m27.output_cost_per_m, 1.20); assert!(!m27.supports_vision); assert!(m27.supports_tools); assert!(catalog.find_model("minimax-m2.7").is_some()); - // Default "minimax" alias now points to M2.7 - let default = catalog.find_model("minimax").unwrap(); - assert_eq!(default.id, "MiniMax-M2.7"); // MiniMax M2.5 — by exact ID, alias, and case-insensitive let m25 = catalog.find_model("MiniMax-M2.5").unwrap(); assert_eq!(m25.provider, "minimax"); diff --git a/docs/configuration.md b/docs/configuration.md index a233c99d59..bd6c849a79 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -334,6 +334,52 @@ api_key_env = "ANTHROPIC_API_KEY" | `api_key_env` | string | `"ANTHROPIC_API_KEY"` | Name of the environment variable holding the API key. The actual key is read from this env var at runtime, never stored in config. | | `base_url` | string or null | `null` | Override the API base URL. Useful for proxies or self-hosted endpoints. When `null`, the provider's default URL from the model catalog is used. | +#### MiniMax M3 + +`MiniMax-M3` is the default MiniMax model. It has a 1,000,000-token context window, accepts text, image, and video input, supports tool use, and supports adaptive or disabled thinking. The model catalog's `supports_vision` flag represents its image capability; the catalog does not expose a separate video flag. `MiniMax-M2.7` remains available with a 204,800-token context window, text input, and always-on thinking. + +OpenFang can use both supported wire protocols in either region: + +| Region | OpenAI-compatible base URL | Anthropic-compatible base URL | +|--------|----------------------------|-------------------------------| +| Global | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` | +| China mainland | `https://api.minimaxi.com/v1` | `https://api.minimaxi.com/anthropic` | + +Use the built-in MiniMax provider for the OpenAI-compatible API: + +```toml +[default_model] +provider = "minimax" +model = "MiniMax-M3" +api_key_env = "MINIMAX_API_KEY" +base_url = "https://api.minimax.io/v1" +# For China mainland, use https://api.minimaxi.com/v1 instead. +``` + +Use the existing Anthropic driver for the Anthropic-compatible API: + +```toml +[default_model] +provider = "anthropic" +model = "MiniMax-M3" +api_key_env = "MINIMAX_API_KEY" +base_url = "https://api.minimax.io/anthropic" +# For China mainland, use https://api.minimaxi.com/anthropic instead. +``` + +The Anthropic driver appends `/v1/messages`, so keep its configured base URL ending in `/anthropic`; do not append `/v1`. + +M3 pay-as-you-go prices are tiered by service tier and input length. The catalog and cost meter use the standard tier and select the long-context rate when input exceeds 512,000 tokens. + +| Service tier | Input length | Input $/M | Output $/M | Cache read $/M | +|--------------|--------------|-----------|------------|---------------| +| Standard | Up to 512,000 tokens | $0.30 | $1.20 | $0.06 | +| Standard | More than 512,000 tokens | $0.60 | $2.40 | $0.12 | +| Priority | Up to 512,000 tokens | $0.45 | $1.80 | $0.09 | +| Priority | More than 512,000 tokens | $0.90 | $3.60 | $0.18 | + +`MiniMax-M2.7` uses flat rates of $0.30/M input tokens, $1.20/M output tokens, $0.06/M cache-read tokens, and $0.375/M cache-write tokens. + --- ### `[memory]`