use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub display_name: String,
pub is_default: bool,
pub is_free_tier: bool,
pub context_window: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionModeInfo {
pub id: String,
pub display_name: String,
pub description: String,
pub is_default: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct CliFeatures {
pub thinking: bool,
pub effort_control: bool,
pub mcp: bool,
pub resume: bool,
pub continue_last: bool,
pub allowed_tools_filter: bool,
pub system_prompt_injection: bool,
pub max_turns: bool,
pub sandbox_mode: bool,
pub ide_context: bool,
pub plan_mode: bool,
pub speed_toggle: bool,
pub multi_provider: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliCapabilities {
pub tool_id: String,
pub display_name: String,
pub binary: String,
pub available_models: Vec<ModelInfo>,
pub permission_modes: Vec<PermissionModeInfo>,
pub features: CliFeatures,
}
impl CliCapabilities {
pub fn default_model(&self) -> Option<&ModelInfo> {
self.available_models
.iter()
.find(|m| m.is_default)
.or_else(|| self.available_models.first())
}
pub fn default_permission_mode(&self) -> Option<&PermissionModeInfo> {
self.permission_modes
.iter()
.find(|p| p.is_default)
.or_else(|| self.permission_modes.first())
}
}
fn model(
id: &str,
display_name: &str,
is_default: bool,
is_free_tier: bool,
context_window: Option<u64>,
) -> ModelInfo {
ModelInfo {
id: id.to_string(),
display_name: display_name.to_string(),
is_default,
is_free_tier,
context_window,
}
}
fn perm(id: &str, display_name: &str, description: &str, is_default: bool) -> PermissionModeInfo {
PermissionModeInfo {
id: id.to_string(),
display_name: display_name.to_string(),
description: description.to_string(),
is_default,
}
}
pub(crate) fn claude_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "claude_code".to_string(),
display_name: "Claude Code".to_string(),
binary: "claude".to_string(),
available_models: vec![
model("claude-opus-4-6", "Claude Opus 4.6", false, false, Some(1_048_576)),
model("claude-sonnet-4-6", "Claude Sonnet 4.6", true, false, Some(1_048_576)),
model("claude-haiku-4-5", "Claude Haiku 4.5", false, false, Some(200_000)),
],
permission_modes: vec![
perm("default", "Default", "Standard interactive permissions; asks before file edits.", true),
perm("acceptEdits", "Accept Edits", "Auto-accepts file edits, asks for shell commands.", false),
perm("auto", "Auto", "Approves most operations automatically.", false),
perm("bypassPermissions", "Bypass Permissions", "Skips all permission checks (equivalent to --dangerously-skip-permissions).", false),
perm("plan", "Plan Mode", "Shows a plan and asks for approval before executing.", false),
],
features: CliFeatures {
thinking: true,
effort_control: true,
mcp: true,
resume: true,
continue_last: true,
allowed_tools_filter: true,
system_prompt_injection: true,
max_turns: true,
sandbox_mode: false,
ide_context: false,
plan_mode: true,
speed_toggle: false,
multi_provider: false,
},
}
}
pub(crate) fn codex_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "codex".to_string(),
display_name: "Codex".to_string(),
binary: "codex".to_string(),
available_models: vec![
model("gpt-5.4", "GPT-5.4", true, false, Some(272_000)),
model("gpt-5.4-mini", "GPT-5.4 Mini", false, false, Some(272_000)),
model("gpt-5.3-codex", "GPT-5.3 Codex", false, false, Some(272_000)),
model("gpt-5.2", "GPT-5.2", false, false, Some(272_000)),
],
permission_modes: vec![
perm("suggest", "Suggest", "Read-only: proposes changes but does not apply them.", false),
perm("auto-edit", "Auto Edit", "Edits files automatically; asks before running commands.", false),
perm("full-auto", "Full Auto", "Fully autonomous: edits and runs commands without asking.", true),
],
features: CliFeatures {
thinking: false,
effort_control: false,
mcp: true,
resume: true,
continue_last: true,
allowed_tools_filter: false,
system_prompt_injection: false,
max_turns: false,
sandbox_mode: false,
ide_context: true,
plan_mode: true,
speed_toggle: true,
multi_provider: false,
},
}
}
pub(crate) fn gemini_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "gemini".to_string(),
display_name: "Gemini".to_string(),
binary: "gemini".to_string(),
available_models: vec![
model("gemini-2.5-pro", "Gemini 2.5 Pro", false, false, Some(1_048_576)),
model("gemini-2.5-flash", "Gemini 2.5 Flash", false, true, Some(1_048_576)),
model("gemini-3.1-pro-preview", "Gemini 3.1 Pro", true, false, Some(1_048_576)),
model("gemini-3-flash-preview", "Gemini 3.0 Flash", false, true, Some(1_048_576)),
],
permission_modes: vec![
perm("default", "Default", "Standard Gemini permissions.", true),
perm("auto-edit", "Auto Edit", "Auto-applies file edits.", false),
perm("yolo", "YOLO", "No permission prompts; maximum autonomy.", false),
perm("plan", "Plan", "Gemini shows a plan before executing tool calls.", false),
],
features: CliFeatures {
thinking: false,
effort_control: false,
mcp: true,
resume: true,
continue_last: false, allowed_tools_filter: false,
system_prompt_injection: false,
max_turns: false,
sandbox_mode: true,
ide_context: false,
plan_mode: true,
speed_toggle: false,
multi_provider: false,
},
}
}
pub(crate) fn opencode_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "opencode".to_string(),
display_name: "OpenCode".to_string(),
binary: "opencode".to_string(),
available_models: vec![
model("opencode/gpt-5-nano", "GPT-5 Nano (free)", true, true, Some(128_000)),
model("opencode/glm-4.7-free", "GLM 4.7 (free)", false, true, None),
model("opencode/glm-5-free", "GLM 5 (free)", false, true, None),
model("opencode/kimi-k2.5-free", "Kimi K2.5 (free)", false, true, None),
model("opencode/mimo-v2-flash-free", "Mimo V2 Flash (free)", false, true, None),
model("opencode/mimo-v2-omni-free", "Mimo V2 Omni (free)", false, true, None),
model("opencode/mimo-v2-pro-free", "Mimo V2 Pro (free)", false, true, None),
model("opencode/minimax-m2.1-free", "MiniMax M2.1 (free)", false, true, None),
model("opencode/minimax-m2.5-free", "MiniMax M2.5 (free)", false, true, None),
model("opencode/nemotron-3-super-free", "Nemotron 3 Super (free)", false, true, None),
model("opencode/qwen3.6-plus-free", "Qwen 3.6 Plus (free)", false, true, None),
model("opencode/trinity-large-preview-free", "Trinity Large Preview (free)", false, true, None),
model("opencode/big-pickle", "Big Pickle", false, false, None),
model("opencode/claude-3-5-haiku", "Claude 3.5 Haiku", false, false, None),
model("opencode/claude-haiku-4-5", "Claude Haiku 4.5", false, false, None),
model("opencode/claude-opus-4-1", "Claude Opus 4.1", false, false, None),
model("opencode/claude-opus-4-5", "Claude Opus 4.5", false, false, None),
model("opencode/claude-opus-4-6", "Claude Opus 4.6", false, false, None),
model("opencode/claude-sonnet-4", "Claude Sonnet 4", false, false, None),
model("opencode/claude-sonnet-4-5", "Claude Sonnet 4.5", false, false, None),
model("opencode/claude-sonnet-4-6", "Claude Sonnet 4.6", false, false, None),
model("opencode/gemini-3-flash", "Gemini 3 Flash", false, false, None),
model("opencode/gemini-3-pro", "Gemini 3 Pro", false, false, None),
model("opencode/gemini-3.1-pro", "Gemini 3.1 Pro", false, false, None),
model("opencode/glm-4.6", "GLM 4.6", false, false, None),
model("opencode/glm-4.7", "GLM 4.7", false, false, None),
model("opencode/glm-5", "GLM 5", false, false, None),
model("opencode/glm-5.1", "GLM 5.1", false, false, None),
model("opencode/gpt-5", "GPT-5", false, false, None),
model("opencode/gpt-5-codex", "GPT-5 Codex", false, false, None),
model("opencode/gpt-5.1", "GPT-5.1", false, false, None),
model("opencode/gpt-5.1-codex", "GPT-5.1 Codex", false, false, None),
model("opencode/gpt-5.1-codex-max", "GPT-5.1 Codex Max", false, false, None),
model("opencode/gpt-5.1-codex-mini", "GPT-5.1 Codex Mini", false, false, None),
model("opencode/gpt-5.2", "GPT-5.2", false, false, None),
model("opencode/gpt-5.2-codex", "GPT-5.2 Codex", false, false, None),
model("opencode/gpt-5.3-codex", "GPT-5.3 Codex", false, false, None),
model("opencode/gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", false, false, None),
model("opencode/gpt-5.4", "GPT-5.4", false, false, None),
model("opencode/gpt-5.4-mini", "GPT-5.4 Mini", false, false, None),
model("opencode/gpt-5.4-nano", "GPT-5.4 Nano", false, false, None),
model("opencode/gpt-5.4-pro", "GPT-5.4 Pro", false, false, None),
model("opencode/grok-code", "Grok Code", false, false, None),
model("opencode/kimi-k2", "Kimi K2", false, false, None),
model("opencode/kimi-k2-thinking", "Kimi K2 Thinking", false, false, None),
model("opencode/kimi-k2.5", "Kimi K2.5", false, false, None),
model("opencode/minimax-m2.1", "MiniMax M2.1", false, false, None),
model("opencode/minimax-m2.5", "MiniMax M2.5", false, false, None),
model("opencode/qwen3-coder", "Qwen3 Coder", false, false, None),
],
permission_modes: vec![],
features: CliFeatures {
thinking: true, effort_control: false,
mcp: true,
resume: true,
continue_last: true,
allowed_tools_filter: false,
system_prompt_injection: false,
max_turns: false,
sandbox_mode: false,
ide_context: false,
plan_mode: false,
speed_toggle: false,
multi_provider: true,
},
}
}
fn read_codex_config_model() -> Option<String> {
let home = home_dir()?;
let path = home.join(".codex").join("config.toml");
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("model") && trimmed.contains('=') {
let val = trimmed.split('=').nth(1)?.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
fn read_opencode_config_model() -> Option<String> {
let candidates: Vec<PathBuf> = {
let mut v = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
v.push(cwd.join("opencode.json"));
}
if let Some(home) = home_dir() {
v.push(home.join(".config").join("opencode").join("opencode.json"));
}
v
};
for path in candidates {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
let model_id = json
.get("model")
.and_then(|m| m.get("default"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if model_id.is_some() {
return model_id;
}
}
}
}
None
}
fn update_default_model(models: &mut Vec<ModelInfo>, discovered_id: &str) {
for m in models.iter_mut() {
m.is_default = false;
}
if let Some(m) = models.iter_mut().find(|m| m.id == discovered_id) {
m.is_default = true;
} else {
models.insert(
0,
ModelInfo {
id: discovered_id.to_string(),
display_name: discovered_id.to_string(),
is_default: true,
is_free_tier: false,
context_window: None,
},
);
}
}
fn home_dir() -> Option<PathBuf> {
crate::utils::home_dir()
}
pub(crate) fn discover(
tool: crate::core::types::CliTool,
) -> CliCapabilities {
use crate::core::types::CliTool;
let mut caps = match tool {
CliTool::ClaudeCode => claude_capabilities(),
CliTool::Codex => codex_capabilities(),
CliTool::Gemini => gemini_capabilities(),
CliTool::OpenCode => opencode_capabilities(),
};
if let Some(cure_cache) = crate::cure::load_cure_cache() {
if let Some(cured_models) = cure_cache.tools.get(caps.tool_id.as_str()) {
apply_cure_context_windows(&mut caps.available_models, cured_models);
}
}
match tool {
CliTool::Codex => {
if let Some(model_id) = read_codex_config_model() {
update_default_model(&mut caps.available_models, &model_id);
}
}
CliTool::OpenCode => {
if let Some(model_id) = read_opencode_config_model() {
update_default_model(&mut caps.available_models, &model_id);
}
}
CliTool::ClaudeCode | CliTool::Gemini => {}
}
caps
}
fn apply_cure_context_windows(
models: &mut Vec<ModelInfo>,
cured: &[crate::cure::CuredModel],
) {
use std::collections::HashMap;
let lookup: HashMap<&str, Option<u64>> = cured
.iter()
.map(|c| (c.id.as_str(), c.context_window))
.collect();
for m in models.iter_mut() {
if let Some(&ctx) = lookup.get(m.id.as_str()) {
if ctx.is_some() {
m.context_window = ctx;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::CliTool;
#[test]
fn each_tool_has_exactly_one_default_model() {
for tool in [CliTool::ClaudeCode, CliTool::Codex, CliTool::Gemini, CliTool::OpenCode] {
let caps = tool.capabilities();
let default_count = caps.available_models.iter().filter(|m| m.is_default).count();
assert_eq!(
default_count,
1,
"{:?} must have exactly one default model, found {}",
tool,
default_count
);
}
}
#[test]
fn each_tool_with_modes_has_exactly_one_default_mode() {
for tool in [CliTool::ClaudeCode, CliTool::Codex, CliTool::Gemini] {
let caps = tool.capabilities();
let default_count = caps.permission_modes.iter().filter(|p| p.is_default).count();
assert_eq!(
default_count,
1,
"{:?} must have exactly one default permission mode, found {}",
tool,
default_count
);
}
assert!(CliTool::OpenCode.capabilities().permission_modes.is_empty());
}
#[test]
fn capabilities_returns_correct_tool_id() {
assert_eq!(CliTool::ClaudeCode.capabilities().tool_id, "claude_code");
assert_eq!(CliTool::Codex.capabilities().tool_id, "codex");
assert_eq!(CliTool::Gemini.capabilities().tool_id, "gemini");
assert_eq!(CliTool::OpenCode.capabilities().tool_id, "opencode");
}
#[test]
fn model_ids_are_nonempty() {
for tool in [CliTool::ClaudeCode, CliTool::Codex, CliTool::Gemini, CliTool::OpenCode] {
let caps = tool.capabilities();
for m in &caps.available_models {
assert!(
!m.id.is_empty(),
"{:?} has a model with an empty id (display_name={})",
tool,
m.display_name
);
}
}
}
#[test]
fn claude_has_five_permission_modes() {
assert_eq!(CliTool::ClaudeCode.capabilities().permission_modes.len(), 5);
}
#[test]
fn codex_has_three_permission_modes() {
assert_eq!(CliTool::Codex.capabilities().permission_modes.len(), 3);
}
#[test]
fn gemini_has_four_permission_modes() {
assert_eq!(CliTool::Gemini.capabilities().permission_modes.len(), 4);
}
#[test]
fn opencode_has_zero_permission_modes() {
assert_eq!(CliTool::OpenCode.capabilities().permission_modes.len(), 0);
}
#[test]
fn claude_features_correct() {
let f = CliTool::ClaudeCode.capabilities().features;
assert!(f.thinking);
assert!(f.effort_control);
assert!(f.plan_mode);
assert!(f.mcp);
assert!(f.resume);
assert!(!f.multi_provider);
assert!(!f.sandbox_mode);
}
#[test]
fn codex_features_correct() {
let f = CliTool::Codex.capabilities().features;
assert!(f.ide_context);
assert!(f.plan_mode);
assert!(f.resume);
assert!(!f.thinking);
assert!(!f.multi_provider);
}
#[test]
fn gemini_features_correct() {
let f = CliTool::Gemini.capabilities().features;
assert!(f.sandbox_mode);
assert!(f.plan_mode);
assert!(f.resume);
assert!(!f.continue_last);
assert!(!f.thinking);
}
#[test]
fn opencode_features_correct() {
let f = CliTool::OpenCode.capabilities().features;
assert!(f.multi_provider);
assert!(f.resume);
assert!(f.thinking);
assert!(!f.plan_mode);
assert!(!f.sandbox_mode);
}
#[test]
fn default_model_helper_works() {
for tool in [CliTool::ClaudeCode, CliTool::Codex, CliTool::Gemini, CliTool::OpenCode] {
let caps = tool.capabilities();
let default_model = caps.default_model();
assert!(
default_model.is_some(),
"{:?} default_model() must return Some",
tool
);
assert!(
default_model.unwrap().is_default,
"{:?} default_model() must return a model with is_default=true",
tool
);
}
}
#[test]
fn update_default_model_marks_known_model() {
let mut models = vec![
ModelInfo { id: "a".to_string(), display_name: "A".to_string(), is_default: true, is_free_tier: false, context_window: None },
ModelInfo { id: "b".to_string(), display_name: "B".to_string(), is_default: false, is_free_tier: false, context_window: None },
];
update_default_model(&mut models, "b");
assert!(!models[0].is_default);
assert!(models[1].is_default);
}
#[test]
fn update_default_model_prepends_unknown_model() {
let mut models = vec![
ModelInfo { id: "a".to_string(), display_name: "A".to_string(), is_default: true, is_free_tier: false, context_window: None },
];
update_default_model(&mut models, "new-model");
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "new-model");
assert!(models[0].is_default);
assert!(!models[1].is_default);
}
#[test]
fn discover_returns_valid_capabilities() {
for tool in [CliTool::ClaudeCode, CliTool::Codex, CliTool::Gemini, CliTool::OpenCode] {
let caps = tool.discover_capabilities();
let default_count = caps.available_models.iter().filter(|m| m.is_default).count();
assert_eq!(
default_count,
1,
"{:?} discover_capabilities must have exactly one default model, found {}",
tool,
default_count
);
assert_eq!(caps.tool_id, tool.capabilities().tool_id);
}
}
#[test]
fn codex_config_discovery_with_temp_file() {
let dir = std::env::temp_dir().join("gate4agent_test_codex");
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.toml");
std::fs::write(&config_path, "model = \"gpt-5-custom\"\n").unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
let mut found = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("model") && trimmed.contains('=') {
if let Some(val) = trimmed.split('=').nth(1) {
let val = val.trim().trim_matches('"');
if !val.is_empty() {
found = Some(val.to_string());
}
}
}
}
assert_eq!(found.as_deref(), Some("gpt-5-custom"));
}
#[test]
fn opencode_config_discovery_with_temp_file() {
let dir = std::env::temp_dir().join("gate4agent_test_opencode");
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("opencode.json");
std::fs::write(
&config_path,
r#"{"model":{"default":"anthropic/claude-opus-4-6"}}"#,
)
.unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let model_id = json
.get("model")
.and_then(|m| m.get("default"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
assert_eq!(model_id.as_deref(), Some("anthropic/claude-opus-4-6"));
}
}