feat(ai): add Qoder CLI provider
This commit is contained in:
1 parent
da6e2738ce
commit
c72b429899
38 files changed
+1100
-19
No files matched your search
@@ -204,6 +204,14 @@ pub async fn run_agent_loop(
|
||||
);
|
||||
return crate::ai_codebuddy_cli::run_codebuddy_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::QoderCli) {
|
||||
let prompt = crate::ai_qoder_cli::build_qoder_prompt(
|
||||
system_prompt,
|
||||
messages,
|
||||
agent_ctx.sql_permissions.allow_writes,
|
||||
);
|
||||
return crate::ai_qoder_cli::run_qoder_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
let prompt =
|
||||
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
|
||||
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
|
||||
@@ -87,6 +87,8 @@ pub enum AiProvider {
|
||||
GrokCli,
|
||||
#[serde(rename = "codebuddy-cli")]
|
||||
CodeBuddyCli,
|
||||
#[serde(rename = "qoder-cli")]
|
||||
QoderCli,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -108,6 +110,7 @@ impl AiProvider {
|
||||
AiProvider::CursorCli => "cursor-cli",
|
||||
AiProvider::GrokCli => "grok-cli",
|
||||
AiProvider::CodeBuddyCli => "codebuddy-cli",
|
||||
AiProvider::QoderCli => "qoder-cli",
|
||||
AiProvider::CodexCli => "codex-cli",
|
||||
AiProvider::Custom => "custom",
|
||||
}
|
||||
@@ -415,6 +418,10 @@ pub struct AiConfig {
|
||||
pub codebuddy_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub codebuddy_cli_env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub qoder_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub qoder_cli_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_enable_thinking() -> bool {
|
||||
@@ -434,6 +441,7 @@ pub fn is_cli_provider(provider: &AiProvider) -> bool {
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli
|
||||
)
|
||||
}
|
||||
|
||||
@@ -667,6 +675,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli
|
||||
| AiProvider::Gemini => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -1518,6 +1527,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
||||
AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?,
|
||||
AiProvider::GrokCli => crate::ai_grok_cli::list_grok_models(config).await?,
|
||||
AiProvider::CodeBuddyCli => crate::ai_codebuddy_cli::list_codebuddy_models(config).await?,
|
||||
AiProvider::QoderCli => crate::ai_qoder_cli::list_qoder_models(config).await?,
|
||||
_ => {
|
||||
validate_model_list_config(config)?;
|
||||
let client = build_ai_http_client(config, 30)?;
|
||||
@@ -1546,7 +1556,8 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli => {
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli => {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
@@ -1575,6 +1586,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
|
||||
return crate::ai_codebuddy_cli::resolve_codebuddy_model_effort(config, model_id).await;
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::QoderCli) {
|
||||
return crate::ai_qoder_cli::resolve_qoder_model_effort(config, model_id).await;
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::CursorCli) {
|
||||
return Ok(AiEffortCapability::Unsupported);
|
||||
}
|
||||
@@ -2120,6 +2135,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
|
||||
if matches!(config.provider, AiProvider::CodeBuddyCli) {
|
||||
return crate::ai_codebuddy_cli::test_codebuddy_connection(config).await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::QoderCli) {
|
||||
return crate::ai_qoder_cli::test_qoder_connection(config).await;
|
||||
}
|
||||
let mut resolved_config = config.clone();
|
||||
if resolved_config.model.trim().is_empty() {
|
||||
let model = list_models_core(&resolved_config)
|
||||
@@ -2498,7 +2516,8 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli => {
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli => {
|
||||
unreachable!()
|
||||
}
|
||||
AiProvider::Openai
|
||||
@@ -2558,7 +2577,8 @@ pub async fn stream(
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli => {
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli => {
|
||||
unreachable!()
|
||||
}
|
||||
AiProvider::Openai
|
||||
@@ -4222,6 +4242,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
},
|
||||
system_prompt: "Be concise.".to_string(),
|
||||
messages: vec![AiMessage {
|
||||
@@ -4809,6 +4831,8 @@ mod tests {
|
||||
assert!(config.cursor_cli_env.is_empty());
|
||||
assert!(config.codebuddy_cli_path.is_none());
|
||||
assert!(config.codebuddy_cli_env.is_empty());
|
||||
assert!(config.qoder_cli_path.is_none());
|
||||
assert!(config.qoder_cli_env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4842,6 +4866,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let err = build_ai_http_client(&config, 1).unwrap_err();
|
||||
@@ -4880,6 +4906,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -4916,6 +4944,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -4952,6 +4982,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -4992,6 +5024,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
|
||||
@@ -5029,6 +5063,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
for provider in
|
||||
@@ -5085,6 +5121,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
|
||||
|
||||
@@ -5117,6 +5155,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
|
||||
}
|
||||
@@ -5152,6 +5192,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert!(uses_anthropic_messages_api(&config));
|
||||
@@ -5239,6 +5281,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert!(!uses_anthropic_messages_api(&config));
|
||||
@@ -5286,6 +5330,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
|
||||
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
|
||||
@@ -5357,6 +5403,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
|
||||
@@ -5400,6 +5448,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let api_key_headers = claude_headers(&config).unwrap();
|
||||
@@ -5523,6 +5573,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
|
||||
|
||||
@@ -5563,6 +5615,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
|
||||
@@ -5652,6 +5706,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let models = vec![
|
||||
AiModelInfo::new("qwen3:0.6b", None),
|
||||
@@ -5921,6 +5977,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
@@ -6140,6 +6198,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
@@ -6185,6 +6245,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6236,6 +6298,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6281,6 +6345,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6322,6 +6388,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6394,6 +6462,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let request = AiCompletionRequest {
|
||||
config: config.clone(),
|
||||
@@ -6460,6 +6530,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
@@ -6994,6 +7066,7 @@ mod tests {
|
||||
AiProvider::CursorCli,
|
||||
AiProvider::GrokCli,
|
||||
AiProvider::CodeBuddyCli,
|
||||
AiProvider::QoderCli,
|
||||
] {
|
||||
let mut config = test_config(provider.clone());
|
||||
config.max_retries = None;
|
||||
|
||||
@@ -589,6 +589,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum CliAgentJsonlDialect {
|
||||
CodexExec,
|
||||
ClaudeCodePrint,
|
||||
CodeBuddyPrint,
|
||||
QoderPrint,
|
||||
OpenCodeRun,
|
||||
CursorPrint,
|
||||
/// Grok Build headless `--output-format streaming-json` (ACP-derived NDJSON).
|
||||
@@ -240,12 +241,81 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA
|
||||
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
|
||||
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_compatible_jsonl_line(line, "Claude Code CLI failed"),
|
||||
CliAgentJsonlDialect::CodeBuddyPrint => parse_claude_compatible_jsonl_line(line, "CodeBuddy Code CLI failed"),
|
||||
CliAgentJsonlDialect::QoderPrint => parse_qoder_jsonl_line(line),
|
||||
CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
|
||||
CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line),
|
||||
CliAgentJsonlDialect::GrokStreamingJson => parse_grok_streaming_json_line(line),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_qoder_jsonl_line(line: &str) -> ParsedCliAgentEvent {
|
||||
let value = serde_json::from_str::<Value>(line).ok();
|
||||
if let Some(value) = value.as_ref().filter(|value| {
|
||||
value.get("type").and_then(Value::as_str) == Some("result")
|
||||
&& value.get("subtype").and_then(Value::as_str).unwrap_or("success") != "success"
|
||||
}) {
|
||||
let message = value
|
||||
.get("errors")
|
||||
.and_then(Value::as_array)
|
||||
.map(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|error| !error.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.filter(|message| !message.is_empty())
|
||||
.unwrap_or_else(|| claude_code_error_message(value, "Qoder CLI failed"));
|
||||
return ParsedCliAgentEvent {
|
||||
error: Some(message.clone()),
|
||||
events: vec![AgentEvent::Error { message }],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let compatible = parse_claude_compatible_jsonl_line(line, "Qoder CLI failed");
|
||||
if !compatible.events.is_empty()
|
||||
|| compatible.final_text.is_some()
|
||||
|| compatible.error.is_some()
|
||||
|| compatible.usage_delta.is_some()
|
||||
{
|
||||
return compatible;
|
||||
}
|
||||
|
||||
let Some(value) = value else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
if value.get("type").and_then(Value::as_str) != Some("stream_event") {
|
||||
return ParsedCliAgentEvent::default();
|
||||
}
|
||||
let Some(delta) = value.get("event").and_then(|event| event.get("delta")) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
match delta.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"text_delta" => {
|
||||
let Some(text) = delta.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
ParsedCliAgentEvent {
|
||||
final_text: Some(text.to_string()),
|
||||
events: vec![AgentEvent::TextDelta { delta: text.to_string() }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
"thinking_delta" => {
|
||||
let Some(text) = delta.get("thinking").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => ParsedCliAgentEvent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codex_jsonl_line(line: &str) -> ParsedCliAgentEvent {
|
||||
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
|
||||
@@ -888,6 +888,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1049,6 +1049,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -426,6 +426,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiE
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli => None,
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +199,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli
|
||||
| AiProvider::Custom => None,
|
||||
}
|
||||
}
|
||||
@@ -220,6 +222,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli
|
||||
) {
|
||||
return match selection {
|
||||
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
|
||||
@@ -278,7 +281,8 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli => {}
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +444,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -806,6 +806,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::GrokCli
|
||||
| AiProvider::CodeBuddyCli
|
||||
| AiProvider::QoderCli
|
||||
| AiProvider::MiniMax
|
||||
| AiProvider::Custom => true,
|
||||
}
|
||||
|
||||
@@ -472,6 +472,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -880,6 +880,8 @@ mod tests {
|
||||
grok_cli_env: HashMap::new(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: HashMap::new(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai_cli_agent::{
|
||||
build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos, parse_cli_jsonl_event,
|
||||
run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const QODER_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const QODER_SETTING_SOURCES: &str = "user";
|
||||
const DEFAULT_QODER_MODELS: &[&str] = &["default"];
|
||||
|
||||
pub type QoderRunOptions = CliAgentRunOptions;
|
||||
pub type QoderCommandSpec = CliAgentCommandSpec;
|
||||
|
||||
struct QoderIsolatedCwd {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl QoderIsolatedCwd {
|
||||
fn create() -> Result<Self, String> {
|
||||
let path = env::temp_dir().join(format!("dbx-qoder-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir(&path)
|
||||
.map_err(|error| format!("[qoderRunFailed] Failed to create isolated Qoder directory: {error}"))?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
fn write_mcp_config(&self, config: &str) -> Result<PathBuf, String> {
|
||||
let path = self.path.join("mcp.json");
|
||||
std::fs::write(&path, config)
|
||||
.map_err(|error| format!("[qoderRunFailed] Failed to write temporary Qoder MCP config: {error}"))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QoderIsolatedCwd {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn qoder_program(config: &AiConfig) -> String {
|
||||
config.qoder_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("qodercli").to_string()
|
||||
}
|
||||
|
||||
fn validate_qoder_program(config: &AiConfig) -> Result<String, String> {
|
||||
let program = qoder_program(config);
|
||||
if starts_with_env_assignment(&program) {
|
||||
return Err("[qoderCliPathInvalid] Qoder CLI path should contain only the executable path. Add environment variables in the Qoder CLI environment variables section.".to_string());
|
||||
}
|
||||
if is_path_like_program(&program) {
|
||||
let expanded = crate::path_utils::expand_tilde(&program);
|
||||
let path = Path::new(&expanded);
|
||||
if path.is_dir() {
|
||||
return launchable_program_in_dir(path, "qodercli").ok_or_else(|| {
|
||||
"[qoderCliPathInvalid] Qoder CLI path should point to the qodercli executable or a directory containing qodercli."
|
||||
.to_string()
|
||||
});
|
||||
}
|
||||
return Ok(expanded);
|
||||
}
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
fn launchable_program_in_dir(dir: &Path, program: &str) -> Option<String> {
|
||||
program_path_candidates(dir, program)
|
||||
.into_iter()
|
||||
.find(|candidate| is_launchable_program_path(candidate) && candidate.is_file())
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
|
||||
vec![dir.join(program)]
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
|
||||
let path = Path::new(program);
|
||||
if path.extension().is_some() {
|
||||
return vec![dir.join(program)];
|
||||
}
|
||||
[".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_launchable_program_path(_path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_launchable_program_path(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(),
|
||||
Some("exe" | "cmd" | "bat" | "com")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_path_like_program(program: &str) -> bool {
|
||||
program.contains('/') || program.contains('\\') || program.starts_with('~')
|
||||
}
|
||||
|
||||
fn starts_with_env_assignment(program: &str) -> bool {
|
||||
let Some(first_token) = program.split_whitespace().next() else {
|
||||
return false;
|
||||
};
|
||||
let Some((key, _)) = first_token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
is_env_var_name(key)
|
||||
}
|
||||
|
||||
fn is_env_var_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
pub fn qoder_cli_env(config: &AiConfig) -> Result<Vec<(String, String)>, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
for (key, value) in &config.qoder_cli_env {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_env_var_name(key) {
|
||||
return Err(format!(
|
||||
"[qoderEnvInvalid] Invalid Qoder CLI environment variable name `{key}`. Use names like HTTPS_PROXY or QODER_PERSONAL_ACCESS_TOKEN."
|
||||
));
|
||||
}
|
||||
if key.to_ascii_uppercase().starts_with("DBX_MCP_") {
|
||||
return Err(format!(
|
||||
"[qoderEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here."
|
||||
));
|
||||
}
|
||||
env.insert(key.to_string(), value.clone());
|
||||
}
|
||||
Ok(env.into_iter().collect())
|
||||
}
|
||||
|
||||
fn qoder_process_env(config: &AiConfig, command: &QoderCommandSpec) -> Result<Vec<(String, String)>, String> {
|
||||
let current_path = env::var("PATH").ok();
|
||||
qoder_process_env_with_path(config, command, current_path.as_deref())
|
||||
}
|
||||
|
||||
fn qoder_process_env_with_path(
|
||||
config: &AiConfig,
|
||||
command: &QoderCommandSpec,
|
||||
current_path: Option<&str>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
let mut process_env = BTreeMap::from_iter(qoder_cli_env(config)?);
|
||||
let mut paths = Vec::new();
|
||||
if let Some(dir) =
|
||||
Path::new(&command.program).parent().filter(|parent| !parent.as_os_str().is_empty()).map(Path::to_path_buf)
|
||||
{
|
||||
paths.push(dir);
|
||||
}
|
||||
if let Some(path) = process_env.get("PATH") {
|
||||
paths.extend(env::split_paths(path));
|
||||
}
|
||||
if let Some(path) = current_path {
|
||||
paths.extend(env::split_paths(path));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Ok(app_data) = env::var("APPDATA") {
|
||||
paths.push(PathBuf::from(app_data).join("npm"));
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
paths.extend([
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/usr/bin"),
|
||||
PathBuf::from("/bin"),
|
||||
PathBuf::from("/usr/sbin"),
|
||||
PathBuf::from("/sbin"),
|
||||
]);
|
||||
let mut seen = BTreeSet::new();
|
||||
paths.retain(|path| seen.insert(path.clone()));
|
||||
if let Ok(path) = env::join_paths(paths) {
|
||||
process_env.insert("PATH".to_string(), path.to_string_lossy().to_string());
|
||||
}
|
||||
Ok(process_env.into_iter().collect())
|
||||
}
|
||||
|
||||
pub fn qoder_enabled_tools(agent_mode: bool) -> Vec<String> {
|
||||
dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("mcp__dbx__{tool}")).collect()
|
||||
}
|
||||
|
||||
fn qoder_mcp_config(options: &QoderRunOptions) -> String {
|
||||
let mcp_command =
|
||||
options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server");
|
||||
let mut server = Map::new();
|
||||
server.insert("type".to_string(), Value::String("stdio".to_string()));
|
||||
server.insert("command".to_string(), Value::String(mcp_command.to_string()));
|
||||
if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) {
|
||||
server.insert("args".to_string(), json!(command.args));
|
||||
}
|
||||
let env = dbx_mcp_scope_env(options)
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name.to_string(), Value::String(value)))
|
||||
.collect::<Map<_, _>>();
|
||||
server.insert("env".to_string(), Value::Object(env));
|
||||
json!({ "mcpServers": { "dbx": Value::Object(server) } }).to_string()
|
||||
}
|
||||
|
||||
pub fn build_qoder_command(config: &AiConfig, _prompt: &str, options: &QoderRunOptions) -> QoderCommandSpec {
|
||||
build_qoder_command_with_mcp_arg(config, options, qoder_mcp_config(options))
|
||||
}
|
||||
|
||||
fn build_qoder_command_with_mcp_arg(
|
||||
config: &AiConfig,
|
||||
options: &QoderRunOptions,
|
||||
mcp_config_arg: String,
|
||||
) -> QoderCommandSpec {
|
||||
let enabled_tools = qoder_enabled_tools(options.agent_mode);
|
||||
let tool_list = enabled_tools.join(",");
|
||||
let mut args = vec![
|
||||
"--print".to_string(),
|
||||
"--output-format".to_string(),
|
||||
"stream-json".to_string(),
|
||||
"--input-format".to_string(),
|
||||
"text".to_string(),
|
||||
"--no-session-persistence".to_string(),
|
||||
"--permission-mode".to_string(),
|
||||
"dont_ask".to_string(),
|
||||
"--mcp-config".to_string(),
|
||||
mcp_config_arg,
|
||||
"--strict-mcp-config".to_string(),
|
||||
"--allowed-mcp-server-names".to_string(),
|
||||
"dbx".to_string(),
|
||||
"--setting-sources".to_string(),
|
||||
QODER_SETTING_SOURCES.to_string(),
|
||||
"--tools".to_string(),
|
||||
tool_list.clone(),
|
||||
"--allowed-tools".to_string(),
|
||||
tool_list,
|
||||
];
|
||||
|
||||
let model = config.model.trim();
|
||||
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
|
||||
args.push("--model".to_string());
|
||||
args.push(model.to_string());
|
||||
}
|
||||
if let Some(effort) = config.runtime_effort.as_ref().and_then(|effort| effort.cli_value()) {
|
||||
args.push("--reasoning-effort".to_string());
|
||||
args.push(effort);
|
||||
}
|
||||
if let Some(context_window) = config.context_window.filter(|value| *value > 0) {
|
||||
args.push("--context-window".to_string());
|
||||
args.push(context_window.to_string());
|
||||
}
|
||||
|
||||
QoderCommandSpec { program: qoder_program(config), args }
|
||||
}
|
||||
|
||||
pub fn build_qoder_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String {
|
||||
build_cli_agent_prompt("Qoder", system_prompt, messages, allow_write_sql)
|
||||
}
|
||||
|
||||
pub async fn list_qoder_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
|
||||
let output = run_qoder_model_listing(config).await?;
|
||||
Ok(parse_qoder_models(&String::from_utf8_lossy(&output.stdout)))
|
||||
}
|
||||
|
||||
async fn run_qoder_model_listing(config: &AiConfig) -> Result<std::process::Output, String> {
|
||||
let program = validate_qoder_program(config)?;
|
||||
let command = QoderCommandSpec {
|
||||
program,
|
||||
args: vec!["--list-models".to_string(), "--setting-sources".to_string(), QODER_SETTING_SOURCES.to_string()],
|
||||
};
|
||||
let isolated_cwd = QoderIsolatedCwd::create()?;
|
||||
let mut process = cli_command(&command.program);
|
||||
process
|
||||
.args(command.args.iter().map(String::as_str))
|
||||
.envs(qoder_process_env(config, &command)?.iter().map(|(key, value)| (key.as_str(), value.as_str())))
|
||||
.current_dir(&isolated_cwd.path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(QODER_MODEL_DISCOVERY_TIMEOUT, process.output())
|
||||
.await
|
||||
.map_err(|_| "[qoderTimeout] Qoder model discovery timed out.".to_string())?
|
||||
.map_err(|error| classify_qoder_spawn_error(&error.to_string()))?;
|
||||
if output.status.success() {
|
||||
Ok(output)
|
||||
} else {
|
||||
let diagnostic = [String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout)]
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Err(classify_qoder_run_error(&diagnostic))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_qoder_models(stdout: &str) -> Vec<AiModelInfo> {
|
||||
let mut ids = Vec::new();
|
||||
if let Ok(value) = serde_json::from_str::<Value>(stdout.trim()) {
|
||||
collect_qoder_model_ids(&value, &mut ids);
|
||||
} else {
|
||||
for line in stdout.lines() {
|
||||
let Ok(value) = serde_json::from_str::<Value>(line.trim()) else {
|
||||
continue;
|
||||
};
|
||||
collect_qoder_model_ids(&value, &mut ids);
|
||||
}
|
||||
}
|
||||
|
||||
// `--list-models` is documented as a human-readable table. Accept model
|
||||
// identifiers only from table cells or one-item bullet rows so labels and
|
||||
// descriptions cannot accidentally become selectable model IDs.
|
||||
let mut in_single_model_column = false;
|
||||
for line in stdout.lines() {
|
||||
if line.trim().eq_ignore_ascii_case("model") {
|
||||
in_single_model_column = true;
|
||||
continue;
|
||||
}
|
||||
collect_qoder_table_model_ids(line, in_single_model_column, &mut ids);
|
||||
}
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids = ids
|
||||
.into_iter()
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty() && seen.insert(id.to_ascii_lowercase()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut models = model_infos(DEFAULT_QODER_MODELS);
|
||||
models
|
||||
.extend(ids.into_iter().filter(|id| !id.eq_ignore_ascii_case("default")).map(|id| AiModelInfo::new(id, None)));
|
||||
models
|
||||
}
|
||||
|
||||
fn collect_qoder_table_model_ids(line: &str, allow_plain_name: bool, ids: &mut Vec<String>) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.chars().all(is_qoder_table_border) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cells = if line.contains('│') || line.contains('|') {
|
||||
line.split(['│', '|']).map(str::trim).filter(|cell| !cell.is_empty()).collect::<Vec<_>>()
|
||||
} else if line.contains('\t') {
|
||||
line.split('\t').map(str::trim).filter(|cell| !cell.is_empty()).collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![line.trim_start_matches(['-', '*', '•']).trim()]
|
||||
};
|
||||
|
||||
if let Some(id) = cells.iter().find_map(|cell| qoder_model_id_from_cell(cell, allow_plain_name)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_qoder_table_border(ch: char) -> bool {
|
||||
ch.is_whitespace()
|
||||
|| matches!(ch, '-' | '=' | '+' | '─' | '━' | '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' | '┼' | '│')
|
||||
}
|
||||
|
||||
fn qoder_model_id_from_cell(cell: &str, allow_plain_name: bool) -> Option<String> {
|
||||
let candidate = cell.trim().trim_matches(['`', '*']);
|
||||
if candidate.is_empty() || candidate.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let lower = candidate.to_ascii_lowercase();
|
||||
if ["auto", "ultimate", "performance", "efficient", "lite"].contains(&lower.as_str()) {
|
||||
return Some(lower);
|
||||
}
|
||||
if matches!(lower.as_str(), "model" | "model-id" | "model_id" | "id" | "name" | "type" | "tier")
|
||||
|| !candidate.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
||||
|| !allow_plain_name && !candidate.chars().any(|ch| ch.is_ascii_digit())
|
||||
|| !candidate.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '@'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(candidate.to_string())
|
||||
}
|
||||
|
||||
fn collect_qoder_model_ids(value: &Value, ids: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
match item {
|
||||
Value::String(id) => ids.push(id.to_string()),
|
||||
Value::Object(fields) => {
|
||||
if let Some(id) = fields
|
||||
.get("id")
|
||||
.or_else(|| fields.get("value"))
|
||||
.or_else(|| fields.get("model"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
for key in ["models", "data", "items"] {
|
||||
if let Some(models) = fields.get(key) {
|
||||
collect_qoder_model_ids(models, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_qoder_model_effort(_config: &AiConfig, _model_id: &str) -> Result<AiEffortCapability, String> {
|
||||
// Qoder model capabilities are account-dependent and `--list-models` does
|
||||
// not currently document a stable machine-readable effort schema.
|
||||
Ok(AiEffortCapability::Unsupported)
|
||||
}
|
||||
|
||||
pub async fn test_qoder_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
|
||||
let start = Instant::now();
|
||||
let _ = run_qoder_model_listing(config).await?;
|
||||
let elapsed = start.elapsed().as_millis() as u64;
|
||||
Ok(AiTestConnectionResult {
|
||||
success: true,
|
||||
message: format!("OK - {elapsed}ms"),
|
||||
latency_ms: Some(elapsed),
|
||||
model_used: config.model.trim().to_string(),
|
||||
error_category: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_qoder_spawn_error(message: &str) -> String {
|
||||
if message.contains("No such file") || message.contains("not found") || message.contains("os error 2") {
|
||||
format!("[qoderNotInstalled] Qoder CLI was not found. Install qodercli or set its executable path in DBX AI settings. {message}")
|
||||
} else {
|
||||
format!("[qoderRunFailed] Failed to start Qoder CLI: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_qoder_run_error(diagnostic: &str) -> String {
|
||||
let diagnostic = sanitize_diagnostic(diagnostic);
|
||||
let lower = diagnostic.to_ascii_lowercase();
|
||||
if lower.contains("not authenticated")
|
||||
|| lower.contains("authentication required")
|
||||
|| lower.contains("please login")
|
||||
|| lower.contains("please log in")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("access token") && (lower.contains("invalid") || lower.contains("expired"))
|
||||
{
|
||||
format!("[qoderNotAuthenticated] {diagnostic}")
|
||||
} else if lower.contains("invalid mcp") || lower.contains("mcp config") && lower.contains("invalid") {
|
||||
format!("[qoderMcpConfigInvalid] {diagnostic}")
|
||||
} else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
|
||||
format!("[dbxMcpMissing] {diagnostic}")
|
||||
} else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
|
||||
format!("[qoderMcpStartupFailed] {diagnostic}")
|
||||
} else if lower.contains("protocol") || lower.contains("invalid json") || lower.contains("parse") {
|
||||
format!("[qoderProtocolError] {diagnostic}")
|
||||
} else {
|
||||
format!("[qoderRunFailed] {diagnostic}")
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_diagnostic(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return "Qoder CLI exited without diagnostics.".to_string();
|
||||
}
|
||||
value.chars().take(2_000).collect()
|
||||
}
|
||||
|
||||
pub fn parse_qoder_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
|
||||
parse_cli_jsonl_event(line, CliAgentJsonlDialect::QoderPrint)
|
||||
}
|
||||
|
||||
pub async fn run_qoder_agent(
|
||||
config: &AiConfig,
|
||||
prompt: &str,
|
||||
options: QoderRunOptions,
|
||||
cancelled: &Notify,
|
||||
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
|
||||
) -> Result<String, String> {
|
||||
let program = validate_qoder_program(config)?;
|
||||
let isolated_cwd = QoderIsolatedCwd::create()?;
|
||||
let mcp_config_path = isolated_cwd.write_mcp_config(&qoder_mcp_config(&options))?;
|
||||
let mut command = build_qoder_command_with_mcp_arg(config, &options, mcp_config_path.to_string_lossy().to_string());
|
||||
command.program = program;
|
||||
let env = qoder_process_env(config, &command)?;
|
||||
run_cli_jsonl_agent(
|
||||
CliAgentProcessSpec {
|
||||
command,
|
||||
env,
|
||||
env_remove: Vec::new(),
|
||||
current_dir: Some(isolated_cwd.path.clone()),
|
||||
stdin: Some(prompt.to_string()),
|
||||
dialect: CliAgentJsonlDialect::QoderPrint,
|
||||
classify_spawn_error: classify_qoder_spawn_error,
|
||||
classify_run_error: classify_qoder_run_error,
|
||||
},
|
||||
cancelled,
|
||||
on_event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_qoder_command, classify_qoder_run_error, parse_qoder_jsonl_event, parse_qoder_models, qoder_cli_env,
|
||||
qoder_process_env_with_path, QoderCommandSpec, QoderRunOptions,
|
||||
};
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortSelection, AiProvider, AiReasoningLevel};
|
||||
|
||||
fn config(model: &str) -> AiConfig {
|
||||
AiConfig {
|
||||
provider: AiProvider::QoderCli,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: String::new(),
|
||||
model: model.to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
max_retries: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
grok_cli_path: None,
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> QoderRunOptions {
|
||||
QoderRunOptions {
|
||||
connection_id: "conn-1".to_string(),
|
||||
connection_name: "local".to_string(),
|
||||
database: "demo".to_string(),
|
||||
schema: Some("public".to_string()),
|
||||
agent_mode: true,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_scoped_headless_qoder_command() {
|
||||
let mut config = config("performance");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string()));
|
||||
config.context_window = Some(400_000);
|
||||
let command = build_qoder_command(&config, "prompt stays on stdin", &options());
|
||||
assert_eq!(command.program, "qodercli");
|
||||
assert!(command.args.windows(2).any(|args| args == ["--output-format", "stream-json"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--permission-mode", "dont_ask"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--allowed-mcp-server-names", "dbx"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--setting-sources", "user"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--model", "performance"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--reasoning-effort", "high"]));
|
||||
assert!(command.args.windows(2).any(|args| args == ["--context-window", "400000"]));
|
||||
assert!(!command.args.iter().any(|arg| arg == "prompt stays on stdin"));
|
||||
let tools = command.args[command.args.iter().position(|arg| arg == "--tools").unwrap() + 1].clone();
|
||||
assert!(tools.contains("mcp__dbx__dbx_execute_query"));
|
||||
assert!(!tools.contains("Bash"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn bare_qoder_command_uses_configured_gui_and_common_paths() {
|
||||
let mut config = config("default");
|
||||
config.qoder_cli_env.insert("PATH".to_string(), "/configured/bin".to_string());
|
||||
let command = QoderCommandSpec { program: "qodercli".to_string(), args: Vec::new() };
|
||||
|
||||
let process_env = qoder_process_env_with_path(&config, &command, Some("/restricted/gui/bin")).unwrap();
|
||||
let path = process_env.iter().find(|(key, _)| key == "PATH").map(|(_, value)| value).unwrap();
|
||||
let paths = std::env::split_paths(path).collect::<Vec<_>>();
|
||||
|
||||
assert!(paths.contains(&std::path::PathBuf::from("/configured/bin")));
|
||||
assert!(paths.contains(&std::path::PathBuf::from("/restricted/gui/bin")));
|
||||
assert!(paths.contains(&std::path::PathBuf::from("/opt/homebrew/bin")));
|
||||
assert!(paths.contains(&std::path::PathBuf::from("/usr/local/bin")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_default_model_implicit() {
|
||||
let command = build_qoder_command(&config("default"), "hello", &options());
|
||||
assert!(!command.args.contains(&"--model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_environment_and_reserves_dbx_scope() {
|
||||
let mut config = config("default");
|
||||
config.qoder_cli_env.insert("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "secret".to_string());
|
||||
assert_eq!(
|
||||
qoder_cli_env(&config).unwrap(),
|
||||
vec![("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "secret".to_string())]
|
||||
);
|
||||
config.qoder_cli_env.insert("DBX_MCP_SCOPE_DATABASE".to_string(), "other".to_string());
|
||||
assert!(qoder_cli_env(&config).unwrap_err().starts_with("[qoderEnvReserved]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qoder_provider_config_roundtrips_with_cli_credentials() {
|
||||
let mut config = config("performance");
|
||||
config.qoder_cli_path = Some("/opt/homebrew/bin/qodercli".to_string());
|
||||
config.qoder_cli_env.insert("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "token".to_string());
|
||||
|
||||
let decoded: AiConfig = serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap();
|
||||
|
||||
assert!(matches!(decoded.provider, AiProvider::QoderCli));
|
||||
assert_eq!(decoded.qoder_cli_path.as_deref(), Some("/opt/homebrew/bin/qodercli"));
|
||||
assert_eq!(decoded.qoder_cli_env.get("QODER_PERSONAL_ACCESS_TOKEN").map(String::as_str), Some("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_json_and_human_readable_model_lists() {
|
||||
let models = parse_qoder_models(r#"{"models":[{"id":"auto"},{"value":"custom/model"}]}"#);
|
||||
assert_eq!(
|
||||
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
|
||||
["default", "auto", "custom/model"]
|
||||
);
|
||||
let models = parse_qoder_models(
|
||||
"MODEL\nAuto\nUltimate\nPerformance\nEfficient\nLite\nCantus\nQwen3.8-Max-Preview\nDeepSeek-V4-Pro",
|
||||
);
|
||||
assert_eq!(
|
||||
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
|
||||
[
|
||||
"default",
|
||||
"auto",
|
||||
"ultimate",
|
||||
"performance",
|
||||
"efficient",
|
||||
"lite",
|
||||
"Cantus",
|
||||
"Qwen3.8-Max-Preview",
|
||||
"DeepSeek-V4-Pro"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_authentication_and_mcp_failures() {
|
||||
assert!(classify_qoder_run_error("Please login first").starts_with("[qoderNotAuthenticated]"));
|
||||
assert!(classify_qoder_run_error("MCP server dbx failed to start").starts_with("[qoderMcpStartupFailed]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_qoder_assistant_and_stream_deltas() {
|
||||
let assistant = parse_qoder_jsonl_event(
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"},{"type":"thinking","thinking":"plan"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(assistant.iter().any(|event| matches!(event, AgentEvent::TextDelta { delta } if delta == "hello")));
|
||||
assert!(assistant.iter().any(|event| matches!(event, AgentEvent::ReasoningDelta { delta } if delta == "plan")));
|
||||
|
||||
let delta = parse_qoder_jsonl_event(
|
||||
r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"partial"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&delta[0], AgentEvent::TextDelta { delta } if delta == "partial"));
|
||||
|
||||
let error = parse_qoder_jsonl_event(
|
||||
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["Network attempt failed"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&error[0], AgentEvent::Error { message } if message == "Network attempt failed"));
|
||||
}
|
||||
}
|
||||
@@ -1637,6 +1637,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod ai_grok_cli;
|
||||
mod ai_model_filter;
|
||||
pub mod ai_opencode_cli;
|
||||
pub mod ai_pi_agent_cli;
|
||||
pub mod ai_qoder_cli;
|
||||
pub mod backend_error;
|
||||
pub mod changelog;
|
||||
pub mod cloud_sync;
|
||||
|
||||
@@ -5480,6 +5480,8 @@ mod tests {
|
||||
grok_cli_env: std::collections::HashMap::new(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: std::collections::HashMap::new(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +535,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +550,7 @@ mod tests {
|
||||
AiProvider::CursorCli,
|
||||
AiProvider::GrokCli,
|
||||
AiProvider::CodeBuddyCli,
|
||||
AiProvider::QoderCli,
|
||||
] {
|
||||
let config = make_config(provider);
|
||||
assert!(reject_web_unsupported_ai_provider(&config).is_err());
|
||||
@@ -638,6 +641,8 @@ mod tests {
|
||||
grok_cli_env: Default::default(),
|
||||
codebuddy_cli_path: None,
|
||||
codebuddy_cli_env: Default::default(),
|
||||
qoder_cli_path: None,
|
||||
qoder_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let body = super::AiTestConnectionRequest { config };
|
||||
|
||||
Reference in new issue
Block a user