Skip to content

Commit 121ff82

Browse files
Add model selection, CLI availability checks, and advanced options flipper
Adds per-backend model selection when creating sessions. Each provider discovers available models from local sources: Claude reads ~/.claude/settings.json, Codex reads ~/.codex/models_cache.json, Copilot reads ~/.copilot/config.json, and Cursor runs cursor-agent --list-models. The --model flag is passed to each backend CLI when a non-default model is selected. Backend availability is now checked via PATH lookup, with unavailable backends greyed out in the dropdown. Branch, main branch, and model fields are grouped under a collapsible "Advanced" section. The session list shows an abbreviated model name alongside the backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 757bdc6 commit 121ff82

18 files changed

Lines changed: 510 additions & 31 deletions

File tree

Cargo.lock

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ralph-cli/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ struct Cli {
3838
/// AI backend to use
3939
#[arg(short = 'B', long, default_value = "claude")]
4040
backend: AiTool,
41+
42+
/// Model to use (backend-specific, e.g. "sonnet", "opus", "o3")
43+
#[arg(short = 'm', long)]
44+
model: Option<String>,
4145
}
4246

4347
#[tokio::main]
@@ -85,6 +89,7 @@ async fn main() {
8589
preamble: cli.preamble,
8690
tagging_enabled: !cli.no_tag,
8791
ai_tool: cli.backend,
92+
model: cli.model,
8893
};
8994

9095
let id = SessionId::new();

crates/ralph-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ uuid = { version = "1", features = ["v4", "serde"] }
1111
async-trait = "0.1"
1212
anyhow = "1"
1313
glob = "0.3"
14+
dirs = "6"

crates/ralph-core/src/provider/claude.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ use tokio::sync::{mpsc, watch};
66

77
use tokio::io::AsyncReadExt;
88

9-
use super::{detect_rate_limit, parse_tool_invocation, AiOutput, AiProvider};
9+
use super::{detect_rate_limit, parse_tool_invocation, AiOutput, AiProvider, BackendModelConfig, ModelInfo};
10+
11+
fn read_claude_current_model() -> Option<String> {
12+
let path = dirs::home_dir()?.join(".claude").join("settings.json");
13+
let data = std::fs::read_to_string(path).ok()?;
14+
let json: serde_json::Value = serde_json::from_str(&data).ok()?;
15+
json.get("model").and_then(|v| v.as_str()).map(|s| s.to_string())
16+
}
1017

1118
pub struct ClaudeProvider;
1219

@@ -16,10 +23,47 @@ impl AiProvider for ClaudeProvider {
1623
"Claude"
1724
}
1825

