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
4 changes: 2 additions & 2 deletions crates/openfang-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
}

Expand All @@ -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");
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/openfang-cli/src/tui/screens/init_wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
},
Expand Down Expand Up @@ -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");
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/openfang-cli/src/tui/screens/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
}
12 changes: 11 additions & 1 deletion crates/openfang-kernel/src/metering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
62 changes: 62 additions & 0 deletions crates/openfang-runtime/src/drivers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down
73 changes: 65 additions & 8 deletions crates/openfang-runtime/src/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// 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());
}
Expand Down Expand Up @@ -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<String, String> {
&self.aliases
Expand Down Expand Up @@ -1016,7 +1029,8 @@ fn builtin_aliases() -> HashMap<String, String> {
("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"),
Expand Down Expand Up @@ -3270,15 +3284,30 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
46 changes: 46 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down