use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_yaml::Value as YamlValue;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct SddConfig {
pub providers: ProviderConfig,
pub runtime: RuntimeConfig,
pub memory: MemoryConfig,
pub intelligence: IntelligenceConfig,
pub optimization: OptimizationConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderConfig {
pub default: Option<String>,
pub model: Option<String>,
pub effort: Option<String>,
pub offline: Option<bool>,
pub registry: BTreeMap<String, ProviderDefinition>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderDefinition {
pub kind: String,
pub base_url: Option<String>,
pub auth_env: Option<String>,
pub auth_methods: Vec<ProviderAuthDefinition>,
pub model: Option<String>,
pub models: Vec<ProviderModelDefinition>,
pub effort: Option<String>,
pub efforts: Vec<String>,
pub stage_models: BTreeMap<String, String>,
pub stage_efforts: BTreeMap<String, String>,
pub capabilities: Vec<String>,
pub timeouts: ProviderTimeouts,
pub retries: ProviderRetries,
pub token_budget: Option<u32>,
pub usage_limits: Vec<ProviderUsageLimit>,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for ProviderDefinition {
fn default() -> Self {
Self {
kind: "custom".to_string(),
base_url: None,
auth_env: None,
auth_methods: Vec::new(),
model: None,
models: Vec::new(),
effort: None,
efforts: Vec::new(),
stage_models: BTreeMap::new(),
stage_efforts: BTreeMap::new(),
capabilities: Vec::new(),
timeouts: ProviderTimeouts::default(),
retries: ProviderRetries::default(),
token_budget: None,
usage_limits: Vec::new(),
enabled: true,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderModelDefinition {
pub id: String,
pub label: String,
pub context_window_tokens: Option<u32>,
pub pricing: ProviderModelPricing,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(default)]
pub struct ProviderModelPricing {
pub currency: Option<String>,
pub input_per_million: Option<f64>,
pub cached_input_per_million: Option<f64>,
pub output_per_million: Option<f64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderAuthDefinition {
pub id: String,
pub label: String,
pub kind: String,
pub env: Option<String>,
pub command: Option<String>,
pub check_command: Option<String>,
pub check_success_text: Option<String>,
pub check_success_regex: Option<String>,
pub login_command: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderUsageLimit {
pub window: String,
pub limit: Option<f64>,
pub remaining: Option<f64>,
pub unit: String,
pub resets_at: Option<String>,
pub source: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderTimeouts {
pub request_ms: u64,
}
impl Default for ProviderTimeouts {
fn default() -> Self {
Self { request_ms: 30_000 }
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProviderRetries {
pub max_attempts: u8,
}
impl Default for ProviderRetries {
fn default() -> Self {
Self { max_attempts: 1 }
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct RuntimeConfig {
pub node_effect_bridge: NodeEffectBridgeConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct NodeEffectBridgeConfig {
pub enabled: bool,
pub command: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct MemoryConfig {
#[serde(default = "default_true")]
pub learning_enabled: bool,
pub index_path: String,
#[serde(default = "default_true")]
pub redact_secrets: bool,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
learning_enabled: true,
index_path: ".sdd/memory/learnings.jsonl".to_string(),
redact_secrets: true,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct IntelligenceConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub index_dir: String,
pub context_pack_dir: String,
pub max_context_chars: usize,
#[serde(default = "default_true")]
pub redact_secrets: bool,
pub context_materialization: ContextMaterializationConfig,
pub code_intelligence: CodeIntelligenceConfig,
}
impl Default for IntelligenceConfig {
fn default() -> Self {
Self {
enabled: true,
index_dir: ".sdd/intelligence".to_string(),
context_pack_dir: ".sdd/intelligence/context-packs".to_string(),
max_context_chars: 24_000,
redact_secrets: true,
context_materialization: ContextMaterializationConfig::default(),
code_intelligence: CodeIntelligenceConfig::default(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ContextMaterializationConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub mode: String,
pub targets: Vec<String>,
pub local_agents: bool,
pub max_boundaries: usize,
pub excludes: Vec<String>,
}
impl Default for ContextMaterializationConfig {
fn default() -> Self {
Self {
enabled: true,
mode: "proposal".to_string(),
targets: vec![
"agents".to_string(),
"claude".to_string(),
"cursor".to_string(),
"antigravity".to_string(),
],
local_agents: false,
max_boundaries: crate::runtime::agent_context::DEFAULT_MAX_BOUNDARIES,
excludes: vec![
".agents".to_string(),
".codex".to_string(),
".claude".to_string(),
".cursor".to_string(),
".devin".to_string(),
".opencode".to_string(),
".trae".to_string(),
".git".to_string(),
".sdd".to_string(),
"target".to_string(),
"node_modules".to_string(),
"dist".to_string(),
"build".to_string(),
".next".to_string(),
],
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CodeIntelligenceConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub preferred: String,
pub providers: BTreeMap<String, CodeIntelligenceProviderConfig>,
}
impl Default for CodeIntelligenceConfig {
fn default() -> Self {
let mut providers = BTreeMap::new();
providers.insert(
"codegraph".to_string(),
CodeIntelligenceProviderConfig {
command: "codegraph".to_string(),
index_marker: ".codegraph/".to_string(),
},
);
providers.insert(
"lexa".to_string(),
CodeIntelligenceProviderConfig {
command: "lexa".to_string(),
index_marker: ".lexa/graph.lexa".to_string(),
},
);
Self {
enabled: true,
preferred: "auto".to_string(),
providers,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CodeIntelligenceProviderConfig {
pub command: String,
pub index_marker: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct OptimizationConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub codegraph: OptimizationCodeGraphConfig,
pub rtk: OptimizationToolConfig,
pub caveman: OptimizationCavemanConfig,
pub ignored_paths: Vec<String>,
}
impl Default for OptimizationConfig {
fn default() -> Self {
Self {
enabled: true,
codegraph: OptimizationCodeGraphConfig::default(),
rtk: OptimizationToolConfig {
enabled: "auto".to_string(),
command: "rtk".to_string(),
},
caveman: OptimizationCavemanConfig::default(),
ignored_paths: Vec::new(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct OptimizationCodeGraphConfig {
#[serde(default = "default_codegraph_mode")]
pub mode: String,
#[serde(default = "default_codegraph_command")]
pub command: String,
#[serde(default = "default_codegraph_index_marker")]
pub index_marker: String,
pub auto_index: bool,
}
impl Default for OptimizationCodeGraphConfig {
fn default() -> Self {
Self {
mode: "adaptive".to_string(),
command: "codegraph".to_string(),
index_marker: ".codegraph/".to_string(),
auto_index: true,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct OptimizationToolConfig {
#[serde(default = "default_auto")]
pub enabled: String,
#[serde(default = "default_rtk_command")]
pub command: String,
}
impl Default for OptimizationToolConfig {
fn default() -> Self {
Self {
enabled: "auto".to_string(),
command: "rtk".to_string(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct OptimizationCavemanConfig {
#[serde(default = "default_auto")]
pub enabled: String,
#[serde(default = "default_caveman_command")]
pub command: String,
#[serde(default = "default_caveman_apply_to")]
pub apply_to: Vec<String>,
}
impl Default for OptimizationCavemanConfig {
fn default() -> Self {
Self {
enabled: "auto".to_string(),
command: "caveman".to_string(),
apply_to: vec![
"trace".to_string(),
"handoff".to_string(),
"memory-derived".to_string(),
],
}
}
}
fn default_auto() -> String {
"auto".to_string()
}
fn default_codegraph_mode() -> String {
"adaptive".to_string()
}
fn default_codegraph_command() -> String {
"codegraph".to_string()
}
fn default_codegraph_index_marker() -> String {
".codegraph/".to_string()
}
fn default_rtk_command() -> String {
"rtk".to_string()
}
fn default_caveman_command() -> String {
"caveman".to_string()
}
fn default_caveman_apply_to() -> Vec<String> {
vec![
"trace".to_string(),
"handoff".to_string(),
"memory-derived".to_string(),
]
}
#[derive(Clone, Debug, Serialize)]
pub struct ProviderSelection {
pub id: String,
pub kind: String,
pub model: String,
pub effort: String,
pub offline: bool,
pub enabled: bool,
pub auth_env: Option<String>,
pub auth_present: Option<bool>,
pub auth_methods: Vec<ProviderAuthStatus>,
pub models: Vec<ProviderModelDefinition>,
pub efforts: Vec<String>,
pub stage_models: BTreeMap<String, String>,
pub stage_efforts: BTreeMap<String, String>,
pub capabilities: Vec<String>,
pub token_budget: Option<u32>,
pub usage_limits: Vec<ProviderUsageLimit>,
}
impl ProviderSelection {
pub fn effective_model_for_stage(&self, stage: &str) -> &str {
provider_stage_lookup_keys(stage)
.iter()
.find_map(|key| self.stage_models.get(*key).map(String::as_str))
.unwrap_or(self.model.as_str())
}
pub fn effective_effort_for_stage(&self, stage: &str) -> &str {
provider_stage_lookup_keys(stage)
.iter()
.find_map(|key| self.stage_efforts.get(*key).map(String::as_str))
.unwrap_or(self.effort.as_str())
}
}
#[derive(Clone, Debug, Default)]
pub struct ProviderResolveOptions {
pub provider: Option<String>,
pub model: Option<String>,
pub effort: Option<String>,
pub offline: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderAuthCheckMode {
Fast,
Full,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProviderCatalogEntry {
pub id: String,
pub kind: String,
pub model: Option<String>,
pub effort: Option<String>,
pub enabled: bool,
pub auth_env: Option<String>,
pub auth_present: Option<bool>,
pub auth_methods: Vec<ProviderAuthStatus>,
pub models: Vec<ProviderModelDefinition>,
pub efforts: Vec<String>,
pub stage_models: BTreeMap<String, String>,
pub stage_efforts: BTreeMap<String, String>,
pub capabilities: Vec<String>,
pub token_budget: Option<u32>,
pub usage_limits: Vec<ProviderUsageLimit>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProviderAuthStatus {
pub id: String,
pub label: String,
pub kind: String,
pub source: String,
pub present: bool,
pub check_command: Option<String>,
pub login_command: Option<String>,
pub detail: Option<String>,
}
pub fn builtin_provider_registry() -> BTreeMap<String, ProviderDefinition> {
let mut registry = BTreeMap::new();
registry.insert(
"claude".to_string(),
provider(
"anthropic",
Some("ANTHROPIC_API_KEY"),
Some("claude-sonnet-4-6"),
Some("xhigh"),
&[
model_choice("claude-haiku-4-5", "Claude Haiku 4.5"),
model_choice("claude-sonnet-4-6", "Claude Sonnet 4.6"),
model_choice("claude-opus-4-8", "Claude Opus 4.8"),
],
&["medium", "high", "xhigh"],
&[
("idea", "claude-sonnet-4-6"),
("prd", "claude-sonnet-4-6"),
("techspec", "claude-opus-4-8"),
("tasks", "claude-sonnet-4-6"),
("refinement", "claude-sonnet-4-6"),
("execution", "claude-sonnet-4-6"),
("review", "claude-opus-4-8"),
("memory", "claude-sonnet-4-6"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools"],
&[
cli_auth_checked(
"claude-code",
"Claude Code",
"claude",
"claude auth status",
Some("\"loggedIn\": true"),
None,
"claude auth login",
),
env_auth("anthropic-api", "Anthropic API", "ANTHROPIC_API_KEY"),
],
),
);
registry.insert(
"codex".to_string(),
provider(
"openai-compatible",
Some("OPENAI_API_KEY"),
Some("gpt-5.5"),
Some("xhigh"),
&[
model_choice("gpt-5.4", "Codex / GPT-5.4"),
model_choice("gpt-5.5", "Codex / GPT-5.5"),
],
&["medium", "high", "xhigh"],
&[
("idea", "gpt-5.4"),
("prd", "gpt-5.4"),
("techspec", "gpt-5.5"),
("tasks", "gpt-5.4"),
("refinement", "gpt-5.4"),
("execution", "gpt-5.5"),
("review", "gpt-5.5"),
("memory", "gpt-5.4"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools", "code"],
&[
cli_auth_checked(
"codex-cli",
"Codex CLI",
"codex",
"codex login status",
Some("Logged in"),
None,
"codex login",
),
env_auth("openai-api", "OpenAI API", "OPENAI_API_KEY"),
],
),
);
registry.insert(
"antigravity".to_string(),
provider(
"local-cli",
Some("GEMINI_API_KEY"),
Some("gemini-3.1-pro"),
Some("high"),
&[
model_choice("gemini-3.5-flash", "Gemini 3.5 Flash"),
model_choice("gemini-3.1-pro", "Gemini 3.1 Pro"),
model_choice("claude-opus-4.6", "Claude Opus 4.6 via Antigravity"),
],
&["medium", "high", "xhigh"],
&[
("idea", "gemini-3.5-flash"),
("prd", "gemini-3.1-pro"),
("techspec", "gemini-3.1-pro"),
("tasks", "gemini-3.1-pro"),
("refinement", "gemini-3.1-pro"),
("execution", "gemini-3.1-pro"),
("review", "gemini-3.1-pro"),
("memory", "gemini-3.5-flash"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat"],
&[
cli_auth_checked(
"antigravity-cli",
"Antigravity CLI",
"agy",
"agy models",
None,
None,
"agy",
),
env_auth("google-ai-api", "Google AI API", "GEMINI_API_KEY"),
],
),
);
registry.insert(
"opencode".to_string(),
provider(
"local-cli",
None,
Some("opencode-go/deepseek-v4-pro"),
Some("xhigh"),
&[
model_choice("opencode/deepseek-v4-flash", "Zen / DeepSeek v4 Flash"),
model_choice("opencode/kimi-k2.7", "Zen / Kimi K2.7"),
model_choice("opencode/minimax-m3-free", "Zen / MiniMax M3"),
model_choice("opencode/mimo-v2.5-free", "Zen / MiMo v2.5"),
model_choice("opencode/glm-5.1", "Zen / GLM 5.1"),
model_choice("opencode-go/deepseek-v4-flash", "Go / DeepSeek v4 Flash"),
model_choice("opencode-go/deepseek-v4-pro", "Go / DeepSeek v4 Pro"),
model_choice("opencode-go/kimi-k2.7", "Go / Kimi K2.7"),
model_choice("opencode-go/minimax-m3", "Go / MiniMax M3"),
model_choice("opencode-go/mimo-v2.5", "Go / MiMo v2.5"),
model_choice("opencode-go/glm-5.1", "Go / GLM 5.1"),
model_choice("opencode-go/qwen3.7-plus", "Go / Qwen 3.7 Plus"),
model_choice("opencode-go/qwen3.7-max", "Go / Qwen 3.7 Max"),
],
&["medium", "high", "xhigh"],
&[
("project-discovery", "opencode-go/kimi-k2.7"),
("idea", "opencode-go/deepseek-v4-flash"),
("prd", "opencode-go/deepseek-v4-pro"),
("techspec", "opencode-go/kimi-k2.7"),
("tasks", "opencode-go/qwen3.7-plus"),
("refinement", "opencode-go/qwen3.7-plus"),
("execution", "opencode-go/kimi-k2.7"),
("review", "opencode-go/glm-5.1"),
("memory", "opencode-go/minimax-m3"),
],
&[
("project-discovery", "xhigh"),
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "code"],
&[
cli_auth_checked(
"opencode-cli",
"opencode CLI",
"opencode",
"opencode providers list",
None,
Some(r"\b[1-9][0-9]* credentials?\b"),
"opencode providers login",
),
cli_auth_checked(
"opencode-provider",
"opencode Go/Zen credential",
"opencode",
"opencode providers list",
None,
Some(r"\b[1-9][0-9]* credentials?\b"),
"opencode providers login",
),
],
),
);
registry.insert(
"cursor".to_string(),
provider(
"local-cli",
None,
Some("composer-2.5"),
Some("high"),
&[
model_choice("composer-2.5-fast", "Composer 2.5 Fast"),
model_choice("composer-2.5", "Composer 2.5"),
model_choice("sonnet-4.6", "Sonnet 4.6"),
model_choice("haiku", "Haiku"),
],
&["medium", "high", "xhigh"],
&[
("idea", "composer-2.5-fast"),
("prd", "composer-2.5"),
("techspec", "sonnet-4.6"),
("tasks", "composer-2.5"),
("refinement", "composer-2.5"),
("execution", "composer-2.5"),
("review", "sonnet-4.6"),
("memory", "haiku"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools", "code"],
&[cli_auth_checked(
"cursor-cli",
"Cursor CLI",
"cursor",
"cursor agent status",
Some("Logged in"),
None,
"cursor agent login",
)],
),
);
registry.insert(
"devin".to_string(),
provider(
"local-cli",
None,
Some("swe-1-6-fast"),
Some("high"),
&[
model_choice("swe-1-6-fast", "Devin SWE-1.6 Fast"),
model_choice("sonnet", "Devin Sonnet"),
model_choice("opus", "Devin Opus"),
model_choice("codex", "Devin Codex"),
],
&["medium", "high", "xhigh"],
&[
("idea", "swe-1-6-fast"),
("prd", "sonnet"),
("techspec", "opus"),
("tasks", "sonnet"),
("refinement", "sonnet"),
("execution", "sonnet"),
("review", "opus"),
("memory", "swe-1-6-fast"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools", "code"],
&[cli_auth_checked(
"devin-cli",
"Devin CLI",
"devin",
"devin auth status",
None,
None,
"devin auth login",
)],
),
);
registry.insert(
"trae".to_string(),
provider(
"local-cli",
None,
Some("trae_agent_model"),
Some("high"),
&[
model_choice("trae_agent_model", "Trae Agent configured model"),
model_choice("openai/gpt-5.5", "Trae / OpenAI GPT-5.5"),
model_choice("anthropic/claude-sonnet-4-6", "Trae / Claude Sonnet 4.6"),
model_choice("google/gemini-3.1-pro", "Trae / Gemini 3.1 Pro"),
],
&["medium", "high", "xhigh"],
&[
("idea", "trae_agent_model"),
("prd", "trae_agent_model"),
("techspec", "trae_agent_model"),
("tasks", "trae_agent_model"),
("refinement", "trae_agent_model"),
("execution", "trae_agent_model"),
("review", "trae_agent_model"),
("memory", "trae_agent_model"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools", "code"],
&[
cli_auth_checked(
"trae-cli",
"Trae Agent CLI",
"trae-cli",
"trae-cli show-config",
None,
None,
"trae-cli show-config",
),
env_auth("openai-api", "OpenAI API for Trae", "OPENAI_API_KEY"),
env_auth(
"anthropic-api",
"Anthropic API for Trae",
"ANTHROPIC_API_KEY",
),
env_auth("google-ai-api", "Google AI API for Trae", "GEMINI_API_KEY"),
],
),
);
registry.insert(
"rig".to_string(),
provider(
"rig-adapter",
None,
Some("openai/gpt-5.5"),
Some("high"),
&[
model_choice("openai/gpt-5.4", "Rig / OpenAI GPT-5.4"),
model_choice("openai/gpt-5.5", "Rig / OpenAI GPT-5.5"),
model_choice("anthropic/claude-sonnet-4-6", "Rig / Claude Sonnet 4.6"),
model_choice("anthropic/claude-opus-4-8", "Rig / Claude Opus 4.8"),
model_choice("google/gemini-3.1-pro", "Rig / Gemini 3.1 Pro"),
model_choice("ollama/llama3.3", "Rig / Ollama Llama 3.3"),
],
&["medium", "high", "xhigh"],
&[
("idea", "openai/gpt-5.4"),
("prd", "openai/gpt-5.4"),
("techspec", "openai/gpt-5.5"),
("tasks", "openai/gpt-5.4"),
("refinement", "openai/gpt-5.4"),
("execution", "openai/gpt-5.5"),
("review", "anthropic/claude-opus-4-8"),
("memory", "openai/gpt-5.4"),
],
&[
("idea", "medium"),
("prd", "high"),
("techspec", "xhigh"),
("tasks", "high"),
("refinement", "high"),
("execution", "high"),
("review", "xhigh"),
("memory", "medium"),
],
&["chat", "tools", "code", "provider-routing"],
&[
env_auth("openai-api", "OpenAI API via Rig", "OPENAI_API_KEY"),
env_auth(
"anthropic-api",
"Anthropic API via Rig",
"ANTHROPIC_API_KEY",
),
env_auth("google-ai-api", "Google AI API via Rig", "GEMINI_API_KEY"),
env_auth(
"rig-generic-api",
"Generic Rig provider API",
"RIG_PROVIDER_API_KEY",
),
],
),
);
registry
}
#[allow(clippy::too_many_arguments)]
fn provider(
kind: &str,
auth_env: Option<&str>,
model: Option<&str>,
effort: Option<&str>,
models: &[ProviderModelDefinition],
efforts: &[&str],
stage_models: &[(&str, &str)],
stage_efforts: &[(&str, &str)],
capabilities: &[&str],
auth_methods: &[ProviderAuthDefinition],
) -> ProviderDefinition {
ProviderDefinition {
kind: kind.to_string(),
auth_env: auth_env.map(str::to_string),
auth_methods: auth_methods.to_vec(),
model: model.map(str::to_string),
models: models.to_vec(),
effort: effort.map(str::to_string),
efforts: efforts.iter().map(|item| (*item).to_string()).collect(),
stage_models: stage_models
.iter()
.map(|(stage, model)| ((*stage).to_string(), (*model).to_string()))
.collect(),
stage_efforts: stage_efforts
.iter()
.map(|(stage, effort)| ((*stage).to_string(), (*effort).to_string()))
.collect(),
capabilities: capabilities
.iter()
.map(|item| (*item).to_string())
.collect(),
..ProviderDefinition::default()
}
}
fn model_choice(id: &str, label: &str) -> ProviderModelDefinition {
ProviderModelDefinition {
id: id.to_string(),
label: label.to_string(),
context_window_tokens: None,
pricing: ProviderModelPricing::default(),
}
}
fn env_auth(id: &str, label: &str, env: &str) -> ProviderAuthDefinition {
ProviderAuthDefinition {
id: id.to_string(),
label: label.to_string(),
kind: "env".to_string(),
env: Some(env.to_string()),
command: None,
check_command: None,
check_success_text: None,
check_success_regex: None,
login_command: None,
}
}
fn cli_auth(id: &str, label: &str, command: &str) -> ProviderAuthDefinition {
ProviderAuthDefinition {
id: id.to_string(),
label: label.to_string(),
kind: "cli".to_string(),
env: None,
command: Some(command.to_string()),
check_command: None,
check_success_text: None,
check_success_regex: None,
login_command: None,
}
}
fn cli_auth_checked(
id: &str,
label: &str,
command: &str,
check_command: &str,
check_success_text: Option<&str>,
check_success_regex: Option<&str>,
login_command: &str,
) -> ProviderAuthDefinition {
ProviderAuthDefinition {
check_command: Some(check_command.to_string()),
check_success_text: check_success_text.map(str::to_string),
check_success_regex: check_success_regex.map(str::to_string),
login_command: Some(login_command.to_string()),
..cli_auth(id, label, command)
}
}
fn default_true() -> bool {
true
}
pub fn load_sdd_config(root: &Path) -> Result<SddConfig> {
let path = root.join("sdd.config.yaml");
if !path.exists() {
return Ok(SddConfig::default());
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_yaml::from_str(&text).with_context(|| format!("invalid YAML in {}", path.display()))
}
pub fn provider_registry(root: &Path) -> Result<BTreeMap<String, ProviderDefinition>> {
let config = load_sdd_config(root)?;
let mut registry = builtin_provider_registry();
for (id, provider) in config.providers.registry {
let id = normalize_provider_id(&id);
let provider = merge_provider_definition(registry.get(&id), provider);
registry.insert(id, provider);
}
Ok(registry)
}
fn merge_provider_definition(
builtin: Option<&ProviderDefinition>,
mut provider: ProviderDefinition,
) -> ProviderDefinition {
if let Some(builtin) = builtin {
if provider.auth_env.is_none() {
provider.auth_env = builtin.auth_env.clone();
}
if provider.auth_methods.is_empty() {
provider.auth_methods = builtin.auth_methods.clone();
}
if provider.model.as_deref().is_none_or(is_placeholder_model) {
provider.model = builtin.model.clone();
}
if provider.models.is_empty() {
provider.models = builtin.models.clone();
}
if provider.effort.is_none() {
provider.effort = builtin.effort.clone();
}
if provider.efforts.is_empty() {
provider.efforts = builtin.efforts.clone();
}
if provider.stage_models.is_empty() {
provider.stage_models = builtin.stage_models.clone();
}
if provider.stage_efforts.is_empty() {
provider.stage_efforts = builtin.stage_efforts.clone();
}
}
if provider.auth_methods.is_empty() {
if let Some(auth_env) = provider.auth_env.clone() {
provider.auth_methods = vec![env_auth("api-key", "API key", &auth_env)];
}
}
if provider.models.is_empty() {
if let Some(model) = provider
.model
.clone()
.filter(|model| !is_placeholder_model(model))
{
provider.models = vec![model_choice(&model, &model)];
}
}
if provider.efforts.is_empty() {
if let Some(effort) = provider.effort.clone() {
provider.efforts = vec![effort];
}
}
provider
}
pub fn provider_catalog(root: &Path) -> Result<Vec<ProviderCatalogEntry>> {
provider_catalog_with_auth(root, ProviderAuthCheckMode::Full)
}
pub fn provider_catalog_fast(root: &Path) -> Result<Vec<ProviderCatalogEntry>> {
provider_catalog_with_auth(root, ProviderAuthCheckMode::Fast)
}
fn provider_catalog_with_auth(
root: &Path,
auth_mode: ProviderAuthCheckMode,
) -> Result<Vec<ProviderCatalogEntry>> {
let registry = provider_registry(root)?;
Ok(registry
.into_iter()
.map(|(id, mut provider)| {
apply_agent_overrides(root, &id, &mut provider);
let auth_methods = match auth_mode {
ProviderAuthCheckMode::Fast => {
auth_statuses_with_mode(&provider, ProviderAuthCheckMode::Fast)
}
ProviderAuthCheckMode::Full => auth_statuses(&provider),
};
let auth_present = auth_present_from_statuses(&auth_methods);
ProviderCatalogEntry {
id,
kind: provider.kind,
model: provider.model,
effort: provider.effort,
enabled: provider.enabled,
auth_present,
auth_methods,
auth_env: provider.auth_env,
models: provider.models,
efforts: provider.efforts,
stage_models: provider.stage_models,
stage_efforts: provider.stage_efforts,
capabilities: provider.capabilities,
token_budget: provider.token_budget,
usage_limits: provider.usage_limits,
}
})
.collect())
}
pub fn resolve_provider(
root: &Path,
options: &ProviderResolveOptions,
) -> Result<ProviderSelection> {
resolve_provider_with_auth(root, options, ProviderAuthCheckMode::Full)
}
pub fn resolve_provider_fast(
root: &Path,
options: &ProviderResolveOptions,
) -> Result<ProviderSelection> {
resolve_provider_with_auth(root, options, ProviderAuthCheckMode::Fast)
}
fn resolve_provider_with_auth(
root: &Path,
options: &ProviderResolveOptions,
auth_mode: ProviderAuthCheckMode,
) -> Result<ProviderSelection> {
let config = load_sdd_config(root)?;
let mut registry = builtin_provider_registry();
for (id, provider) in config.providers.registry.clone() {
let id = normalize_provider_id(&id);
let provider = merge_provider_definition(registry.get(&id), provider);
registry.insert(id, provider);
}
let provider_id = options
.provider
.clone()
.and_then(non_empty_owned)
.or_else(|| env_string("SDD_PROVIDER"))
.or_else(|| config.providers.default.clone().and_then(non_empty_owned))
.map(|value| normalize_provider_id(&value))
.unwrap_or_else(|| "codex".to_string());
let mut provider = registry
.get(&provider_id)
.cloned()
.unwrap_or_else(|| ProviderDefinition {
kind: "custom".to_string(),
enabled: true,
..ProviderDefinition::default()
});
apply_agent_overrides(root, &provider_id, &mut provider);
let model_override = options
.model
.clone()
.and_then(non_empty_owned)
.or_else(|| env_string("SDD_MODEL"));
let effort_override = options
.effort
.clone()
.and_then(non_empty_owned)
.or_else(|| env_string("SDD_EFFORT"))
.or_else(|| env_string("SDD_REASONING_EFFORT"));
let model = model_override
.clone()
.or_else(|| provider.model.clone().and_then(non_empty_owned))
.or_else(|| config.providers.model.clone().and_then(non_empty_owned))
.unwrap_or_else(|| "default".to_string());
let effort = effort_override
.clone()
.or_else(|| provider.effort.clone().and_then(non_empty_owned))
.or_else(|| config.providers.effort.clone().and_then(non_empty_owned))
.unwrap_or_else(|| "medium".to_string());
let offline =
options.offline || env_truthy("SDD_OFFLINE") || config.providers.offline.unwrap_or(true);
let auth_methods = match auth_mode {
ProviderAuthCheckMode::Fast => {
auth_statuses_with_mode(&provider, ProviderAuthCheckMode::Fast)
}
ProviderAuthCheckMode::Full => auth_statuses(&provider),
};
let auth_present = auth_present_from_statuses(&auth_methods);
Ok(ProviderSelection {
id: provider_id,
kind: provider.kind,
model,
effort,
offline,
enabled: provider.enabled,
auth_env: provider.auth_env,
auth_present,
auth_methods,
models: provider.models,
efforts: provider.efforts,
stage_models: if model_override.is_some() {
BTreeMap::new()
} else {
provider.stage_models
},
stage_efforts: if effort_override.is_some() {
BTreeMap::new()
} else {
provider.stage_efforts
},
capabilities: provider.capabilities,
token_budget: provider.token_budget,
usage_limits: provider.usage_limits,
})
}
#[derive(Default)]
struct AgentOverrides {
stage_models: BTreeMap<String, String>,
stage_efforts: BTreeMap<String, String>,
}
fn apply_agent_overrides(root: &Path, provider_id: &str, provider: &mut ProviderDefinition) {
let overrides = agent_overrides(root, provider_id);
if overrides.stage_models.is_empty() && overrides.stage_efforts.is_empty() {
return;
}
let valid_models = provider
.models
.iter()
.map(|model| model.id.as_str())
.collect::<BTreeSet<_>>();
for (stage, model) in overrides.stage_models {
if valid_models.is_empty() || valid_models.contains(model.as_str()) {
provider.stage_models.insert(stage, model);
}
}
let valid_efforts = provider
.efforts
.iter()
.map(|effort| effort.as_str())
.collect::<BTreeSet<_>>();
for (stage, effort) in overrides.stage_efforts {
if valid_efforts.is_empty() || valid_efforts.contains(effort.as_str()) {
provider.stage_efforts.insert(stage, effort);
}
}
}
fn agent_overrides(root: &Path, provider_id: &str) -> AgentOverrides {
match provider_id {
"codex" => codex_agent_overrides(root),
"claude" => claude_agent_overrides(root),
"cursor" => routing_file_overrides(root.join(".cursor/model-routing.yaml")),
"opencode" => routing_file_overrides(root.join(".opencode/model-routing.yaml")),
_ => AgentOverrides::default(),
}
}
fn codex_agent_overrides(root: &Path) -> AgentOverrides {
let mut overrides = AgentOverrides::default();
for stage in SDD_STAGES {
let path = root.join(format!(".codex/agents/{stage}.toml"));
if let Some(text) = read_to_string(&path) {
if let Some(model) =
collect_line_value(&text, "model").filter(|model| !is_placeholder_model(model))
{
overrides.stage_models.insert(stage.to_string(), model);
}
if let Some(effort) = collect_line_value(&text, "model_reasoning_effort")
.or_else(|| collect_line_value(&text, "effort"))
{
overrides.stage_efforts.insert(stage.to_string(), effort);
}
}
}
overrides
}
fn claude_agent_overrides(root: &Path) -> AgentOverrides {
let mut overrides = AgentOverrides::default();
for stage in SDD_STAGES {
let path = root.join(format!(".claude/agents/{stage}.md"));
if let Some(text) = read_to_string(&path) {
if let Some(model) = collect_yaml_frontmatter_value(&text, "model")
.filter(|model| !is_placeholder_model(model))
{
overrides.stage_models.insert(stage.to_string(), model);
}
if let Some(effort) = collect_yaml_frontmatter_value(&text, "effort") {
overrides.stage_efforts.insert(stage.to_string(), effort);
}
}
}
overrides
}
fn routing_file_overrides(path: PathBuf) -> AgentOverrides {
let Some(text) = read_to_string(&path) else {
return AgentOverrides::default();
};
let Ok(doc) = serde_yaml::from_str::<YamlValue>(&text) else {
return AgentOverrides::default();
};
let mut overrides = AgentOverrides::default();
let Some(stages) = doc.get("stages").and_then(YamlValue::as_mapping) else {
return overrides;
};
for (key, value) in stages {
let Some(stage) = key.as_str().and_then(normalize_routing_stage_key) else {
continue;
};
if let Some(model) = value.get("model").and_then(YamlValue::as_str) {
overrides
.stage_models
.insert(stage.to_string(), model.to_string());
}
if let Some(effort) = value.get("effort").and_then(YamlValue::as_str) {
overrides
.stage_efforts
.insert(stage.to_string(), effort.to_string());
}
}
overrides
}
fn collect_line_value(text: &str, key: &str) -> Option<String> {
let prefix = format!("{key} =");
text.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix(&prefix))
.map(clean_value)
.filter(|value| !value.is_empty())
}
fn collect_yaml_frontmatter_value(text: &str, key: &str) -> Option<String> {
let mut lines = text.lines();
if lines.next().map(str::trim) != Some("---") {
return None;
}
let prefix = format!("{key}:");
for line in lines {
let line = line.trim();
if line == "---" {
break;
}
if let Some(value) = line.strip_prefix(&prefix) {
let value = clean_value(value);
if !value.is_empty() {
return Some(value);
}
}
}
None
}
fn clean_value(value: &str) -> String {
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
}
fn read_to_string(path: &Path) -> Option<String> {
fs::read_to_string(path).ok()
}
const SDD_STAGES: &[&str] = &[
"idea",
"prd",
"techspec",
"tasks",
"refinement",
"execution",
"review",
"memory",
];
fn normalize_routing_stage_key(stage: &str) -> Option<&'static str> {
match stage.trim() {
"project-discovery" | "discover" => Some("project-discovery"),
"risk" | "risk-classification" => Some("risk"),
"idea" => Some("idea"),
"prd" => Some("prd"),
"techspec" | "tech-spec" => Some("techspec"),
"tasks" => Some("tasks"),
"refinement" => Some("refinement"),
"execution" => Some("execution"),
"adr" => Some("adr"),
"review" => Some("review"),
"memory" => Some("memory"),
_ => None,
}
}
fn provider_stage_lookup_keys(stage: &str) -> &'static [&'static str] {
match stage.trim() {
"project-discovery" | "discover" => &["project-discovery", "discover"],
"risk" | "risk-classification" => &["risk", "risk-classification"],
"tech-spec" => &["techspec", "tech-spec"],
"orchestration" | "orchestrator" => &["orchestration", "idea"],
"adr" => &["adr", "execution"],
"idea" => &["idea"],
"prd" => &["prd"],
"techspec" => &["techspec"],
"tasks" => &["tasks"],
"refinement" => &["refinement"],
"execution" => &["execution"],
"review" => &["review"],
"memory" => &["memory"],
_ => &[],
}
}
pub fn auth_statuses(provider: &ProviderDefinition) -> Vec<ProviderAuthStatus> {
auth_statuses_with_mode(provider, ProviderAuthCheckMode::Full)
}
fn auth_statuses_with_mode(
provider: &ProviderDefinition,
auth_mode: ProviderAuthCheckMode,
) -> Vec<ProviderAuthStatus> {
let methods = if provider.auth_methods.is_empty() {
provider
.auth_env
.as_ref()
.map(|auth_env| vec![env_auth("api-key", "API key", auth_env)])
.unwrap_or_default()
} else {
provider.auth_methods.clone()
};
methods
.into_iter()
.map(|method| {
let source = match method.kind.as_str() {
"env" => method.env.clone().unwrap_or_default(),
"cli" => method.command.clone().unwrap_or_default(),
_ => method
.env
.clone()
.or(method.command.clone())
.unwrap_or_default(),
};
let present = match method.kind.as_str() {
"env" => method
.env
.as_ref()
.map(|name| env::var_os(name).is_some())
.unwrap_or(false),
"cli" => cli_auth_present(&method, auth_mode),
_ => false,
};
let detail = auth_detail(&method, present, auth_mode);
ProviderAuthStatus {
id: method.id,
label: method.label,
kind: method.kind,
source,
present,
check_command: method.check_command,
login_command: method.login_command,
detail,
}
})
.collect()
}
fn cli_auth_present(method: &ProviderAuthDefinition, auth_mode: ProviderAuthCheckMode) -> bool {
let Some(command) = method.command.as_deref() else {
return false;
};
if !command_exists(command) {
return false;
}
if auth_mode == ProviderAuthCheckMode::Fast {
return true;
}
let Some(check_command) = method.check_command.as_deref() else {
return true;
};
let Some(output) = run_check_command(check_command) else {
return false;
};
if !output.status.success() {
return false;
}
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if let Some(expected) = method.check_success_text.as_deref() {
return combined.contains(expected);
}
if let Some(pattern) = method.check_success_regex.as_deref() {
return regex::Regex::new(pattern)
.map(|regex| regex.is_match(&combined))
.unwrap_or(false);
}
true
}
fn auth_detail(
method: &ProviderAuthDefinition,
present: bool,
auth_mode: ProviderAuthCheckMode,
) -> Option<String> {
match (method.kind.as_str(), auth_mode, present) {
("cli", ProviderAuthCheckMode::Fast, true) if method.check_command.is_some() => {
Some("sessão não verificada no modo rápido; rode `sdd providers doctor`".to_string())
}
("cli", _, false) if method.command.is_some() => {
Some("comando ausente ou sessão inválida".to_string())
}
_ => None,
}
}
fn run_check_command(command: &str) -> Option<std::process::Output> {
let mut parts = command.split_whitespace();
let program = parts.next()?;
let exe = crate::runtime::platform::find_executable(program)
.unwrap_or_else(|| PathBuf::from(program));
Command::new(exe).args(parts).output().ok()
}
fn auth_present_from_statuses(statuses: &[ProviderAuthStatus]) -> Option<bool> {
if statuses.is_empty() {
None
} else {
Some(statuses.iter().any(|status| status.present))
}
}
fn command_exists(command: &str) -> bool {
crate::runtime::platform::executable_exists(command)
}
pub fn normalize_provider_id(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"agy" | "gemini" | "google-antigravity" => "antigravity".to_string(),
"devin-cli" => "devin".to_string(),
"trae-cli" | "trae-agent" => "trae".to_string(),
"rig-core" | "rigcore" => "rig".to_string(),
value => value.to_string(),
}
}
fn non_empty_owned(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}
fn is_placeholder_model(value: &str) -> bool {
let normalized = value.trim();
normalized.is_empty() || normalized == "default" || normalized.ends_with("-default")
}
fn env_string(name: &str) -> Option<String> {
env::var(name).ok().and_then(non_empty_owned)
}
fn env_truthy(name: &str) -> bool {
env::var(name)
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn provider_catalog_carries_usage_limits_from_config() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("sdd.config.yaml"),
r#"
providers:
registry:
codex:
kind: openai-compatible
usage_limits:
- window: weekly
limit: 40
remaining: 12.5
unit: hours
resets_at: segunda 00:00
source: manual
"#,
)
.unwrap();
let catalog = provider_catalog(dir.path()).unwrap();
let codex = catalog
.iter()
.find(|provider| provider.id == "codex")
.unwrap();
assert_eq!(codex.usage_limits.len(), 1);
assert_eq!(codex.usage_limits[0].window, "weekly");
assert_eq!(codex.usage_limits[0].remaining, Some(12.5));
assert_eq!(codex.usage_limits[0].unit, "hours");
}
#[test]
fn auth_statuses_keep_login_command_when_cli_is_missing() {
let provider = ProviderDefinition {
auth_methods: vec![ProviderAuthDefinition {
id: "missing-cli".to_string(),
label: "Missing CLI".to_string(),
kind: "cli".to_string(),
env: None,
command: Some("sdd-definitely-missing-cli".to_string()),
check_command: Some("sdd-definitely-missing-cli status".to_string()),
check_success_text: Some("ok".to_string()),
check_success_regex: None,
login_command: Some("sdd-definitely-missing-cli login".to_string()),
}],
..ProviderDefinition::default()
};
let statuses = auth_statuses(&provider);
assert_eq!(statuses.len(), 1);
assert!(!statuses[0].present);
assert_eq!(
statuses[0].login_command.as_deref(),
Some("sdd-definitely-missing-cli login")
);
assert_eq!(
statuses[0].check_command.as_deref(),
Some("sdd-definitely-missing-cli status")
);
}
#[test]
fn fast_auth_status_skips_session_check_command() {
let present_command = if cfg!(windows) { "cmd" } else { "sh" };
let provider = ProviderDefinition {
auth_methods: vec![ProviderAuthDefinition {
id: "local-cli".to_string(),
label: "Local CLI".to_string(),
kind: "cli".to_string(),
env: None,
command: Some(present_command.to_string()),
check_command: Some("sdd-definitely-missing-cli status".to_string()),
check_success_text: Some("ok".to_string()),
check_success_regex: None,
login_command: Some("sh login".to_string()),
}],
..ProviderDefinition::default()
};
let fast = auth_statuses_with_mode(&provider, ProviderAuthCheckMode::Fast);
assert!(fast[0].present);
assert!(fast[0]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("modo rápido")));
let full = auth_statuses_with_mode(&provider, ProviderAuthCheckMode::Full);
assert!(!full[0].present);
}
#[test]
fn normalize_provider_id_keeps_antigravity_aliases_compatible() {
assert_eq!(normalize_provider_id("antigravity"), "antigravity");
assert_eq!(normalize_provider_id("agy"), "antigravity");
assert_eq!(normalize_provider_id("gemini"), "antigravity");
assert_eq!(normalize_provider_id("google-antigravity"), "antigravity");
}
#[test]
fn builtin_registry_contains_headless_client_providers() {
let registry = builtin_provider_registry();
let devin = registry.get("devin").unwrap();
assert_eq!(devin.kind, "local-cli");
assert_eq!(devin.model.as_deref(), Some("swe-1-6-fast"));
assert!(devin
.auth_methods
.iter()
.any(|method| method.check_command.as_deref() == Some("devin auth status")));
assert_eq!(
devin.stage_models.get("review").map(String::as_str),
Some("opus")
);
let trae = registry.get("trae").unwrap();
assert_eq!(trae.kind, "local-cli");
assert_eq!(trae.model.as_deref(), Some("trae_agent_model"));
assert!(trae
.auth_methods
.iter()
.any(|method| method.command.as_deref() == Some("trae-cli")));
assert!(trae
.auth_methods
.iter()
.any(|method| method.env.as_deref() == Some("OPENAI_API_KEY")));
assert_eq!(
trae.stage_models.get("techspec").map(String::as_str),
Some("trae_agent_model")
);
}
#[test]
fn normalize_provider_id_keeps_headless_client_aliases_compatible() {
assert_eq!(normalize_provider_id("devin"), "devin");
assert_eq!(normalize_provider_id("devin-cli"), "devin");
assert_eq!(normalize_provider_id("trae"), "trae");
assert_eq!(normalize_provider_id("trae-cli"), "trae");
assert_eq!(normalize_provider_id("trae-agent"), "trae");
}
#[test]
fn builtin_registry_contains_optional_rig_adapter() {
let registry = builtin_provider_registry();
let rig = registry.get("rig").unwrap();
assert_eq!(rig.kind, "rig-adapter");
assert_eq!(rig.model.as_deref(), Some("openai/gpt-5.5"));
assert!(rig.capabilities.iter().any(|item| item == "tools"));
assert!(rig
.capabilities
.iter()
.any(|item| item == "provider-routing"));
assert!(rig
.auth_methods
.iter()
.any(|method| method.env.as_deref() == Some("RIG_PROVIDER_API_KEY")));
assert_eq!(
rig.stage_models.get("review").map(String::as_str),
Some("anthropic/claude-opus-4-8")
);
}
#[test]
fn normalize_provider_id_keeps_rig_aliases_compatible() {
assert_eq!(normalize_provider_id("rig"), "rig");
assert_eq!(normalize_provider_id("rig-core"), "rig");
assert_eq!(normalize_provider_id("rigcore"), "rig");
}
}