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");
}
}
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
46 changes: 38 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 @@ -1016,7 +1016,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 +3271,29 @@ 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,
input_cost_per_m: 0.60,
output_cost_per_m: 2.40,
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 @@ -4325,16 +4340,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.60);
assert_eq!(m3.output_cost_per_m, 2.40);
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
39 changes: 39 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,45 @@ 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 $0.60/M input tokens, $2.40/M output tokens, and $0.12/M cache-read tokens. Cache-write pricing is not listed for M3 in the provider metadata.

`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
Loading