26+
async fn list_models(&self) -> BackendModelConfig {
27+
let current = read_claude_current_model();
28+
let mut models = vec![
29+
ModelInfo { id: "sonnet".into(), label: "Sonnet".into(), is_default: false },
30+
ModelInfo { id: "opus".into(), label: "Opus".into(), is_default: false },
31+
ModelInfo { id: "haiku".into(), label: "Haiku".into(), is_default: false },
32+
];
33+
// Mark the current model as default
34+
let matched = if let Some(cur) = &current {
35+
models.iter_mut().any(|m| {
36+
if m.id == *cur {
37+
m.is_default = true;
38+
true
39+
} else {
40+
false
41+
}
42+
})
43+
} else {
44+
false
45+
};
46+
// If no match (e.g. full model name like "claude-sonnet-4-6"), default to opus
47+
if !matched {
48+
for m in &mut models {
49+
if m.id == "opus" {
50+
m.is_default = true;
51+
break;
52+
}
53+
}
54+
}
55+
BackendModelConfig {
56+
current_model: current.or_else(|| Some("opus".into())),
57+
models,
58+
supports_freeform: true,
59+
}
60+
}
61+
1962
async fn run(
2063
&self,
2164
working_dir: &Path,
2265
prompt: &str,
66+
model: Option<&str>,
2367
resume_session_id: Option<&str>,
2468
output_tx: mpsc::UnboundedSender<AiOutput>,
2569
mut abort: watch::Receiver<bool>,
@@ -32,6 +76,10 @@ impl AiProvider for ClaudeProvider {
3276
.arg("stream-json")
3377
.arg("--verbose");
3478

79+
if let Some(m) = model {
80+
cmd.arg("--model").arg(m);
81+
}
82+
3583
if let Some(id) = resume_session_id {
3684
cmd.arg("--resume").arg(id);
3785
}

crates/ralph-core/src/provider/codex.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,56 @@
1-
use std::path::Path;
1+
use std::path::{Path, PathBuf};
22

33
use tokio::io::AsyncBufReadExt;
44
use tokio::process::Command;
55
use tokio::sync::{mpsc, watch};
66

77
use crate::events::ToolInvocation;
88

9-
use super::{AiOutput, AiProvider};
9+
use super::{AiOutput, AiProvider, BackendModelConfig, ModelInfo};
10+
11+
fn codex_models_cache_path() -> Option<PathBuf> {
12+
dirs::home_dir().map(|h| h.join(".codex").join("models_cache.json"))
13+
}
14+
15+
fn parse_codex_models_cache(path: &Path) -> Option<BackendModelConfig> {
16+
let data = std::fs::read_to_string(path).ok()?;
17+
let json: serde_json::Value = serde_json::from_str(&data).ok()?;
18+
let models_arr = json.get("models")?.as_array()?;
19+
20+
let mut models = Vec::new();
21+
let mut first_id = None;
22+
for m in models_arr {
23+
let visibility = m.get("visibility").and_then(|v| v.as_str()).unwrap_or("");
24+
if visibility != "list" {
25+
continue;
26+
}
27+
let slug = m.get("slug").and_then(|v| v.as_str()).unwrap_or("").to_string();
28+
let display_name = m
29+
.get("display_name")
30+
.and_then(|v| v.as_str())
31+
.unwrap_or(&slug)
32+
.to_string();
33+
if first_id.is_none() {
34+
first_id = Some(slug.clone());
35+
}
36+
models.push(ModelInfo {
37+
id: slug,
38+
label: display_name,
39+
is_default: false,
40+
});
41+
}
42+
43+
// Mark the first model as default (highest priority in the cache)
44+
if let Some(first) = models.first_mut() {
45+
first.is_default = true;
46+
}
47+
48+
Some(BackendModelConfig {
49+
current_model: first_id,
50+
models,
51+
supports_freeform: true,
52+
})
53+
}
1054

1155
pub struct CodexProvider;
1256

@@ -16,18 +60,38 @@ impl AiProvider for CodexProvider {
1660
"Codex"
1761
}
1862

63+
async fn list_models(&self) -> BackendModelConfig {
64+
// Try reading from ~/.codex/models_cache.json (populated by the codex CLI)
65+
if let Some(path) = codex_models_cache_path() {
66+
if let Some(config) = parse_codex_models_cache(&path) {
67+
if !config.models.is_empty() {
68+
return config;
69+
}
70+
}
71+
}
72+
// Fallback
73+
BackendModelConfig {
74+
models: vec![
75+
ModelInfo { id: "o3".into(), label: "o3".into(), is_default: true },
76+
ModelInfo { id: "o4-mini".into(), label: "o4-mini".into(), is_default: false },
77+
],
78+
supports_freeform: true,
79+
current_model: Some("o3".into()),
80+
}
81+
}
82+
1983
async fn run(
2084
&self,
2185
working_dir: &Path,
2286
prompt: &str,
87+
model: Option<&str>,
2388
resume_session_id: Option<&str>,
2489
output_tx: mpsc::UnboundedSender<AiOutput>,
2590
mut abort: watch::Receiver<bool>,
2691
) -> anyhow::Result<()> {
2792
let mut cmd = Command::new("codex");
2893

2994
if let Some(id) = resume_session_id {
30-
// codex exec resume <thread-id> <prompt> --json ...
3195
cmd.args(["exec", "resume"])
3296
.arg(id)
3397
.arg(prompt)
@@ -39,6 +103,10 @@ impl AiProvider for CodexProvider {
39103
.arg("--json");
40104
}
41105

106+
if let Some(m) = model {
107+
cmd.arg("--model").arg(m);
108+
}
109+
42110
let mut child = cmd
43111
.current_dir(working_dir)
44112
.stdout(std::process::Stdio::piped())

crates/ralph-core/src/provider/copilot.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
1-
use std::path::Path;
1+
use std::path::{Path, PathBuf};
22
use std::time::Instant;
33

44
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
55
use tokio::process::Command;
66
use tokio::sync::{mpsc, watch};
77

8-
use super::{detect_rate_limit, parse_tool_invocation, AiOutput, AiProvider};
8+
use super::{detect_rate_limit, parse_tool_invocation, AiOutput, AiProvider, BackendModelConfig, ModelInfo};
9+
10+
fn copilot_config_path() -> Option<PathBuf> {
11+
dirs::home_dir().map(|h| h.join(".copilot").join("config.json"))
12+
}
13+
14+
fn read_copilot_current_model() -> Option<String> {
15+
let path = copilot_config_path()?;
16+
let data = std::fs::read_to_string(path).ok()?;
17+
let json: serde_json::Value = serde_json::from_str(&data).ok()?;
18+
json.get("model").and_then(|v| v.as_str()).map(|s| s.to_string())
19+
}
20+
21+
fn copilot_known_models() -> Vec<ModelInfo> {
22+
vec![
23+
ModelInfo { id: "claude-sonnet-4.6".into(), label: "Claude Sonnet 4.6".into(), is_default: false },
24+
ModelInfo { id: "claude-opus-4.6".into(), label: "Claude Opus 4.6".into(), is_default: false },
25+
ModelInfo { id: "gpt-5.2".into(), label: "GPT-5.2".into(), is_default: false },
26+
ModelInfo { id: "gpt-5-mini".into(), label: "GPT-5 mini".into(), is_default: false },
27+
ModelInfo { id: "gpt-4.1".into(), label: "GPT-4.1".into(), is_default: false },
28+
]
29+
}
930

1031
pub struct CopilotProvider;
1132

@@ -15,10 +36,41 @@ impl AiProvider for CopilotProvider {
1536
"Copilot"
1637
}
1738

39+
async fn list_models(&self) -> BackendModelConfig {
40+
let current = read_copilot_current_model();
41+
let mut models = copilot_known_models();
42+
43+
// Mark the current model as default if found in the list
44+
let matched = if let Some(cur) = &current {
45+
models.iter_mut().any(|m| {
46+
if m.id == *cur {
47+
m.is_default = true;
48+
true
49+
} else {
50+
false
51+
}
52+
})
53+
} else {
54+
false
55+
};
56+
if !matched {
57+
if let Some(m) = models.first_mut() {
58+
m.is_default = true;
59+
}
60+
}
61+
62+
BackendModelConfig {
63+
current_model: current,
64+
models,
65+
supports_freeform: true,
66+
}
67+
}
68+
1869
async fn run(
1970
&self,
2071
working_dir: &Path,
2172
prompt: &str,
73+
model: Option<&str>,
2274
resume_session_id: Option<&str>,
2375
output_tx: mpsc::UnboundedSender<AiOutput>,
2476
mut abort: watch::Receiver<bool>,
@@ -32,6 +84,10 @@ impl AiProvider for CopilotProvider {
3284
.arg("--output-format")
3385
.arg("json");
3486

87+
if let Some(m) = model {
88+
cmd.arg("--model").arg(m);
89+
}
90+
3591
if let Some(id) = resume_session_id {
3692
cmd.arg(format!("--resume={}", id));
3793
}

0 commit comments

Comments
 (0)