pub mod app_mode;
pub mod auth_source;
pub mod auto_model;
pub mod catalog;
mod config_document;
pub mod external_credentials;
mod harness;
pub mod model_reference;
pub mod models_dev;
pub mod persistence;
pub mod pricing;
pub mod provider;
mod provider_defaults;
mod provider_kind;
pub mod route;
pub mod setup_state;
pub mod user_constitution;
mod xai_credentials;
pub use config_document::{
create_config_document, mutate_config_document, replace_config_document_if_unchanged,
set_config_document_value, unset_config_document_value,
};
pub use harness::{
HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
};
pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
pub(crate) use provider_defaults::*;
pub use provider_kind::ProviderKind;
pub use setup_state::{
ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
TELEMETRY_NOTICE_VERSION,
};
pub use user_constitution::{
APPROX_BYTES_PER_TOKEN, AutonomyPreference, CacheProjection, ClauseOrigin, ClauseStatus,
ConstitutionClause, ConstitutionRecommendation, MigrationOutcome, MigrationReceipt,
MigrationRejection, Ratification, RatificationError, RecommendationParse,
USER_CONSTITUTION_SCHEMA_VERSION, USER_CONSTITUTION_SCHEMA_VERSION_V1, UntrustedDraftParse,
UserConstitution, UserConstitutionLoad,
};
pub use xai_credentials::{
LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs;
#[cfg(unix)]
use std::io::Read;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
pub use app_mode::AppMode;
pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
pub use codewhale_execpolicy::ToolAskRule;
use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
use codewhale_secrets::SecretSource;
pub use codewhale_secrets::Secrets;
pub use external_credentials::{
EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
ExternalCredentialReadGrant, ExternalCredentialSource, default_agy_credentials_path,
default_dsh_credentials_path, external_credential_consent_status, quote_os_path,
resolve_external_credential_path,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
pub const API_KEYRING_SENTINEL: &str = "__KEYRING__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigApiKeyValueKind {
Empty,
SecretStoreSentinel,
Literal,
}
#[must_use]
pub fn classify_config_api_key_value(value: &str) -> ConfigApiKeyValueKind {
match value.trim() {
"" => ConfigApiKeyValueKind::Empty,
API_KEYRING_SENTINEL => ConfigApiKeyValueKind::SecretStoreSentinel,
_ => ConfigApiKeyValueKind::Literal,
}
}
fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
!headers
.iter()
.any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
}
#[must_use]
pub fn is_upstream_auth_header(name: &str) -> bool {
let name = name.trim();
is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfigToml {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "contextWindow",
alias = "context_window_tokens",
alias = "contextWindowTokens",
alias = "context_length",
alias = "contextLength"
)]
pub context_window: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "api_style",
alias = "protocol",
alias = "wire_format",
alias = "dialect"
)]
pub wire: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub insecure_skip_tls_verify: Option<bool>,
#[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
pub http_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_suffix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<ProviderAuthSourceToml>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_credentials: Option<ExternalCredentialConsentToml>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_credential_generation: Option<String>,
#[serde(flatten)]
pub extras: BTreeMap<String, toml::Value>,
}
impl ProviderConfigToml {
#[must_use]
pub fn is_empty(&self) -> bool {
let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
blank(self.api_key.as_ref())
&& blank(self.base_url.as_ref())
&& blank(self.model.as_ref())
&& self.context_window.is_none()
&& blank(self.mode.as_ref())
&& blank(self.wire.as_ref())
&& blank(self.auth_mode.as_ref())
&& self.insecure_skip_tls_verify.is_none()
&& http_headers_are_effectively_empty(&self.http_headers)
&& blank(self.path_suffix.as_ref())
&& self.auth.is_none()
&& self.external_credentials.is_none()
&& self.oauth_credential_generation.is_none()
&& self.extras.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProvidersToml {
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub deepseek: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "deepseek-anthropic",
alias = "deepseekAnthropic",
alias = "deepseek-claude",
alias = "deepseek_claude"
)]
pub deepseek_anthropic: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
// The canonical provider id is the kebab `nvidia-nim` (see
// `provider.rs`); without these aliases a `[providers.nvidia-nim]`
alias = "nvidia-nim",
alias = "nvidia",
alias = "nim"
)]
pub nvidia_nim: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub openai: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub atlascloud: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "wanjie-ark",
alias = "wanjie",
alias = "ark-wanjie",
alias = "ark_wanjie"
)]
pub wanjie_ark: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub volcengine: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub openrouter: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub orcarouter: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "xiaomi-mimo",
alias = "xiaomi",
alias = "mimo",
alias = "xiaomimimo"
)]
pub xiaomi_mimo: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub novita: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub fireworks: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub siliconflow: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "siliconflow-CN",
alias = "siliconflow-cn"
)]
pub siliconflow_cn: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub arcee: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub moonshot: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub sglang: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub vllm: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub ollama: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "ollama-cloud"
)]
pub ollama_cloud: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub huggingface: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub together: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "baidu-qianfan",
alias = "baidu_qianfan",
alias = "baidu"
)]
pub qianfan: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "openai-codex",
alias = "openai_codex",
alias = "codex",
alias = "chatgpt",
alias = "chatgpt-codex"
)]
pub openai_codex: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub anthropic: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "open-model",
alias = "open_model"
)]
pub openmodel: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "z-ai",
alias = "z_ai",
alias = "z.ai",
alias = "zhipu",
alias = "zhipuai",
alias = "bigmodel",
alias = "big-model"
)]
pub zai: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "step-fun",
alias = "step_fun",
alias = "stepfun",
alias = "stepflash",
alias = "step-flash",
alias = "step_flash"
)]
pub stepfun: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "mini-max",
alias = "mini_max",
alias = "minimax"
)]
pub minimax: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "minimax-anthropic",
alias = "minimaxAnthropic",
alias = "mini-max-anthropic",
alias = "mini_max_anthropic"
)]
pub minimax_anthropic: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "deep-infra",
alias = "deep_infra"
)]
pub deepinfra: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "sakana-ai",
alias = "sakana_ai",
alias = "fugu"
)]
pub sakana: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "long-cat",
alias = "meituan-longcat",
alias = "meituan"
)]
pub longcat: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "opencode-go",
alias = "opencodego"
)]
pub opencode_go: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "opencode-zen",
alias = "opencodezen",
alias = "zen",
alias = "opencode"
)]
pub opencode_zen: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "meta-ai",
alias = "meta_ai",
alias = "meta-model-api",
alias = "meta_model_api",
alias = "muse",
alias = "muse-spark"
)]
pub meta: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "x-ai",
alias = "x_ai",
alias = "grok"
)]
pub xai: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "mistral-ai",
alias = "mistral_ai",
alias = "mistralai",
alias = "la-plateforme",
alias = "la_plateforme"
)]
pub mistral: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "google-gemini",
alias = "google_gemini",
alias = "gemini"
)]
pub google: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "agy"
)]
pub antigravity: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "telecom-js",
alias = "telecom_js",
alias = "telecomjs-cn",
alias = "tokenhub"
)]
pub telecomjs: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "eden-ai",
alias = "eden_ai"
)]
pub edenai: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "modelstudio-token-plan",
alias = "modelstudio_token_plan",
alias = "alibaba-token-plan",
alias = "dashscope-token-plan"
)]
pub modelstudio_token_plan: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "modelstudio-token-plan-anthropic",
alias = "modelstudio_token_plan_anthropic",
alias = "alibaba-token-plan-anthropic"
)]
pub modelstudio_token_plan_anthropic: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "modelstudio-coding-plan",
alias = "modelstudio_coding_plan",
alias = "alibaba-coding-plan",
alias = "dashscope-coding-plan"
)]
pub modelstudio_coding_plan: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "modelstudio-coding-plan-anthropic",
alias = "modelstudio_coding_plan_anthropic",
alias = "alibaba-coding-plan-anthropic"
)]
pub modelstudio_coding_plan_anthropic: ProviderConfigToml,
#[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
pub custom: ProviderConfigToml,
#[serde(flatten)]
pub extras: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PermissionsToml {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ToolAskRule>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionsFileState {
Missing,
Empty,
Present,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionsSnapshot {
path: PathBuf,
file_state: PermissionsFileState,
permissions: PermissionsToml,
removal_tokens: Vec<String>,
}
impl PermissionsSnapshot {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn file_exists(&self) -> bool {
self.file_state != PermissionsFileState::Missing
}
#[must_use]
pub fn file_state(&self) -> PermissionsFileState {
self.file_state
}
#[must_use]
pub fn permissions(&self) -> &PermissionsToml {
&self.permissions
}
#[must_use]
pub fn rules(&self) -> &[ToolAskRule] {
&self.permissions.rules
}
#[must_use]
pub fn removal_token(&self, index: usize) -> Option<&str> {
self.removal_tokens.get(index).map(String::as_str)
}
}
impl PermissionsToml {
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[must_use]
pub fn ruleset(&self) -> Ruleset {
let mut denied = Vec::new();
let mut trusted = Vec::new();
let mut ask_rules = Vec::new();
for rule in &self.rules {
match rule.action {
PermissionAction::Deny => {
if let Some(cmd) = &rule.command
&& !rule.command_exact
&& rule.workspace.is_none()
{
denied.push(cmd.clone());
}
ask_rules.push(rule.clone());
}
PermissionAction::Allow => {
if let Some(cmd) = &rule.command
&& !rule.command_exact
&& rule.workspace.is_none()
{
trusted.push(cmd.clone());
}
ask_rules.push(rule.clone());
}
PermissionAction::Ask => {
ask_rules.push(rule.clone());
}
}
}
Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
}
}
impl ProvidersToml {
#[must_use]
pub fn is_empty(&self) -> bool {
self.extras.is_empty()
&& ProviderKind::all()
.iter()
.all(|provider| self.for_provider(*provider).is_empty())
}
#[must_use]
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
match provider {
ProviderKind::Deepseek => &self.deepseek,
ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
ProviderKind::NvidiaNim => &self.nvidia_nim,
ProviderKind::Openai => &self.openai,
ProviderKind::Atlascloud => &self.atlascloud,
ProviderKind::WanjieArk => &self.wanjie_ark,
ProviderKind::Volcengine => &self.volcengine,
ProviderKind::Openrouter => &self.openrouter,
ProviderKind::Orcarouter => &self.orcarouter,
ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
ProviderKind::Novita => &self.novita,
ProviderKind::Fireworks => &self.fireworks,
ProviderKind::Siliconflow => &self.siliconflow,
ProviderKind::SiliconflowCN => &self.siliconflow_cn,
ProviderKind::Arcee => &self.arcee,
ProviderKind::Moonshot => &self.moonshot,
ProviderKind::Sglang => &self.sglang,
ProviderKind::Vllm => &self.vllm,
ProviderKind::Ollama => &self.ollama,
ProviderKind::OllamaCloud => &self.ollama_cloud,
ProviderKind::Huggingface => &self.huggingface,
ProviderKind::Together => &self.together,
ProviderKind::Qianfan => &self.qianfan,
ProviderKind::OpenaiCodex => &self.openai_codex,
ProviderKind::Anthropic => &self.anthropic,
ProviderKind::Openmodel => &self.openmodel,
ProviderKind::Zai => &self.zai,
ProviderKind::Stepfun => &self.stepfun,
ProviderKind::Minimax => &self.minimax,
ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
ProviderKind::Deepinfra => &self.deepinfra,
ProviderKind::Sakana => &self.sakana,
ProviderKind::LongCat => &self.longcat,
ProviderKind::OpencodeGo => &self.opencode_go,
ProviderKind::OpencodeZen => &self.opencode_zen,
ProviderKind::Meta => &self.meta,
ProviderKind::Xai => &self.xai,
ProviderKind::Mistral => &self.mistral,
ProviderKind::Google => &self.google,
ProviderKind::Antigravity => &self.antigravity,
ProviderKind::Telecomjs => &self.telecomjs,
ProviderKind::Edenai => &self.edenai,
ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
ProviderKind::ModelstudioCodingPlan => &self.modelstudio_coding_plan,
ProviderKind::ModelstudioCodingPlanAnthropic => &self.modelstudio_coding_plan_anthropic,
ProviderKind::Custom => &self.custom,
}
}
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
match provider {
ProviderKind::Deepseek => &mut self.deepseek,
ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
ProviderKind::NvidiaNim => &mut self.nvidia_nim,
ProviderKind::Openai => &mut self.openai,
ProviderKind::Atlascloud => &mut self.atlascloud,
ProviderKind::WanjieArk => &mut self.wanjie_ark,
ProviderKind::Volcengine => &mut self.volcengine,
ProviderKind::Openrouter => &mut self.openrouter,
ProviderKind::Orcarouter => &mut self.orcarouter,
ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
ProviderKind::Novita => &mut self.novita,
ProviderKind::Fireworks => &mut self.fireworks,
ProviderKind::Siliconflow => &mut self.siliconflow,
ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
ProviderKind::Arcee => &mut self.arcee,
ProviderKind::Moonshot => &mut self.moonshot,
ProviderKind::Sglang => &mut self.sglang,
ProviderKind::Vllm => &mut self.vllm,
ProviderKind::Ollama => &mut self.ollama,
ProviderKind::OllamaCloud => &mut self.ollama_cloud,
ProviderKind::Huggingface => &mut self.huggingface,
ProviderKind::Together => &mut self.together,
ProviderKind::Qianfan => &mut self.qianfan,
ProviderKind::OpenaiCodex => &mut self.openai_codex,
ProviderKind::Anthropic => &mut self.anthropic,
ProviderKind::Openmodel => &mut self.openmodel,
ProviderKind::Zai => &mut self.zai,
ProviderKind::Stepfun => &mut self.stepfun,
ProviderKind::Minimax => &mut self.minimax,
ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
ProviderKind::Deepinfra => &mut self.deepinfra,
ProviderKind::Sakana => &mut self.sakana,
ProviderKind::LongCat => &mut self.longcat,
ProviderKind::OpencodeGo => &mut self.opencode_go,
ProviderKind::OpencodeZen => &mut self.opencode_zen,
ProviderKind::Meta => &mut self.meta,
ProviderKind::Xai => &mut self.xai,
ProviderKind::Mistral => &mut self.mistral,
ProviderKind::Google => &mut self.google,
ProviderKind::Antigravity => &mut self.antigravity,
ProviderKind::Telecomjs => &mut self.telecomjs,
ProviderKind::Edenai => &mut self.edenai,
ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
ProviderKind::ModelstudioTokenPlanAnthropic => {
&mut self.modelstudio_token_plan_anthropic
}
ProviderKind::ModelstudioCodingPlan => &mut self.modelstudio_coding_plan,
ProviderKind::ModelstudioCodingPlanAnthropic => {
&mut self.modelstudio_coding_plan_anthropic
}
ProviderKind::Custom => &mut self.custom,
}
}
}
fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigToml {
pub api_key: Option<String>,
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
pub http_headers: BTreeMap<String, String>,
pub default_text_model: Option<String>,
#[serde(default, deserialize_with = "deserialize_root_provider")]
pub provider: ProviderKind,
#[doc(hidden)]
#[serde(skip)]
pub selected_provider_id: Option<String>,
pub model: Option<String>,
pub auth_mode: Option<String>,
pub output_mode: Option<String>,
pub verbosity: Option<String>,
pub log_level: Option<String>,
pub telemetry: Option<bool>,
pub telemetry_endpoint: Option<String>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
#[serde(default)]
pub tools: Option<ToolsToml>,
#[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
pub providers: ProvidersToml,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallback_providers: Vec<ProviderKind>,
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
#[serde(default)]
pub verifier: Option<VerifierConfigToml>,
#[serde(default)]
pub skills: Option<SkillsToml>,
#[serde(default)]
pub snapshots: Option<SnapshotsToml>,
#[serde(default)]
pub lsp: Option<LspConfigToml>,
#[serde(default)]
pub harness_profiles: Vec<HarnessProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hotbar: Option<Vec<HotbarBindingToml>>,
#[serde(default)]
pub hook_sinks: Option<HookSinksToml>,
#[serde(default)]
pub fleet: Option<FleetConfigToml>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fleets: BTreeMap<String, NamedFleetConfigToml>,
#[serde(default)]
pub workflow: Option<WorkflowConfigToml>,
#[serde(flatten)]
pub extras: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderConfigField {
ApiKey,
BaseUrl,
Model,
ContextWindow,
Mode,
Wire,
AuthMode,
InsecureSkipTlsVerify,
HttpHeaders,
PathSuffix,
}
impl ProviderConfigField {
fn parse(key: &str) -> Option<Self> {
Some(match key {
"api_key" => Self::ApiKey,
"base_url" => Self::BaseUrl,
"model" => Self::Model,
"context_window" | "context_window_tokens" => Self::ContextWindow,
"mode" => Self::Mode,
"wire" | "api_style" | "protocol" | "wire_format" | "dialect" => Self::Wire,
"auth_mode" => Self::AuthMode,
"insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
"http_headers" => Self::HttpHeaders,
"path_suffix" => Self::PathSuffix,
_ => return None,
})
}
fn key(self) -> &'static str {
match self {
Self::ApiKey => "api_key",
Self::BaseUrl => "base_url",
Self::Model => "model",
Self::ContextWindow => "context_window",
Self::Mode => "mode",
Self::Wire => "wire",
Self::AuthMode => "auth_mode",
Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
Self::HttpHeaders => "http_headers",
Self::PathSuffix => "path_suffix",
}
}
}
fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
let suffix = key.strip_prefix("providers.")?;
let (provider_key, field_key) = suffix.split_once('.')?;
let field = ProviderConfigField::parse(field_key)?;
let provider = provider::all_providers()
.iter()
.map(|p| p.kind())
.find(|kind| kind.provider().provider_config_key() == provider_key)?;
Some((provider, field))
}
fn parse_custom_provider_config_key(key: &str) -> Option<(&str, &str)> {
let suffix = key.strip_prefix("providers.")?;
let (provider_id, field_key) = suffix.split_once('.')?;
(!provider_id.is_empty()).then_some((provider_id, field_key))
}
fn is_builtin_provider_config_id(provider_id: &str) -> bool {
provider::all_providers()
.iter()
.any(|p| p.provider_config_key() == provider_id)
}
const CUSTOM_PROVIDER_FIELD_HINT: &str = "api_key, base_url, model, context_window, mode, wire, auth_mode, \
insecure_skip_tls_verify, http_headers, path_suffix, kind";
fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
format!(
"providers.{}.{}",
provider.provider().provider_config_key(),
field.key()
)
}
fn get_provider_config_value(
config: &ProviderConfigToml,
field: ProviderConfigField,
) -> Option<String> {
match field {
ProviderConfigField::ApiKey => config.api_key.clone(),
ProviderConfigField::BaseUrl => config.base_url.clone(),
ProviderConfigField::Model => config.model.clone(),
ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
ProviderConfigField::Mode => config.mode.clone(),
ProviderConfigField::Wire => config.wire.clone(),
ProviderConfigField::AuthMode => config.auth_mode.clone(),
ProviderConfigField::InsecureSkipTlsVerify => config
.insecure_skip_tls_verify
.map(|value| value.to_string()),
ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
ProviderConfigField::PathSuffix => config.path_suffix.clone(),
}
}
fn get_provider_config_display_value(
config: &ProviderConfigToml,
field: ProviderConfigField,
) -> Option<String> {
match field {
ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
ProviderConfigField::HttpHeaders => {
serialize_http_headers_for_display(&config.http_headers)
}
_ => get_provider_config_value(config, field),
}
}
fn parse_context_window(value: &str) -> Result<u32> {
let parsed = value.trim().parse::<u32>().with_context(|| {
format!("invalid context_window '{value}': expected a positive token count")
})?;
if parsed == 0 {
bail!("context_window must be greater than 0");
}
Ok(parsed)
}
fn set_provider_config_value(
config: &mut ConfigToml,
provider: ProviderKind,
field: ProviderConfigField,
value: &str,
) -> Result<()> {
match field {
ProviderConfigField::ApiKey => {
let value = value.to_string();
config.providers.for_provider_mut(provider).api_key = Some(value.clone());
if provider == ProviderKind::Deepseek {
config.api_key = Some(value);
}
}
ProviderConfigField::BaseUrl => {
let value = value.to_string();
config.providers.for_provider_mut(provider).base_url = Some(value.clone());
if provider == ProviderKind::Deepseek {
config.base_url = Some(value);
}
}
ProviderConfigField::Model => {
let value = value.to_string();
config.providers.for_provider_mut(provider).model = Some(value.clone());
if provider == ProviderKind::Deepseek {
config.default_text_model = Some(value);
}
}
ProviderConfigField::ContextWindow => {
config.providers.for_provider_mut(provider).context_window =
Some(parse_context_window(value)?);
}
ProviderConfigField::Mode => {
config.providers.for_provider_mut(provider).mode = Some(value.to_string());
}
ProviderConfigField::Wire => {
config.providers.for_provider_mut(provider).wire = Some(value.to_string());
}
ProviderConfigField::AuthMode => {
config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
}
ProviderConfigField::InsecureSkipTlsVerify => {
config
.providers
.for_provider_mut(provider)
.insecure_skip_tls_verify = Some(parse_bool(value)?);
}
ProviderConfigField::HttpHeaders => {
let headers = parse_http_headers(value)?;
config.providers.for_provider_mut(provider).http_headers = headers.clone();
if provider == ProviderKind::Deepseek {
config.http_headers = headers;
}
}
ProviderConfigField::PathSuffix => {
config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
}
}
Ok(())
}
fn unset_provider_config_value(
config: &mut ConfigToml,
provider: ProviderKind,
field: ProviderConfigField,
) {
match field {
ProviderConfigField::ApiKey => {
config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
config.api_key = None;
}
}
ProviderConfigField::BaseUrl => {
config.providers.for_provider_mut(provider).base_url = None;
if provider == ProviderKind::Deepseek {
config.base_url = None;
}
}
ProviderConfigField::Model => {
config.providers.for_provider_mut(provider).model = None;
if provider == ProviderKind::Deepseek {
config.default_text_model = None;
}
}
ProviderConfigField::ContextWindow => {
config.providers.for_provider_mut(provider).context_window = None;
}
ProviderConfigField::Mode => {
config.providers.for_provider_mut(provider).mode = None;
}
ProviderConfigField::Wire => {
config.providers.for_provider_mut(provider).wire = None;
}
ProviderConfigField::AuthMode => {
config.providers.for_provider_mut(provider).auth_mode = None;
}
ProviderConfigField::InsecureSkipTlsVerify => {
config
.providers
.for_provider_mut(provider)
.insecure_skip_tls_verify = None;
}
ProviderConfigField::HttpHeaders => {
config
.providers
.for_provider_mut(provider)
.http_headers
.clear();
if provider == ProviderKind::Deepseek {
config.http_headers.clear();
}
}
ProviderConfigField::PathSuffix => {
config.providers.for_provider_mut(provider).path_suffix = None;
}
}
}
fn insert_provider_config_values(
out: &mut BTreeMap<String, String>,
provider: ProviderKind,
config: &ProviderConfigToml,
) {
if let Some(v) = config.api_key.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::ApiKey),
redact_secret(v),
);
}
if let Some(v) = config.base_url.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::BaseUrl),
v.clone(),
);
}
if let Some(v) = config.model.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::Model),
v.clone(),
);
}
if let Some(v) = config.context_window {
out.insert(
provider_config_key(provider, ProviderConfigField::ContextWindow),
v.to_string(),
);
}
if let Some(v) = config.mode.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::Mode),
v.clone(),
);
}
if let Some(v) = config.auth_mode.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::AuthMode),
v.clone(),
);
}
if let Some(v) = config.insecure_skip_tls_verify {
out.insert(
provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
v.to_string(),
);
}
if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
out.insert(
provider_config_key(provider, ProviderConfigField::HttpHeaders),
v,
);
}
if let Some(v) = config.path_suffix.as_ref() {
out.insert(
provider_config_key(provider, ProviderConfigField::PathSuffix),
v.clone(),
);
}
}
impl ConfigToml {
#[must_use]
pub fn resolve_harness_profile(
&self,
provider_route: &str,
model: &str,
) -> Option<&HarnessProfile> {
self.harness_profiles
.iter()
.chain(built_in_harness_profiles().iter())
.find(|profile| profile.matches_route(provider_route, model))
}
#[must_use]
pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
}
pub fn resolve_fleet(&self, name: &str) -> Result<&NamedFleetConfigToml, FleetResolutionError> {
self.fleets
.get(name)
.ok_or_else(|| FleetResolutionError::UnknownFleet {
name: name.to_string(),
available: self.fleets.keys().cloned().collect(),
})
}
pub fn resolve_fleet_for_operator(
&self,
operator: &str,
) -> Result<(&str, &NamedFleetConfigToml), FleetResolutionError> {
let matches: Vec<(&str, &NamedFleetConfigToml)> = self
.fleets
.iter()
.filter(|(_, fleet)| fleet.operator == operator)
.map(|(name, fleet)| (name.as_str(), fleet))
.collect();
match matches.len() {
0 => {
let mut available: Vec<String> = self
.fleets
.values()
.map(|f| f.operator.clone())
.filter(|op| !op.is_empty())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
available.sort();
Err(FleetResolutionError::UnknownOperator {
operator: operator.to_string(),
available,
})
}
1 => Ok(matches.into_iter().next().unwrap()),
_ => Err(FleetResolutionError::AmbiguousOperator {
operator: operator.to_string(),
fleet_names: matches
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderChain {
providers: Vec<ProviderKind>,
position: usize,
}
pub const HOTBAR_SLOT_COUNT: u8 = 8;
pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
"voice.toggle",
"session.compact",
"mode.plan",
"mode.agent",
"mode.operate",
"palette.open",
"sidebar.toggle",
"trust.toggle",
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HotbarBindingToml {
pub slot: u8,
pub action: String,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HotbarBinding {
pub slot: u8,
pub action: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HotbarConfigWarning {
SlotOutOfRange {
slot: u8,
action: String,
},
DuplicateSlot {
slot: u8,
previous_action: String,
replacement_action: String,
},
UnknownAction {
slot: u8,
action: String,
},
}
impl fmt::Display for HotbarConfigWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SlotOutOfRange { slot, action } => write!(
f,
"hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
),
Self::DuplicateSlot {
slot,
previous_action,
replacement_action,
} => write!(
f,
"hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
),
Self::UnknownAction { slot, action } => write!(
f,
"hotbar slot {slot} references unknown action '{action}'; keeping binding"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HotbarConfigResolution {
pub bindings: Vec<HotbarBinding>,
pub warnings: Vec<HotbarConfigWarning>,
}
#[must_use]
pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
DEFAULT_HOTBAR_ACTIONS
.iter()
.enumerate()
.map(|(idx, action)| HotbarBinding {
slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
action: (*action).to_string(),
label: None,
})
.collect()
}
#[must_use]
pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
default_hotbar_bindings()
.into_iter()
.map(|binding| HotbarBindingToml {
slot: binding.slot,
action: binding.action,
label: binding.label,
})
.collect()
}
#[must_use]
pub fn resolve_hotbar_bindings(
configured: Option<&[HotbarBindingToml]>,
known_action_ids: &[&str],
) -> HotbarConfigResolution {
let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
let mut warnings = Vec::new();
let source = match configured {
Some(bindings) => bindings
.iter()
.map(|binding| HotbarBinding {
slot: binding.slot,
action: binding.action.clone(),
label: binding.label.clone(),
})
.collect::<Vec<_>>(),
None => Vec::new(),
};
let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
for binding in source {
if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
warnings.push(HotbarConfigWarning::SlotOutOfRange {
slot: binding.slot,
action: binding.action,
});
continue;
}
if !known.is_empty() && !known.contains(binding.action.as_str()) {
warnings.push(HotbarConfigWarning::UnknownAction {
slot: binding.slot,
action: binding.action.clone(),
});
}
if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
warnings.push(HotbarConfigWarning::DuplicateSlot {
slot: binding.slot,
previous_action: previous.action,
replacement_action: binding.action,
});
}
}
HotbarConfigResolution {
bindings: by_slot.into_values().collect(),
warnings,
}
}
impl ProviderChain {
#[must_use]
pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
let mut providers = vec![active];
for fallback in fallbacks {
if *fallback != active && !providers.contains(fallback) {
providers.push(*fallback);
}
}
Self {
providers,
position: 0,
}
}
#[must_use]
pub fn providers(&self) -> &[ProviderKind] {
&self.providers
}
#[must_use]
pub fn position(&self) -> usize {
self.position
}
#[must_use]
pub fn current(&self) -> ProviderKind {
self.providers
.get(self.position)
.copied()
.or_else(|| self.providers.first().copied())
.unwrap_or_default()
}
#[must_use]
pub fn has_next(&self) -> bool {
self.position + 1 < self.providers.len()
}
pub fn advance(&mut self) -> Option<ProviderKind> {
if !self.has_next() {
return None;
}
self.position += 1;
Some(self.current())
}
pub fn reset(&mut self) {
self.position = 0;
}
#[must_use]
pub fn is_fallback_active(&self) -> bool {
self.position > 0
}
#[must_use]
pub fn remaining(&self) -> usize {
self.providers.len() - self.position
}
}
#[cfg(test)]
mod provider_chain_tests {
use super::*;
#[test]
fn current_on_empty_chain_returns_default_provider() {
let chain = ProviderChain {
providers: vec![],
position: 0,
};
assert_eq!(chain.current(), ProviderKind::default());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookSinksToml {
#[serde(default)]
pub unix_socket_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillsToml {
#[serde(default)]
pub registry_url: Option<String>,
#[serde(default)]
pub max_install_size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolsToml {
#[serde(default)]
pub always_load: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotsToml {
#[serde(default = "default_snapshots_enabled")]
pub enabled: bool,
#[serde(default = "default_snapshot_max_age_days")]
pub max_age_days: u64,
}
fn default_snapshots_enabled() -> bool {
true
}
fn default_snapshot_max_age_days() -> u64 {
7
}
impl Default for SnapshotsToml {
fn default() -> Self {
Self {
enabled: default_snapshots_enabled(),
max_age_days: default_snapshot_max_age_days(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FleetResolutionError {
UnknownFleet {
name: String,
available: Vec<String>,
},
UnknownOperator {
operator: String,
available: Vec<String>,
},
AmbiguousOperator {
operator: String,
fleet_names: Vec<String>,
},
}
impl std::fmt::Display for FleetResolutionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownFleet { name, available } => {
write!(f, "fleet `{name}` is not defined")?;
if available.is_empty() {
write!(
f,
". No named fleets are configured. Add `[fleets.{name}]` to your \
config.toml or use the default `[fleet]` table."
)
} else {
write!(
f,
". Available named fleets: {}. Check your config.toml `[fleets.*]` \
tables.",
available.join(", ")
)
}
}
Self::UnknownOperator {
operator,
available,
} => {
write!(f, "no fleet is owned by operator `{operator}`")?;
if available.is_empty() {
write!(
f,
". No named fleets define an operator. Add \
`operator = \"{operator}\"` inside a `[fleets.<name>]` table."
)
} else {
write!(
f,
". Operators with configured fleets: {}.",
available.join(", ")
)
}
}
Self::AmbiguousOperator {
operator,
fleet_names,
} => {
write!(
f,
"operator `{operator}` owns multiple fleets ({}); specify a fleet name \
explicitly.",
fleet_names.join(", ")
)
}
}
}
}
impl std::error::Error for FleetResolutionError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetConfigToml {
#[serde(default = "default_fleet_trust_level_str")]
pub default_trust_level: String,
#[serde(default = "default_fleet_require_identity")]
pub require_identity_verification: bool,
#[serde(default = "default_fleet_max_trust_level_str")]
pub max_trust_level: String,
#[serde(default)]
pub roles: BTreeMap<String, FleetRolePreset>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profiles: BTreeMap<String, FleetProfile>,
#[serde(default)]
pub exec: FleetExecConfig,
}
pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetExecConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disallowed_tools: Vec<String>,
#[serde(default = "default_fleet_max_turns")]
pub max_turns: u32,
#[serde(default = "default_fleet_max_spawn_depth")]
pub max_spawn_depth: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub append_system_prompt: String,
#[serde(default = "default_fleet_output_format")]
pub output_format: String,
}
pub const FLEET_DEFAULT_MAX_TURNS: u32 = 500;
fn default_fleet_max_turns() -> u32 {
FLEET_DEFAULT_MAX_TURNS
}
fn default_fleet_max_spawn_depth() -> u32 {
DEFAULT_SPAWN_DEPTH
}
fn default_fleet_output_format() -> String {
"text".to_string()
}
impl Default for FleetExecConfig {
fn default() -> Self {
Self {
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
max_turns: default_fleet_max_turns(),
max_spawn_depth: default_fleet_max_spawn_depth(),
append_system_prompt: String::new(),
output_format: default_fleet_output_format(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct FleetProfile {
#[serde(default)]
pub slot: FleetSlot,
#[serde(default)]
pub role: FleetRole,
#[serde(default)]
pub loadout: FleetLoadout,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub permissions: FleetProfilePermissions,
#[serde(default)]
pub delegation: FleetDelegationHints,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FleetRole {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
impl Default for FleetRole {
fn default() -> Self {
Self {
name: "general".to_string(),
description: None,
instructions: None,
}
}
}
impl<'de> Deserialize<'de> for FleetRole {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum FleetRoleWire {
Name(String),
Full {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
instructions: Option<String>,
},
}
match FleetRoleWire::deserialize(deserializer)? {
FleetRoleWire::Name(name) => Ok(Self {
name,
..Self::default()
}),
FleetRoleWire::Full {
name,
description,
instructions,
} => Ok(Self {
name: name.unwrap_or_else(|| Self::default().name),
description,
instructions,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FleetSlot {
Manager,
Scout,
Planner,
Implementer,
Reviewer,
Verifier,
Operator,
Summarizer,
#[default]
General,
Custom(String),
}
impl FleetSlot {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Manager => "manager",
Self::Scout => "scout",
Self::Planner => "planner",
Self::Implementer => "implementer",
Self::Reviewer => "reviewer",
Self::Verifier => "verifier",
Self::Operator => "operator",
Self::Summarizer => "summarizer",
Self::General => "general",
Self::Custom(value) => value.as_str(),
}
}
#[must_use]
pub fn from_name(value: &str) -> Self {
match value.trim() {
"manager" | "coordinator" => Self::Manager,
"scout" | "research" | "research-worker" => Self::Scout,
"planner" | "plan" | "awaiter" => Self::Planner,
"implementer" | "builder" => Self::Implementer,
"reviewer" => Self::Reviewer,
"verifier" | "tester" => Self::Verifier,
"operator" | "incident" | "incident-worker" => Self::Operator,
"summarizer" | "reducer" => Self::Summarizer,
"general" | "" => Self::General,
other => Self::Custom(other.to_string()),
}
}
}
impl Serialize for FleetSlot {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for FleetSlot {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::from_name(&value))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FleetLoadout {
#[default]
Inherit,
Fast,
Custom(String),
}
impl FleetLoadout {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Inherit => "inherit",
Self::Fast => "fast",
Self::Custom(value) => value.as_str(),
}
}
#[must_use]
pub fn from_name(value: &str) -> Self {
match value.trim() {
"inherit" | "default" | "auto" | "" => Self::Inherit,
"fast" => Self::Fast,
other => Self::Custom(other.to_string()),
}
}
}
impl Serialize for FleetLoadout {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for FleetLoadout {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::from_name(&value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FleetProfilePermissions {
#[serde(default)]
pub allow_shell: bool,
#[serde(default)]
pub trust: bool,
#[serde(default = "default_fleet_profile_approval_required")]
pub approval_required: bool,
}
fn default_fleet_profile_approval_required() -> bool {
true
}
impl Default for FleetProfilePermissions {
fn default() -> Self {
Self {
allow_shell: false,
trust: false,
approval_required: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct FleetDelegationHints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_spawn_depth: Option<u32>,
#[serde(
default,
alias = "concurrency",
skip_serializing_if = "Option::is_none"
)]
pub max_concurrency: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetRolePreset {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_profile: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust_level: Option<String>,
}
fn default_fleet_trust_level_str() -> String {
"sandbox".to_string()
}
fn default_fleet_require_identity() -> bool {
true
}
fn default_fleet_max_trust_level_str() -> String {
"operator".to_string()
}
impl Default for FleetConfigToml {
fn default() -> Self {
Self {
default_trust_level: default_fleet_trust_level_str(),
require_identity_verification: default_fleet_require_identity(),
max_trust_level: default_fleet_max_trust_level_str(),
roles: BTreeMap::new(),
profiles: BTreeMap::new(),
exec: FleetExecConfig::default(),
}
}
}
impl FleetConfigToml {
#[must_use]
pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
self.roles
.get(name)
.cloned()
.or_else(|| built_in_role_presets().get(name).cloned())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NamedFleetConfigToml {
pub operator: String,
#[serde(default = "default_fleet_trust_level_str")]
pub default_trust_level: String,
#[serde(default = "default_fleet_require_identity")]
pub require_identity_verification: bool,
#[serde(default = "default_fleet_max_trust_level_str")]
pub max_trust_level: String,
#[serde(default)]
pub roles: BTreeMap<String, FleetRolePreset>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profiles: BTreeMap<String, FleetProfile>,
#[serde(default)]
pub exec: FleetExecConfig,
}
impl NamedFleetConfigToml {
#[must_use]
pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
self.roles
.get(name)
.cloned()
.or_else(|| built_in_role_presets().get(name).cloned())
}
#[must_use]
pub fn as_fleet_config(&self) -> FleetConfigToml {
FleetConfigToml {
default_trust_level: self.default_trust_level.clone(),
require_identity_verification: self.require_identity_verification,
max_trust_level: self.max_trust_level.clone(),
roles: self.roles.clone(),
profiles: self.profiles.clone(),
exec: self.exec.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkflowConfigToml {
#[serde(default = "default_workflow_automatic")]
pub automatic: bool,
#[serde(default = "default_workflow_auto_start_read_only")]
pub auto_start_read_only: bool,
#[serde(default = "default_workflow_require_approval_for_writes")]
pub require_approval_for_writes: bool,
#[serde(default = "default_workflow_auto_start_child_limit")]
pub auto_start_child_limit: u32,
#[serde(default = "default_workflow_max_children")]
pub max_children: u32,
#[serde(default = "default_workflow_max_concurrent")]
pub max_concurrent: u32,
#[serde(default = "default_workflow_max_depth")]
pub max_depth: u32,
#[serde(default = "default_workflow_default_token_budget")]
pub default_token_budget: u64,
#[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
pub max_parallel_writes_without_worktree: u32,
#[serde(default = "default_workflow_persist_completed_activity")]
pub persist_completed_activity: bool,
#[serde(default = "default_workflow_persist_completed_across_restarts")]
pub persist_completed_across_restarts: bool,
}
fn default_workflow_automatic() -> bool {
true
}
fn default_workflow_auto_start_read_only() -> bool {
true
}
fn default_workflow_require_approval_for_writes() -> bool {
true
}
fn default_workflow_auto_start_child_limit() -> u32 {
16
}
fn default_workflow_max_children() -> u32 {
1000
}
fn default_workflow_max_concurrent() -> u32 {
16
}
fn default_workflow_max_depth() -> u32 {
2
}
fn default_workflow_default_token_budget() -> u64 {
120_000
}
fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
0
}
fn default_workflow_persist_completed_activity() -> bool {
true
}
fn default_workflow_persist_completed_across_restarts() -> bool {
true
}
impl Default for WorkflowConfigToml {
fn default() -> Self {
Self {
automatic: default_workflow_automatic(),
auto_start_read_only: default_workflow_auto_start_read_only(),
require_approval_for_writes: default_workflow_require_approval_for_writes(),
auto_start_child_limit: default_workflow_auto_start_child_limit(),
max_children: default_workflow_max_children(),
max_concurrent: default_workflow_max_concurrent(),
max_depth: default_workflow_max_depth(),
default_token_budget: default_workflow_default_token_budget(),
max_parallel_writes_without_worktree:
default_workflow_max_parallel_writes_without_worktree(),
persist_completed_activity: default_workflow_persist_completed_activity(),
persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
}
}
}
#[must_use]
pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
[
(
"smoke-runner".to_string(),
FleetRolePreset {
description: Some("Lightweight read-only smoke check worker".to_string()),
tool_profile: Some("read-only".to_string()),
tools: vec![],
capabilities: vec![],
timeout_seconds: Some(300),
trust_level: Some("local".to_string()),
},
),
(
"reviewer".to_string(),
FleetRolePreset {
description: Some("Read-only code and documentation review".to_string()),
tool_profile: Some("read-only".to_string()),
tools: vec![],
capabilities: vec![],
timeout_seconds: Some(600),
trust_level: None,
},
),
(
"builder".to_string(),
FleetRolePreset {
description: Some(
"Read-write builder with compilation and test access".to_string(),
),
tool_profile: Some("read-write".to_string()),
tools: vec![],
capabilities: vec![],
timeout_seconds: Some(1800),
trust_level: Some("local".to_string()),
},
),
(
"read-only".to_string(),
FleetRolePreset {
description: Some(
"Minimal read-only observer with no writes or secrets".to_string(),
),
tool_profile: Some("read-only".to_string()),
tools: vec![],
capabilities: vec![],
timeout_seconds: Some(300),
trust_level: Some("sandbox".to_string()),
},
),
]
.into()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum VerifierVerdictPolicy {
#[default]
Hunt,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VerifierConfigToml {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub verdict_policy: VerifierVerdictPolicy,
}
impl Default for VerifierConfigToml {
fn default() -> Self {
Self {
enabled: false,
verdict_policy: VerifierVerdictPolicy::Hunt,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AdvisorConfigToml {
#[serde(default)]
pub enabled: bool,
#[serde(default = "advisor_default_max_tool_calls")]
pub max_tool_calls: u32,
#[serde(default = "advisor_default_rate_limit_secs")]
pub rate_limit_secs: u64,
#[serde(default = "advisor_default_dedup_window_secs")]
pub dedup_window_secs: u64,
#[serde(default)]
pub model: Option<String>,
}
fn advisor_default_max_tool_calls() -> u32 {
10
}
fn advisor_default_rate_limit_secs() -> u64 {
60
}
fn advisor_default_dedup_window_secs() -> u64 {
300
}
impl Default for AdvisorConfigToml {
fn default() -> Self {
Self {
enabled: false,
max_tool_calls: advisor_default_max_tool_calls(),
rate_limit_secs: advisor_default_rate_limit_secs(),
dedup_window_secs: advisor_default_dedup_window_secs(),
model: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkPolicyToml {
#[serde(default = "default_network_decision")]
pub default: String,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub proxy: Vec<String>,
#[serde(default)]
pub proxy_fake_ip_cidrs: Vec<String>,
#[serde(default = "default_network_audit")]
pub audit: bool,
}
fn default_network_decision() -> String {
"prompt".to_string()
}
fn default_network_audit() -> bool {
true
}
impl Default for NetworkPolicyToml {
fn default() -> Self {
Self {
default: default_network_decision(),
allow: Vec::new(),
deny: Vec::new(),
proxy: Vec::new(),
proxy_fake_ip_cidrs: Vec::new(),
audit: default_network_audit(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct CustomLspDef {
pub language_id: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LspConfigToml {
pub enabled: Option<bool>,
pub poll_after_edit_ms: Option<u64>,
pub max_diagnostics_per_file: Option<usize>,
pub include_warnings: Option<bool>,
pub servers: Option<BTreeMap<String, Vec<String>>>,
pub custom: Option<BTreeMap<String, CustomLspDef>>,
}
impl ConfigToml {
#[must_use]
pub fn provider_id(&self) -> &str {
self.named_custom_provider_id()
.unwrap_or_else(|| self.provider.as_str())
}
#[must_use]
pub fn named_custom_provider_id(&self) -> Option<&str> {
(self.provider == ProviderKind::Custom)
.then_some(self.selected_provider_id.as_deref())
.flatten()
}
fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
let table = self
.providers
.extras
.get(provider_id)
.and_then(toml::Value::as_table)
.with_context(|| {
format!(
"custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
)
})?;
let compatible = table
.get("kind")
.and_then(toml::Value::as_str)
.is_some_and(|kind| {
kind.trim()
.to_ascii_lowercase()
.replace('_', "-")
.eq("openai-compatible")
});
if !compatible {
bail!(
"custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
);
}
Ok(table)
}
fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
let provider_id = self.named_custom_provider_id()?;
self.named_custom_provider_table(provider_id).ok()?;
self.providers
.extras
.get(provider_id)
.cloned()?
.try_into()
.ok()
}
fn custom_provider_table_mut(&mut self, provider_id: &str) -> Result<&mut toml::value::Table> {
let entry = self
.providers
.extras
.entry(provider_id.to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
entry.as_table_mut().with_context(|| {
format!("custom provider '{provider_id}' must be a [providers.{provider_id}] table")
})
}
fn set_custom_provider_value(
&mut self,
provider_id: &str,
field_key: &str,
value: &str,
) -> Result<()> {
if is_builtin_provider_config_id(provider_id) {
bail!(
"unknown field '{field_key}' for built-in provider '{provider_id}': \
expected one of api_key, base_url, model, context_window, mode, auth_mode, \
insecure_skip_tls_verify, http_headers, path_suffix"
);
}
if field_key == "kind" {
let compatible =
value.trim().to_ascii_lowercase().replace('_', "-") == "openai-compatible";
if !compatible {
bail!(
"custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
);
}
self.custom_provider_table_mut(provider_id)?.insert(
"kind".to_string(),
toml::Value::String(value.trim().to_string()),
);
return Ok(());
}
let Some(field) = ProviderConfigField::parse(field_key) else {
bail!(
"unknown field '{field_key}' for custom provider '{provider_id}': \
expected one of {CUSTOM_PROVIDER_FIELD_HINT}"
);
};
let toml_value = match field {
ProviderConfigField::ApiKey
| ProviderConfigField::BaseUrl
| ProviderConfigField::Model
| ProviderConfigField::Mode
| ProviderConfigField::Wire
| ProviderConfigField::AuthMode
| ProviderConfigField::PathSuffix => toml::Value::String(value.to_string()),
ProviderConfigField::ContextWindow => {
toml::Value::Integer(i64::from(parse_context_window(value)?))
}
ProviderConfigField::InsecureSkipTlsVerify => toml::Value::Boolean(parse_bool(value)?),
ProviderConfigField::HttpHeaders => toml::Value::Table(
parse_http_headers(value)?
.into_iter()
.map(|(name, header)| (name, toml::Value::String(header)))
.collect(),
),
};
self.custom_provider_table_mut(provider_id)?
.insert(field.key().to_string(), toml_value);
Ok(())
}
fn get_custom_provider_value_with(
&self,
provider_id: &str,
field_key: &str,
render: fn(&ProviderConfigToml, ProviderConfigField) -> Option<String>,
) -> Option<String> {
let table = self.providers.extras.get(provider_id)?.as_table()?;
if field_key == "kind" {
return table.get("kind")?.as_str().map(str::to_string);
}
let field = ProviderConfigField::parse(field_key)?;
let config: ProviderConfigToml = toml::Value::Table(table.clone()).try_into().ok()?;
render(&config, field)
}
fn unset_custom_provider_value(&mut self, provider_id: &str, field_key: &str) {
let Some(table) = self
.providers
.extras
.get_mut(provider_id)
.and_then(toml::Value::as_table_mut)
else {
return;
};
let leg = if field_key == "kind" {
"kind"
} else {
ProviderConfigField::parse(field_key).map_or(field_key, |field| field.key())
};
table.remove(leg);
}
fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
self.selected_provider_id = None;
if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
return Ok(());
}
self.named_custom_provider_table(provider_id)?;
self.selected_provider_id = Some(provider_id.to_string());
Ok(())
}
pub fn merge_project_overrides(&mut self, project: ConfigToml) {
if project.default_text_model.is_some() {
self.default_text_model = project.default_text_model;
}
if project.model.is_some() {
self.model = project.model;
}
if project.output_mode.is_some() {
self.output_mode = project.output_mode;
}
if project.verbosity.is_some() {
self.verbosity = project.verbosity;
}
if project.log_level.is_some() {
self.log_level = project.log_level;
}
if let Some(policy) = project.approval_policy
&& project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
{
self.approval_policy = Some(policy);
}
if let Some(mode) = project.sandbox_mode
&& project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
{
self.sandbox_mode = Some(mode);
}
if project.tools.is_some() {
self.tools = project.tools;
}
for provider in provider::all_providers().iter().map(|p| p.kind()) {
merge_project_provider_config(
self.providers.for_provider_mut(provider),
project.providers.for_provider(provider),
);
}
}
#[must_use]
pub fn get_value(&self, key: &str) -> Option<String> {
if let Some((provider, field)) = parse_provider_config_key(key) {
return get_provider_config_value(self.providers.for_provider(provider), field);
}
if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
return self.get_custom_provider_value_with(
provider_id,
field_key,
get_provider_config_value,
);
}
match key {
"provider" => Some(self.provider_id().to_string()),
"stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
Some(self.stream_chunk_timeout_secs().to_string())
}
"api_key" => self.api_key.clone(),
"base_url" => self.base_url.clone(),
"http_headers" => serialize_http_headers(&self.http_headers),
"default_text_model" => self.default_text_model.clone(),
"model" => self.model.clone(),
"auth.mode" => self.auth_mode.clone(),
"output_mode" => self.output_mode.clone(),
"verbosity" => self.verbosity.clone(),
"log_level" => self.log_level.clone(),
"telemetry" => self.telemetry.map(|v| v.to_string()),
"telemetry_endpoint" => self.telemetry_endpoint.clone(),
"approval_policy" => self.approval_policy.clone(),
"sandbox_mode" => self.sandbox_mode.clone(),
"tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
"hook_sinks.unix_socket_path" => self
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_ref())
.map(|path| path.display().to_string()),
_ => self.extras.get(key).map(toml::Value::to_string),
}
}
#[must_use]
pub fn get_raw_string(&self, key: &str) -> Option<&str> {
self.extras.get(key).and_then(toml::Value::as_str)
}
#[must_use]
pub fn get_display_value(&self, key: &str) -> Option<String> {
if let Some((provider, field)) = parse_provider_config_key(key) {
return get_provider_config_display_value(self.providers.for_provider(provider), field);
}
if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
return self.get_custom_provider_value_with(
provider_id,
field_key,
get_provider_config_display_value,
);
}
if key == "http_headers" {
return serialize_http_headers_for_display(&self.http_headers);
}
if let Some(value) = self.extras.get(key) {
return Some(redact_toml_value_for_display(key, value));
}
self.get_value(key).map(|value| {
if is_sensitive_config_key(key) {
redact_secret(&value)
} else {
value
}
})
}
#[must_use]
pub fn stream_chunk_timeout_secs(&self) -> u64 {
let raw = self
.extras
.get("tui")
.and_then(toml::Value::as_table)
.and_then(|table| table.get("stream_chunk_timeout_secs"))
.and_then(toml_value_as_u64)
.or_else(|| {
self.extras
.get("tui.stream_chunk_timeout_secs")
.and_then(toml_value_as_u64)
})
.or_else(|| {
self.extras
.get("stream_chunk_timeout_secs")
.and_then(toml_value_as_u64)
})
.unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
if raw == 0 {
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
} else {
raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
}
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
if let Some((provider, field)) = parse_provider_config_key(key) {
return set_provider_config_value(self, provider, field, value);
}
if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
return self.set_custom_provider_value(provider_id, field_key, value);
}
match key {
"provider" => {
if let Some(provider) = ProviderKind::parse_config_identity(value) {
self.provider = provider;
self.selected_provider_id = None;
} else {
let provider_id = value.trim();
self.named_custom_provider_table(provider_id)
.with_context(|| {
format!(
"unknown provider '{value}': expected {} or a configured custom provider",
ProviderKind::names_hint()
)
})?;
self.provider = ProviderKind::Custom;
self.selected_provider_id = Some(provider_id.to_string());
}
}
"api_key" => self.api_key = Some(value.to_string()),
"base_url" => self.base_url = Some(value.to_string()),
"http_headers" => self.http_headers = parse_http_headers(value)?,
"default_text_model" => self.default_text_model = Some(value.to_string()),
"model" => self.model = Some(value.to_string()),
"auth.mode" => self.auth_mode = Some(value.to_string()),
"output_mode" => self.output_mode = Some(value.to_string()),
"verbosity" => self.verbosity = Some(value.to_string()),
"log_level" => self.log_level = Some(value.to_string()),
"telemetry" => {
self.telemetry = Some(parse_bool(value)?);
}
"telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
"approval_policy" => self.approval_policy = Some(value.to_string()),
"sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
"hook_sinks.unix_socket_path" => {
self.hook_sinks
.get_or_insert_with(HookSinksToml::default)
.unix_socket_path = Some(PathBuf::from(value));
}
_ => {
self.extras
.insert(key.to_string(), toml::Value::String(value.to_string()));
}
}
Ok(())
}
pub fn unset_value(&mut self, key: &str) -> Result<()> {
if let Some((provider, field)) = parse_provider_config_key(key) {
unset_provider_config_value(self, provider, field);
return Ok(());
}
if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
self.unset_custom_provider_value(provider_id, field_key);
return Ok(());
}
match key {
"provider" => {
self.provider = ProviderKind::Deepseek;
self.selected_provider_id = None;
}
"api_key" => self.api_key = None,
"base_url" => self.base_url = None,
"http_headers" => self.http_headers.clear(),
"default_text_model" => self.default_text_model = None,
"model" => self.model = None,
"auth.mode" => self.auth_mode = None,
"output_mode" => self.output_mode = None,
"verbosity" => self.verbosity = None,
"log_level" => self.log_level = None,
"telemetry" => self.telemetry = None,
"telemetry_endpoint" => self.telemetry_endpoint = None,
"approval_policy" => self.approval_policy = None,
"sandbox_mode" => self.sandbox_mode = None,
"hook_sinks.unix_socket_path" => {
if let Some(sinks) = self.hook_sinks.as_mut() {
sinks.unix_socket_path = None;
}
}
_ => {
self.extras.remove(key);
}
}
Ok(())
}
#[must_use]
pub fn list_values(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
out.insert("provider".to_string(), self.provider_id().to_string());
if let Some(v) = self.api_key.as_ref() {
out.insert("api_key".to_string(), redact_secret(v));
}
if let Some(v) = self.base_url.as_ref() {
out.insert("base_url".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
out.insert("http_headers".to_string(), v);
}
if let Some(v) = self.default_text_model.as_ref() {
out.insert("default_text_model".to_string(), v.clone());
}
if let Some(v) = self.model.as_ref() {
out.insert("model".to_string(), v.clone());
}
if let Some(v) = self.auth_mode.as_ref() {
out.insert("auth.mode".to_string(), v.clone());
}
if let Some(v) = self.output_mode.as_ref() {
out.insert("output_mode".to_string(), v.clone());
}
if let Some(v) = self.verbosity.as_ref() {
out.insert("verbosity".to_string(), v.clone());
}
if let Some(v) = self.log_level.as_ref() {
out.insert("log_level".to_string(), v.clone());
}
if let Some(v) = self.telemetry {
out.insert("telemetry".to_string(), v.to_string());
}
if let Some(v) = self.telemetry_endpoint.as_ref() {
out.insert("telemetry_endpoint".to_string(), v.clone());
}
if let Some(v) = self.approval_policy.as_ref() {
out.insert("approval_policy".to_string(), v.clone());
}
if let Some(v) = self.sandbox_mode.as_ref() {
out.insert("sandbox_mode".to_string(), v.clone());
}
if let Some(v) = self
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_ref())
{
out.insert(
"hook_sinks.unix_socket_path".to_string(),
v.display().to_string(),
);
}
for provider in provider::all_providers().iter().map(|p| p.kind()) {
insert_provider_config_values(
&mut out,
provider,
self.providers.for_provider(provider),
);
}
for (k, v) in &self.extras {
out.insert(k.clone(), redact_toml_value_for_display(k, v));
}
out
}
#[must_use]
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
let no_keyring = Secrets::new(std::sync::Arc::new(
codewhale_secrets::InMemoryKeyringStore::new(),
));
self.resolve_runtime_options_with_secrets(cli, &no_keyring)
}
#[must_use]
pub fn resolve_runtime_options_with_secrets(
&self,
cli: &CliRuntimeOverrides,
secrets: &Secrets,
) -> ResolvedRuntimeOptions {
let env = EnvRuntimeOverrides::load();
let (provider, provider_source) = if let Some(provider) = cli.provider {
(provider, ProviderSource::Cli)
} else if let Some(provider) = env.provider {
(
provider,
ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
)
} else {
(self.provider, ProviderSource::Config)
};
let mut provider_cfg = if provider == ProviderKind::Custom
&& matches!(provider_source, ProviderSource::Config)
{
self.named_custom_provider_config()
.unwrap_or_else(|| self.providers.for_provider(provider).clone())
} else {
self.providers.for_provider(provider).clone()
};
if provider == ProviderKind::SiliconflowCN {
let fb = &self.providers.siliconflow;
if provider_cfg.api_key.is_none() {
provider_cfg.api_key = fb.api_key.clone();
}
if provider_cfg.base_url.is_none() {
provider_cfg.base_url = fb.base_url.clone();
}
if provider_cfg.model.is_none() {
provider_cfg.model = fb.model.clone();
}
}
let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
.then(|| self.api_key.clone())
.flatten();
let root_base_url = matches!(
provider,
ProviderKind::Deepseek | ProviderKind::XiaomiMimo | ProviderKind::OpenaiCodex
)
.then(|| self.base_url.clone())
.flatten();
let auth_mode = cli
.auth_mode
.clone()
.or_else(|| env.auth_mode.clone())
.or_else(|| provider_cfg.auth_mode.clone())
.or_else(|| self.auth_mode.clone());
let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
let cli_base_url = cli.base_url.clone();
let env_base_url = env.base_url_for(provider);
let file_base_url = provider_cfg.base_url.clone().or(root_base_url);
let base_url_from_file =
cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
env.xiaomi_mimo_mode
.clone()
.or_else(|| provider_cfg.mode.clone())
} else {
None
};
let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
xiaomi_mimo_env_api_key_for_runtime(
xiaomi_mimo_mode.as_deref(),
configured_base_url.as_deref(),
)
} else {
None
};
let explicit_api_key_for_endpoint = cli
.api_key
.as_deref()
.or(from_file.as_deref().filter(|value| {
classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
}))
.or(xiaomi_mimo_env_api_key.as_deref());
let provider_wire = provider_cfg.wire.as_deref();
let base_url = if provider == ProviderKind::XiaomiMimo {
resolve_xiaomi_mimo_base_url(
configured_base_url,
explicit_api_key_for_endpoint,
xiaomi_mimo_mode.as_deref(),
)
} else if is_modelstudio_family(provider) {
resolve_modelstudio_base_url(
configured_base_url,
provider,
provider_cfg.mode.as_deref(),
provider_wire,
)
} else if matches!(
provider,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
) {
resolve_minimax_base_url(configured_base_url, provider, provider_wire)
} else if matches!(
provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
) {
resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
} else {
configured_base_url.unwrap_or_else(|| match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
ProviderKind::Moonshot => {
if auth_mode
.as_deref()
.is_some_and(auth_mode_uses_kimi_imported_token)
{
DEFAULT_KIMI_CODE_BASE_URL.to_string()
} else {
DEFAULT_MOONSHOT_BASE_URL.to_string()
}
}
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL.to_string(),
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL.to_string(),
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL.to_string(),
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => {
DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
}
ProviderKind::Custom => provider.provider().default_base_url().to_string(),
})
};
let legacy_ollama_cloud = provider::migrates_legacy_ollama_cloud_route(provider, &base_url);
let provider = if legacy_ollama_cloud {
ProviderKind::OllamaCloud
} else {
provider
};
let uses_kimi_imported_token = provider == ProviderKind::Moonshot
&& auth_mode
.as_deref()
.is_some_and(auth_mode_uses_kimi_imported_token);
let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
let (api_key, api_key_source) = if auth_disabled {
(None, None)
} else if let Some(value) = cli.api_key.clone() {
(Some(value), Some(RuntimeApiKeySource::Cli))
} else if uses_kimi_imported_token && !custom_endpoint {
(None, None)
} else if (!custom_endpoint || base_url_from_file)
&& let Some(value) = from_file.clone().filter(|value| {
classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
})
{
(Some(value), Some(RuntimeApiKeySource::ConfigFile))
} else if !custom_endpoint
&& let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
{
(Some(value), Some(RuntimeApiKeySource::Env))
} else if custom_endpoint {
(None, None)
} else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
match env_api_key_for_provider(provider) {
Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
None => (None, None),
}
} else {
match stored_api_key_for_provider(secrets, provider, legacy_ollama_cloud) {
Some((value, source)) => {
let source = match source {
SecretSource::Keyring => RuntimeApiKeySource::Keyring,
SecretSource::Env => RuntimeApiKeySource::Env,
};
(Some(value), Some(source))
}
None => match env_api_key_for_provider(provider) {
Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
None => (None, None),
},
}
};
let env_provider_model = env.model_for(provider, &base_url);
let root_default_model = self
.default_text_model
.clone()
.filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
let model_source = if cli.model.is_some() {
ModelSource::Cli
} else if env.model.is_some() || env_provider_model.is_some() {
ModelSource::Env
} else if provider_cfg.model.is_some() {
ModelSource::ProviderConfig
} else if root_default_model.is_some() {
ModelSource::RootDefaultTextModel
} else if self.model.is_some() {
ModelSource::RootModel
} else {
ModelSource::ProviderDefault
};
let explicit_model = model_source.is_explicit();
let model = cli
.model
.clone()
.or_else(|| env.model.clone())
.or(env_provider_model)
.or_else(|| provider_cfg.model.clone())
.or(root_default_model)
.or_else(|| self.model.clone())
.unwrap_or_else(|| {
if provider == ProviderKind::Moonshot
&& (auth_mode
.as_deref()
.is_some_and(auth_mode_uses_kimi_imported_token)
|| moonshot_base_url_uses_kimi_code(&base_url))
{
DEFAULT_KIMI_CODE_MODEL.to_string()
} else {
default_model_for_provider(provider).to_string()
}
});
let model = if provider == ProviderKind::OpencodeGo {
normalize_model_for_provider(provider, &model)
} else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
model.trim().to_string()
} else {
normalize_model_for_provider(provider, &model)
};
let mut http_headers = self.http_headers.clone();
http_headers.extend(provider_cfg.http_headers.clone());
if let Some(env_headers) = env.http_headers {
http_headers.extend(env_headers);
}
http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
if auth_disabled {
http_headers.retain(|name, _| !is_upstream_auth_header(name));
}
let output_mode = cli
.output_mode
.clone()
.or_else(|| env.output_mode.clone())
.or_else(|| self.output_mode.clone());
let log_level = cli
.log_level
.clone()
.or_else(|| env.log_level.clone())
.or_else(|| self.log_level.clone());
let telemetry_allowed = cli
.telemetry
.or(env.telemetry)
.or(self.telemetry)
.unwrap_or(true);
let telemetry_persisted_off = self.telemetry == Some(false);
let telemetry = telemetry_allowed
&& env.telemetry != Some(false)
&& !env.telemetry_env_invalid
&& !env.telemetry_floor
&& !telemetry_persisted_off;
let telemetry_explicit_off = telemetry_persisted_off;
let telemetry_endpoint = match env
.telemetry_endpoint
.clone()
.or_else(|| self.telemetry_endpoint.clone())
{
Some(configured) if configured.trim().is_empty() => None,
Some(configured) => Some(configured),
None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
};
let approval_policy = cli
.approval_policy
.clone()
.or_else(|| env.approval_policy.clone())
.or_else(|| self.approval_policy.clone());
let sandbox_mode = cli
.sandbox_mode
.clone()
.or_else(|| env.sandbox_mode.clone())
.or_else(|| self.sandbox_mode.clone());
let yolo = cli.yolo.or(env.yolo);
let verbosity = cli
.verbosity
.clone()
.or_else(|| env.verbosity.clone())
.or_else(|| self.verbosity.clone());
ResolvedRuntimeOptions {
provider,
provider_source,
model,
model_source,
api_key,
api_key_source,
base_url,
auth_mode,
insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
output_mode,
log_level,
telemetry,
telemetry_explicit_off,
telemetry_endpoint,
approval_policy,
sandbox_mode,
yolo,
verbosity,
http_headers,
}
}
}
fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
if source.model.is_some() {
target.model = source.model.clone();
}
}
pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
#[must_use]
pub fn telemetry_floor_in_force() -> bool {
if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
&& let Ok(declared) = parse_bool(&raw)
{
return declared;
}
let Ok(raw) =
std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
else {
return false;
};
!matches!(parse_bool(&raw), Ok(true))
}
#[must_use]
pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
let Some(project_rank) = approval_policy_rank(project) else {
return false;
};
match current.and_then(approval_policy_rank) {
Some(current_rank) => project_rank >= current_rank,
None => project_rank >= 2,
}
}
#[must_use]
pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
let normalized_project = project.trim().to_ascii_lowercase();
if normalized_project == "external-sandbox" {
return current
.map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
.unwrap_or(false);
}
let Some(project_rank) = sandbox_mode_rank(project) else {
return false;
};
match current.and_then(sandbox_mode_rank) {
Some(current_rank) => project_rank >= current_rank,
None => project_rank >= 2,
}
}
fn approval_policy_rank(value: &str) -> Option<u8> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(0),
"suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
"never" | "deny" | "denied" => Some(2),
_ => None,
}
}
fn sandbox_mode_rank(value: &str) -> Option<u8> {
match value.trim().to_ascii_lowercase().as_str() {
"danger-full-access" => Some(0),
"external-sandbox" => Some(0),
"workspace-write" => Some(1),
"read-only" => Some(2),
_ => None,
}
}
#[derive(Debug, Clone)]
pub enum ProjectConfigOutcome {
Missing,
Loaded(Box<ConfigToml>),
Invalid {
path: PathBuf,
reason: String,
},
}
impl ProjectConfigOutcome {
#[must_use]
pub fn into_config(self) -> Option<ConfigToml> {
match self {
Self::Loaded(config) => Some(*config),
Self::Missing | Self::Invalid { .. } => None,
}
}
#[must_use]
pub fn invalid(&self) -> Option<(&Path, &str)> {
match self {
Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
Self::Missing | Self::Loaded(_) => None,
}
}
}
pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
let path = workspace.join(dir).join(CONFIG_FILE_NAME);
if !project_config_candidate_exists(&path) {
continue;
}
let raw = match read_checked_config_file(&path) {
Ok(raw) => raw,
Err(e) => {
tracing::warn!("Failed to read project config {}: {e:#}", path.display());
return ProjectConfigOutcome::Invalid {
path,
reason: format!("could not be read: {e}"),
};
}
};
match toml::from_str::<ConfigToml>(&raw) {
Ok(config) => {
let raw_provider = toml::from_str::<toml::Value>(&raw)
.ok()
.and_then(|document| document.get("provider").cloned())
.and_then(|provider| provider.as_str().map(str::to_string));
if config.provider == ProviderKind::Custom
&& raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
{
tracing::warn!(
"Failed to parse project config {}; file contents were omitted",
quote_os_path(&path)
);
return ProjectConfigOutcome::Invalid {
path,
reason: match raw_provider {
Some(name) => format!("unknown provider '{name}'"),
None => "unknown provider".to_string(),
},
};
}
return ProjectConfigOutcome::Loaded(Box::new(config));
}
Err(err) => {
tracing::warn!(
"Failed to parse project config {}; file contents were omitted",
quote_os_path(&path)
);
return ProjectConfigOutcome::Invalid {
path,
reason: err.message().to_string(),
};
}
}
}
ProjectConfigOutcome::Missing
}
pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
load_project_config_outcome(workspace).into_config()
}
fn project_config_candidate_exists(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|metadata| {
let file_type = metadata.file_type();
file_type.is_file() || file_type.is_symlink()
})
}
fn deepseek_family_model_id(model: &str) -> Option<String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return None;
}
match trimmed.to_ascii_lowercase().as_str() {
"pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
"flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
_ => {}
}
let normalized = trimmed.to_ascii_lowercase();
if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
return None;
}
if trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
{
return Some(trimmed.to_string());
}
None
}
fn provider_passes_model_through(provider: ProviderKind) -> bool {
matches!(
provider,
ProviderKind::Openai
| ProviderKind::Atlascloud
| ProviderKind::WanjieArk
| ProviderKind::Volcengine
| ProviderKind::XiaomiMimo
| ProviderKind::Moonshot
| ProviderKind::Qianfan
| ProviderKind::Openmodel
| ProviderKind::Ollama
| ProviderKind::OllamaCloud
| ProviderKind::Huggingface
| ProviderKind::Meta
| ProviderKind::Xai
| ProviderKind::Telecomjs
| ProviderKind::Edenai
| ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic
| ProviderKind::Custom
)
}
fn root_default_model_is_foreign_to_provider(
provider: ProviderKind,
model: &str,
base_url: &str,
) -> bool {
if deepseek_family_model_id(model).is_none() {
return false;
}
if matches!(
provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
) {
return false;
}
if provider_preserves_custom_base_url_model(provider, base_url) {
return false;
}
if matches!(
provider,
ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
) {
return true;
}
if provider_passes_model_through(provider) {
return false;
}
if matches!(
provider,
ProviderKind::NvidiaNim
| ProviderKind::Openrouter
| ProviderKind::Orcarouter
| ProviderKind::Novita
| ProviderKind::Fireworks
| ProviderKind::Siliconflow
| ProviderKind::SiliconflowCN
| ProviderKind::Deepinfra
| ProviderKind::Together
| ProviderKind::Sglang
| ProviderKind::Vllm
| ProviderKind::Volcengine
| ProviderKind::Atlascloud
| ProviderKind::OpencodeGo
| ProviderKind::WanjieArk
) {
return false;
}
true
}
#[must_use]
pub fn known_foreign_model_owner(
provider: ProviderKind,
model: &str,
base_url: &str,
) -> Option<ProviderKind> {
root_default_model_is_foreign_to_provider(provider, model, base_url)
.then_some(ProviderKind::Deepseek)
}
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
if matches!(provider, ProviderKind::OpencodeGo) {
return opencode_go_chat_model_id(model)
.map(str::to_string)
.unwrap_or_else(|| model.trim().to_string());
}
if matches!(provider, ProviderKind::XiaomiMimo)
&& let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
{
return canonical.to_string();
}
if matches!(
provider,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
) && let Some(canonical) = canonical_minimax_model_id(model)
{
return canonical.to_string();
}
if matches!(provider, ProviderKind::Zai)
&& let Some(canonical) = canonical_zai_model_id(model)
{
return canonical.to_string();
}
if matches!(
provider,
ProviderKind::Atlascloud
| ProviderKind::WanjieArk
| ProviderKind::Volcengine
| ProviderKind::XiaomiMimo
| ProviderKind::Zai
| ProviderKind::Stepfun
| ProviderKind::Minimax
| ProviderKind::MinimaxAnthropic
| ProviderKind::Qianfan
| ProviderKind::Ollama
| ProviderKind::OllamaCloud
| ProviderKind::Meta
| ProviderKind::Xai
) {
return model.to_string();
}
let normalized = model.trim().to_ascii_lowercase();
if provider == ProviderKind::Openrouter
&& let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
{
return canonical.to_string();
}
if provider == ProviderKind::Orcarouter
&& let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
{
return canonical.to_string();
}
match (provider, normalized.as_str()) {
(ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_NVIDIA_NIM_MODEL.to_string()
}
(
ProviderKind::NvidiaNim,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
(ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_OPENROUTER_MODEL.to_string()
}
(
ProviderKind::Openrouter,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
(ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_ORCAROUTER_MODEL.to_string()
}
(
ProviderKind::Orcarouter,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
(ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_NOVITA_MODEL.to_string()
}
(
ProviderKind::Novita,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
(ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_FIREWORKS_MODEL.to_string()
}
(
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
"deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
) => DEFAULT_SILICONFLOW_MODEL.to_string(),
(
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
(
ProviderKind::Arcee,
"trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
) => DEFAULT_ARCEE_MODEL.to_string(),
(ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
ARCEE_TRINITY_MINI_MODEL.to_string()
}
(ProviderKind::Arcee, "arcee-trinity-large-preview") => {
ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
}
(
ProviderKind::Moonshot,
"kimi"
| "kimi-k2"
| "kimi-k2.7"
| "kimi-k2-7"
| "kimi-k2.7-code"
| "kimi-k2-7-code"
| "kimi-code"
| "moonshot-kimi-k2.7-code",
) => DEFAULT_MOONSHOT_MODEL.to_string(),
(ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
MOONSHOT_KIMI_K2_6_MODEL.to_string()
}
(ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_SGLANG_MODEL.to_string()
}
(
ProviderKind::Sglang,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
(ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_VLLM_MODEL.to_string()
}
(
ProviderKind::Vllm,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
(ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_HUGGINGFACE_MODEL.to_string()
}
(
ProviderKind::Huggingface,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
(ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_TOGETHER_MODEL.to_string()
}
(
ProviderKind::Together,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
(ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_DEEPINFRA_MODEL.to_string()
}
(
ProviderKind::Deepinfra,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
_ => model.to_string(),
}
}
pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
DEFAULT_OPENCODE_GO_MODEL,
OPENCODE_GO_GROK_4_5_MODEL,
OPENCODE_GO_GLM_5_2_MODEL,
OPENCODE_GO_GLM_5_1_MODEL,
OPENCODE_GO_KIMI_K3_MODEL,
OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
OPENCODE_GO_KIMI_K2_6_MODEL,
OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
OPENCODE_GO_MIMO_V2_5_MODEL,
OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
];
#[must_use]
pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
let normalized = normalized
.strip_prefix("opencode-go/")
.unwrap_or(&normalized);
let familiar_alias = match normalized {
"grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
"glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
"glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
"kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
"kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
"deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
"deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
"mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
"mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
_ => None,
};
familiar_alias.or_else(|| {
OPENCODE_GO_CHAT_MODELS
.iter()
.copied()
.find(|candidate| *candidate == normalized)
})
}
fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"mimo"
| DEFAULT_XIAOMI_MIMO_MODEL
| "mimo-v2-5-pro"
| "xiaomi-mimo-v2.5-pro"
| "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
| "mimo-v2-5-pro-ultraspeed"
| "xiaomi-mimo-v2.5-pro-ultraspeed"
| "xiaomi-mimo-v2-5-pro-ultraspeed"
| "ultraspeed"
| "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
"omni"
| "mimo-omni"
| "v2.5-omni"
| "v25-omni"
| "mimo-v2.5"
| "mimo-v25"
| "mimo-v2-5"
| "mimo-v2.5-omni"
| "mimo-v25-omni"
| "mimo-v2-5-omni"
| "xiaomi-mimo-v2.5"
| "xiaomi-mimo-v2-5"
| "xiaomi-mimo-v2.5-omni"
| "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
"asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
Some(XIAOMI_MIMO_ASR_MODEL)
}
"mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
Some(XIAOMI_MIMO_TTS_MODEL)
}
"mimo-tts-voicedesign"
| "mimo-voice-design"
| "mimo-v25-tts-voicedesign"
| "mimo-v2.5-tts-voicedesign"
| "voicedesign"
| "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
"mimo-tts-voiceclone"
| "mimo-voice-clone"
| "mimo-v25-tts-voiceclone"
| "mimo-v2.5-tts-voiceclone"
| "voiceclone"
| "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
"mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
_ => None,
}
}
fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
Some(DEFAULT_MINIMAX_MODEL)
}
"minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
Some(MINIMAX_M2_7_MODEL)
}
"minimax-m2.7-highspeed"
| "minimax-m2-7-highspeed"
| "minimax-m-2.7-highspeed"
| "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
"minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
Some(MINIMAX_M2_5_MODEL)
}
"minimax-m2.5-highspeed"
| "minimax-m2-5-highspeed"
| "minimax-m-2.5-highspeed"
| "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
"minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
Some(MINIMAX_M2_1_MODEL)
}
"minimax-m2.1-highspeed"
| "minimax-m2-1-highspeed"
| "minimax-m-2.1-highspeed"
| "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
"minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
_ => None,
}
}
fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
"glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
"glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
"glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
_ => None,
}
}
fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
| "trinity"
| "trinity-large-thinking"
| "arcee-trinity"
| "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
Some(OPENROUTER_GEMMA_4_31B_MODEL)
}
OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
}
OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
Some(OPENROUTER_GLM_5_1_MODEL)
}
OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
Some(OPENROUTER_GLM_5_2_MODEL)
}
OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
Some(OPENROUTER_GLM_5_3_MODEL)
}
OPENROUTER_KIMI_K2_7_CODE_MODEL
| "kimi"
| "kimi-k2"
| "kimi-k2.7"
| "kimi-k2-7"
| "kimi-k2.7-code"
| "kimi-k2-7-code"
| "kimi-code"
| "moonshot-kimi-k2.7-code"
| "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
Some(OPENROUTER_KIMI_K2_6_MODEL)
}
OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
Some(OPENROUTER_MINIMAX_M3_MODEL)
}
OPENROUTER_MINIMAX_M2_7_MODEL
| "minimax-2.7"
| "minimax-2-7"
| "minimax-m2.7"
| "minimax-m2-7"
| "minimax-m-2.7"
| "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
| "nemotron-3-nano-omni"
| "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
OPENROUTER_QWEN_3_6_35B_A3B_MODEL
| "qwen3.6-35b-a3b"
| "qwen-3.6-35b-a3b"
| "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
}
OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
| "qwen3.6-max-preview"
| "qwen-3.6-max-preview"
| "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
Some(OPENROUTER_QWEN_3_6_27B_MODEL)
}
OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
}
OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
}
OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
}
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
}
OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
| "mimo-v2.5-pro"
| "mimo-v2-5-pro"
| "xiaomi-mimo-v2.5-pro"
| "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
| "mimo-v2.5"
| "mimo-v2-5"
| "xiaomi-mimo-v2.5"
| "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
_ => None,
}
}
fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
Some(ORCAROUTER_AUTO_MODEL)
}
_ => None,
}
}
fn default_model_for_provider(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_MODEL,
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
ProviderKind::Zai => DEFAULT_ZAI_MODEL,
ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
ProviderKind::Meta => DEFAULT_META_MODEL,
ProviderKind::Xai => DEFAULT_XAI_MODEL,
ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
ProviderKind::Edenai => DEFAULT_EDENAI_MODEL,
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
ProviderKind::Custom => provider.provider().default_model(),
}
}
fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL,
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
ProviderKind::Meta => DEFAULT_META_BASE_URL,
ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL,
ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
ProviderKind::Custom => provider.provider().default_base_url(),
}
}
fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
normalized == DEFAULT_KIMI_CODE_BASE_URL
|| normalized == "https://api.kimi.com/coding"
|| normalized.starts_with("https://api.kimi.com/coding/")
}
fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
if matches!(
kind,
ProviderKind::DeepseekAnthropic
| ProviderKind::MinimaxAnthropic
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlanAnthropic
) {
return true;
}
let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
matches!(
normalized.as_str(),
"anthropic"
| "anthropic-messages"
| "messages"
| "claude"
| "anthropic-compatible"
| "anthropic-compat"
)
}
fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
if matches!(
kind,
ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
) {
return true;
}
let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
matches!(
normalized.as_str(),
"coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
)
}
fn is_modelstudio_family(kind: ProviderKind) -> bool {
matches!(
kind,
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic
)
}
fn resolve_modelstudio_base_url(
configured: Option<String>,
kind: ProviderKind,
mode: Option<&str>,
wire: Option<&str>,
) -> String {
if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
return url;
}
let coding = modelstudio_mode_is_coding_plan(kind, mode);
let anthropic = wire_prefers_anthropic(kind, wire);
match (coding, anthropic) {
(true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
(true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
(false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
(false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
}
}
fn resolve_minimax_base_url(
configured: Option<String>,
kind: ProviderKind,
wire: Option<&str>,
) -> String {
if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
return url;
}
if wire_prefers_anthropic(kind, wire) {
DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
} else {
DEFAULT_MINIMAX_BASE_URL.to_string()
}
}
fn resolve_deepseek_base_url(
configured: Option<String>,
kind: ProviderKind,
wire: Option<&str>,
) -> String {
if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
return url;
}
if wire_prefers_anthropic(kind, wire) {
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
} else {
DEFAULT_DEEPSEEK_BASE_URL.to_string()
}
}
fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
return None;
}
Some(match normalized.as_str() {
"token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
DEFAULT_XIAOMI_MIMO_BASE_URL
}
"token-plan-cn"
| "token-plan-china"
| "token-plan-mainland"
| "token-plan-mainland-china"
| "cn"
| "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
"token-plan-sgp"
| "token-plan-sg"
| "token-plan-singapore"
| "sgp"
| "sg"
| "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
"token-plan-ams"
| "token-plan-eu"
| "token-plan-europe"
| "token-plan-amsterdam"
| "ams"
| "eu"
| "europe"
| "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
_ => DEFAULT_XIAOMI_MIMO_BASE_URL,
})
}
fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
matches!(
normalized_mode,
"standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
)
}
fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
|| normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
|| normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
}
fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
candidates.iter().find_map(|name| {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
})
}
fn xiaomi_mimo_env_api_key_for_runtime(
mode: Option<&str>,
base_url: Option<&str>,
) -> Option<String> {
const TOKEN_PLAN_ENV_VARS: &[&str] =
&["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
let normalized_mode =
mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
let standard_selected = normalized_mode
.as_deref()
.is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
|| base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
if standard_selected {
return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
}
let token_plan_selected = normalized_mode
.as_deref()
.and_then(xiaomi_mimo_base_url_for_mode)
.is_some()
|| base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
if token_plan_selected {
return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
}
xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
}
fn resolve_xiaomi_mimo_base_url(
configured: Option<String>,
api_key: Option<&str>,
mode: Option<&str>,
) -> String {
let normalized_mode =
mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
let uses_standard_mode = normalized_mode
.as_deref()
.is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
let mode_base_url = normalized_mode
.as_deref()
.and_then(xiaomi_mimo_base_url_for_mode);
let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
match configured {
Some(base_url) if uses_standard_mode => base_url,
Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
mode_base_url
.unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
.to_string()
}
Some(base_url) => base_url,
None => {
if let Some(base_url) = mode_base_url {
base_url.to_string()
} else if uses_standard_mode {
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
} else if uses_token_plan || api_key.is_none() {
DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
} else {
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
}
}
}
}
fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
}
fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
matches!(
base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
"https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
)
}
#[must_use]
pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
match provider {
ProviderKind::Deepseek => matches!(
normalized.as_str(),
"https://api.deepseek.com"
| "https://api.deepseek.com/v1"
| "https://api.deepseek.com/beta"
),
ProviderKind::DeepseekAnthropic => matches!(
normalized.as_str(),
"https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
),
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
normalized.as_str(),
"https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
),
ProviderKind::Moonshot => {
normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
}
ProviderKind::XiaomiMimo => {
xiaomi_mimo_base_url_uses_token_plan(base_url)
|| xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
}
ProviderKind::Ollama => {
normalized == DEFAULT_OLLAMA_BASE_URL
|| provider::is_exact_ollama_cloud_route(provider, base_url)
}
ProviderKind::OllamaCloud => provider::is_exact_ollama_cloud_route(provider, base_url),
ProviderKind::Edenai => matches!(
normalized.as_str(),
"https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3"
),
ProviderKind::Custom => false,
_ => {
normalized
== default_base_url_for_provider(provider)
.trim()
.trim_end_matches('/')
.to_ascii_lowercase()
}
}
}
fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
!provider_base_url_is_official(provider, base_url)
}
#[must_use]
pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
base_url_is_custom_for_provider(provider, base_url)
}
fn should_skip_secret_store_for_provider(
provider: ProviderKind,
base_url: &str,
auth_mode: Option<&str>,
) -> bool {
if auth_mode_disables_api_key(auth_mode) {
return true;
}
if base_url_is_custom_for_provider(provider, base_url) {
return true;
}
if auth_mode_requires_api_key(auth_mode) {
return false;
}
matches!(provider, ProviderKind::Sglang | ProviderKind::Vllm)
|| (provider == ProviderKind::Ollama
&& !provider::is_exact_ollama_cloud_route(provider, base_url))
|| base_url_uses_local_host(base_url)
}
fn stored_api_key_for_provider(
secrets: &Secrets,
provider: ProviderKind,
legacy_ollama_cloud: bool,
) -> Option<(String, SecretSource)> {
let mut slots = vec![provider.secret_store_slot()];
if provider == ProviderKind::OllamaCloud && legacy_ollama_cloud {
slots.push(ProviderKind::Ollama.secret_store_slot());
}
slots.into_iter().find_map(|slot| {
secrets
.get(slot)
.ok()
.flatten()
.filter(|value| !value.trim().is_empty())
.map(|value| (value, SecretSource::Keyring))
})
}
fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
if provider == ProviderKind::Huggingface {
return std::env::var("HUGGINGFACE_API_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var("HF_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty())
});
}
codewhale_secrets::env_for(provider.as_str())
}
#[must_use]
pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
matches!(
auth_mode
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase()),
Some(value)
if matches!(
value.as_str(),
"api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
)
)
}
#[must_use]
pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
matches!(
auth_mode
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase()),
Some(value)
if matches!(
value.as_str(),
"none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
)
)
}
#[must_use]
pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
matches!(
auth_mode
.trim()
.to_ascii_lowercase()
.replace('-', "_")
.as_str(),
"kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
)
}
fn base_url_uses_local_host(base_url: &str) -> bool {
let Some(host) = base_url_host(base_url) else {
return false;
};
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if matches!(host.as_str(), "localhost" | "0.0.0.0") {
return true;
}
host.parse::<std::net::IpAddr>()
.is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
}
fn base_url_host(base_url: &str) -> Option<&str> {
let without_scheme = base_url
.split_once("://")
.map_or(base_url, |(_, rest)| rest);
let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
if let Some(rest) = authority.strip_prefix('[') {
return rest.split_once(']').map(|(host, _)| host);
}
authority.split(':').next().filter(|host| !host.is_empty())
}
#[derive(Debug, Clone, Default)]
pub struct CliRuntimeOverrides {
pub provider: Option<ProviderKind>,
pub model: Option<String>,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub auth_mode: Option<String>,
pub output_mode: Option<String>,
pub log_level: Option<String>,
pub telemetry: Option<bool>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
pub yolo: Option<bool>,
pub verbosity: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeApiKeySource {
Cli,
ConfigFile,
Keyring,
Env,
}
impl RuntimeApiKeySource {
#[must_use]
pub fn as_env_value(self) -> &'static str {
match self {
Self::Cli => "cli",
Self::ConfigFile => "config",
Self::Keyring => "keyring",
Self::Env => "env",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderSource {
Cli,
Env(&'static str),
Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSource {
Cli,
Env,
ProviderConfig,
RootDefaultTextModel,
RootModel,
ProviderDefault,
}
impl ModelSource {
#[must_use]
pub fn is_explicit(self) -> bool {
!matches!(self, Self::ProviderDefault)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Cli => "--model",
Self::Env => "environment",
Self::ProviderConfig => "config [providers.*].model",
Self::RootDefaultTextModel => "config default_text_model",
Self::RootModel => "config model",
Self::ProviderDefault => "provider default",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedRuntimeOptions {
pub provider: ProviderKind,
pub provider_source: ProviderSource,
pub model: String,
pub model_source: ModelSource,
pub api_key: Option<String>,
pub api_key_source: Option<RuntimeApiKeySource>,
pub base_url: String,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: bool,
pub output_mode: Option<String>,
pub log_level: Option<String>,
pub telemetry: bool,
pub telemetry_explicit_off: bool,
pub telemetry_endpoint: Option<String>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
pub yolo: Option<bool>,
pub verbosity: Option<String>,
pub http_headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ConfigStore {
path: PathBuf,
pub config: ConfigToml,
permissions: PermissionsToml,
original_raw: Option<String>,
}
impl ConfigStore {
pub fn load(path: Option<PathBuf>) -> Result<Self> {
let path = resolve_config_path(path)?;
let (config, original_raw) = if checked_path_exists(&path)? {
let raw = read_checked_config_file(&path)?;
let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
anyhow::anyhow!(
"failed to parse config at {}; file contents were omitted",
quote_os_path(&path)
)
})?;
let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
anyhow::anyhow!(
"failed to parse config at {}; file contents were omitted",
quote_os_path(&path)
)
})?;
if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
parsed
.bind_persisted_provider_id(provider_id)
.with_context(|| {
format!("failed to parse config at {}", quote_os_path(&path))
})?;
}
(parsed, Some(raw))
} else {
(ConfigToml::default(), None)
};
let permissions = load_sibling_permissions(&path)?;
Ok(Self {
path,
config,
permissions,
original_raw,
})
}
pub fn rendered_body(&self) -> Result<String> {
let mut serialized =
toml::to_string_pretty(&self.config).context("failed to serialize config")?;
if let Some(provider_id) = self.config.named_custom_provider_id() {
let mut document = serialized
.parse::<toml_edit::DocumentMut>()
.context("failed to edit serialized config")?;
document["provider"] = toml_edit::value(provider_id);
serialized = document.to_string();
}
if let Some(ref original_raw) = self.original_raw {
merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
format!(
"cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
quote_os_path(&self.path)
)
})
} else {
Ok(serialized)
}
}
pub fn save(&mut self) -> Result<()> {
let path = normalize_config_file_path(self.path.clone())?;
let body = self.rendered_body()?;
replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
self.original_raw = Some(body);
Ok(())
}
pub fn reload(&mut self) -> Result<()> {
*self = Self::load(Some(self.path.clone()))?;
Ok(())
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn permissions(&self) -> &PermissionsToml {
&self.permissions
}
#[must_use]
pub fn permissions_path(&self) -> PathBuf {
checked_permissions_path_for_config_path(&self.path)
.expect("ConfigStore path is validated before construction")
}
#[must_use]
pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
if self.permissions.is_empty() {
ExecPolicyEngine::new(Vec::new(), Vec::new())
} else {
ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
}
}
pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
self.append_permission_rules(rules, PermissionAction::Ask)
}
pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
for rule in rules {
if rule.action != PermissionAction::Allow {
bail!("append_allow_rules only accepts action = \"allow\"");
}
let Some(workspace) = rule
.workspace
.as_deref()
.and_then(codewhale_execpolicy::normalize_workspace_scope)
else {
bail!("persistent allow rules must be scoped to a workspace");
};
if rule.command.is_some() && !rule.command_exact {
bail!("persistent command allow rules must use exact matching");
}
if rule.command.is_none() && rule.path.is_none() {
bail!("persistent allow rules must match an exact command or path");
}
if let Some(command) = rule.command.as_deref()
&& command.trim().is_empty()
{
bail!("persistent command allow rules must not be empty");
}
if let Some(path) = rule.path.as_deref()
&& codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
.is_none_or(|path| path.is_empty())
{
bail!("persistent path allow rules must stay within the workspace");
}
}
self.append_permission_rules(rules, PermissionAction::Allow)
}
fn append_permission_rules(
&mut self,
rules: &[ToolAskRule],
expected_action: PermissionAction,
) -> Result<usize> {
if rules.is_empty() {
return Ok(0);
}
if rules.iter().any(|rule| rule.action != expected_action) {
bail!(
"permission rule action does not match requested {:?} persistence",
expected_action
);
}
let path = checked_permissions_path_for_config_path(&self.path)?;
let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
let (_, raw, mut permissions) = read_permissions_state(path)?;
let mut document = parse_permissions_document(path, &raw)?;
if !document.contains_key("rules") {
document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
}
let rules_item = document
.get_mut("rules")
.expect("rules entry was inserted above");
let mut added = 0;
for rule in rules {
if permissions.rules.contains(rule) {
continue;
}
append_permission_rule(rules_item, rule)?;
permissions.rules.push(rule.clone());
added += 1;
}
if added == 0 {
return Ok((0, permissions));
}
let body = document.to_string();
let persisted = parse_generated_permissions(path, &body)?;
write_permissions_atomic(path, body.as_bytes())?;
Ok((added, persisted))
})?;
self.permissions = persisted;
Ok(added)
}
}
fn config_backup_file_name(path: &Path) -> OsString {
let mut file_name = path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
file_name.push(".bak");
file_name
}
fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(file_name)
}
fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
let config_path = normalize_config_file_path(config_path.to_path_buf())?;
let parent = config_path
.parent()
.context("config path must include a parent directory")?;
let path = parent.join(file_name);
reject_path_symlink(&path)?;
Ok(path)
}
#[cfg(test)]
fn config_backup_path(path: &Path) -> PathBuf {
config_sibling_path_unchecked(path, &config_backup_file_name(path))
}
fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
checked_config_sibling_path(path, &config_backup_file_name(path))
}
pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
let backup = checked_config_backup_path(path)?;
if !backup.exists() {
return Ok(());
}
let raw = read_checked_toml_file(&backup, "config backup")?;
let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
format!(
"failed to scrub plaintext API keys from config backup {}",
backup.display()
)
})?;
if scrubbed != raw {
persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
format!(
"failed to write credential-free config backup {}",
backup.display()
)
})?;
}
Ok(())
}
fn write_one_time_config_backup(path: &Path) -> Result<()> {
let backup = checked_config_backup_path(path)?;
if backup.exists() {
return scrub_plaintext_api_keys_from_config_backup(path);
}
let raw = read_checked_config_file(path)?;
let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
format!(
"failed to scrub plaintext API keys while creating config backup {}",
backup.display()
)
})?;
persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
format!(
"failed to create credential-free config backup {} from {}",
backup.display(),
path.display()
)
})?;
Ok(())
}
fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
let mut document = raw
.parse::<toml_edit::DocumentMut>()
.map_err(|_| {
anyhow::anyhow!(
"failed to parse config TOML while removing plaintext API keys; file contents were omitted"
)
})?;
remove_plaintext_api_keys_recursive(document.as_table_mut());
Ok(document.to_string())
}
fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
table.remove("api_key");
for (_, item) in table.iter_mut() {
if let toml_edit::Item::ArrayOfTables(tables) = item {
for nested in tables.iter_mut() {
remove_plaintext_api_keys_recursive(nested);
}
} else if let Some(nested) = item.as_table_like_mut() {
remove_plaintext_api_keys_recursive(nested);
}
}
}
pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
let original = original_raw
.parse::<toml_edit::DocumentMut>()
.map_err(|_| {
anyhow::anyhow!(
"failed to parse original config for comment merge; file contents were omitted"
)
})?;
let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
anyhow::anyhow!(
"failed to parse serialized config for comment merge; file contents were omitted"
)
})?;
new_doc.set_trailing(original.trailing().clone());
*new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
merge_decor_table(new_doc.as_table_mut(), original.as_table());
Ok(new_doc.to_string())
}
fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
for key in &keys {
let Some((source_key, source_item)) = source.get_key_value(key) else {
continue;
};
let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
continue;
};
*target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
copy_item_decor(target_item, source_item);
if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
merge_decor_table(tt, st);
}
if let (Some(ta), Some(sa)) = (
target_item.as_array_of_tables_mut(),
source_item.as_array_of_tables(),
) {
for (i, source_table) in sa.iter().enumerate() {
if let Some(target_table) = ta.get_mut(i) {
copy_item_decor_table(target_table, source_table);
merge_decor_table(target_table, source_table);
}
}
}
}
}
fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
match (target, source) {
(toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
*tt.decor_mut() = st.decor().clone();
}
(toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
*tv.decor_mut() = sv.decor().clone();
}
_ => {}
}
}
fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
*target.decor_mut() = source.decor().clone();
}
pub fn default_secrets() -> &'static Secrets {
static SECRETS: OnceLock<Secrets> = OnceLock::new();
SECRETS.get_or_init(|| {
#[cfg(test)]
{
Secrets::new(std::sync::Arc::new(
codewhale_secrets::InMemoryKeyringStore::new(),
))
}
#[cfg(not(test))]
{
Secrets::auto_detect()
}
})
}
pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
pub fn codewhale_home() -> Result<PathBuf> {
codewhale_paths::codewhale_home()
.map_err(anyhow::Error::new)?
.context("failed to resolve home directory")
}
pub fn codewhale_home_is_explicit() -> bool {
codewhale_paths::codewhale_home_is_explicit()
}
pub fn legacy_deepseek_home() -> Result<PathBuf> {
codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
}
fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
if subdir.is_empty() {
bail!("state subdir must not be empty");
}
let path = std::path::Path::new(subdir);
if path.is_absolute() {
bail!("state subdir must not be an absolute path: {subdir}");
}
if path.components().any(|c| {
matches!(
c,
std::path::Component::RootDir | std::path::Component::Prefix(_)
)
}) {
bail!("state subdir must not contain a root or prefix: {subdir}");
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("state subdir must not contain parent-dir (..) components: {subdir}");
}
Ok(())
}
pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
ensure_safe_state_subdir(subdir)?;
let explicit_codewhale_home = codewhale_home_is_explicit();
let primary = codewhale_home()?.join(subdir);
if explicit_codewhale_home || primary.exists() {
return Ok(primary);
}
let legacy = legacy_deepseek_home()?.join(subdir);
if legacy.exists() {
return Ok(legacy);
}
Ok(primary)
}
pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
if let Some(migration) = migration {
eprintln!("{}", migration.user_notice());
}
Ok(dir)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateMigrationKind {
Relocated,
Copied,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateMigration {
pub subdir: String,
pub legacy_path: PathBuf,
pub primary_path: PathBuf,
pub kind: StateMigrationKind,
}
impl StateMigration {
pub fn user_notice(&self) -> String {
let action = match self.kind {
StateMigrationKind::Relocated => "relocated",
StateMigrationKind::Copied => "copied",
};
let legacy_detail = match self.kind {
StateMigrationKind::Relocated => {
"The legacy .deepseek copy for this state path was removed by the move."
}
StateMigrationKind::Copied => {
"The legacy .deepseek copy was left in place because a direct move failed."
}
};
format!(
"Codewhale migrated legacy state ({action}):\n {} -> {}\nYour data was preserved. Use .codewhale as the canonical state location from now on.\n{legacy_detail}\nIf no other apps use it, you can remove the legacy .deepseek tree after confirming everything looks right.",
self.legacy_path.display(),
self.primary_path.display(),
)
}
}
pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
ensure_safe_state_subdir(subdir)?;
let explicit_codewhale_home = codewhale_home_is_explicit();
let dir = codewhale_home()?.join(subdir);
let migration = if !explicit_codewhale_home {
migrate_legacy_state_dir(&dir, subdir)?
} else {
None
};
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create {}/", dir.display()))?;
Ok((dir, migration))
}
fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
if primary.exists() || subdir == "." || subdir.is_empty() {
return Ok(None);
}
let legacy = match legacy_deepseek_home() {
Ok(home) => home.join(subdir),
Err(_) => return Ok(None),
};
if !legacy.exists() {
return Ok(None);
}
if let Some(parent) = primary.parent()
&& let Err(err) = std::fs::create_dir_all(parent)
{
tracing::warn!(
target: "config::migration",
"Could not create {} for state migration ({}); writing to primary anyway",
parent.display(),
err
);
}
match std::fs::rename(&legacy, primary) {
Ok(()) => {
tracing::info!(
target: "config::migration",
"Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
legacy.display(),
primary.display()
);
return Ok(Some(StateMigration {
subdir: subdir.to_string(),
legacy_path: legacy,
primary_path: primary.to_path_buf(),
kind: StateMigrationKind::Relocated,
}));
}
Err(err) => {
match copy_dir_recursive(&legacy, primary) {
Ok(()) => {
tracing::info!(
target: "config::migration",
"Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
The legacy .deepseek copy was left in place.",
legacy.display(),
primary.display()
);
return Ok(Some(StateMigration {
subdir: subdir.to_string(),
legacy_path: legacy,
primary_path: primary.to_path_buf(),
kind: StateMigrationKind::Copied,
}));
}
Err(copy_err) => {
tracing::warn!(
target: "config::migration",
"Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
New data is written to the primary path; the legacy tree remains untouched.",
legacy.display(),
primary.display()
);
}
}
}
}
Ok(None)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
{
let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
let path = entry.path();
let target = dst.join(entry.file_name());
let file_type = entry
.file_type()
.with_context(|| format!("failed to read file type for {}", path.display()))?;
if file_type.is_dir() {
copy_dir_recursive(&path, &target)?;
} else if file_type.is_file() {
std::fs::copy(&path, &target).with_context(|| {
format!("failed to copy {} -> {}", path.display(), target.display())
})?;
}
}
Ok(())
}
pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
ensure_safe_state_subdir(subdir)?;
let workspace = normalize_project_workspace(workspace)?;
let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
if primary.exists() {
return Ok((true, primary));
}
let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
Ok((false, legacy))
}
pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
ensure_safe_state_subdir(subdir)?;
let workspace = normalize_project_workspace(workspace)?;
let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create {}/", dir.display()))?;
Ok(dir)
}
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
return normalize_config_file_path(path);
}
if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
return normalize_config_file_path(path);
}
default_config_path()
}
#[must_use]
pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
config_path_is_workspace_scoped_with_context(
path,
codewhale_paths::codewhale_home_override()
.ok()
.flatten()
.as_deref(),
codewhale_paths::user_home().as_deref(),
std::env::current_dir().ok().as_deref(),
)
}
fn config_path_is_workspace_scoped_with_context(
path: &Path,
explicit_codewhale_home: Option<&Path>,
user_home: Option<&Path>,
current_dir: Option<&Path>,
) -> bool {
if let Some(home) = explicit_codewhale_home
&& same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
{
return false;
}
let Some(parent) = path.parent() else {
return false;
};
let parent_is_app_dir = parent
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
if !parent_is_app_dir {
return false;
}
let Some(base) = parent.parent() else {
return true;
};
if let Some(home) = user_home
&& same_lexical_or_canonical_path(base, home)
{
return false;
}
if path.is_relative() {
return true;
}
if let Some(cwd) = current_dir
&& canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
{
return true;
}
base.join(".git").exists()
}
fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
}
fn canonicalize_or_keep(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod credential_scope_tests {
use super::config_path_is_workspace_scoped_with_context;
use std::path::Path;
#[test]
fn config_inside_current_workspace_is_workspace_scoped() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
let cwd = repo.join("nested/dir");
for app_dir in [".codewhale", ".deepseek"] {
let config = repo.join(app_dir).join("config.toml");
assert!(
config_path_is_workspace_scoped_with_context(
&config,
None,
Some(Path::new("/home/user")),
Some(&cwd),
),
"{} should be workspace-scoped when cwd sits inside the repo",
config.display()
);
}
}
#[test]
fn relative_app_dir_config_is_workspace_scoped() {
assert!(config_path_is_workspace_scoped_with_context(
Path::new(".codewhale/config.toml"),
None,
Some(Path::new("/home/user")),
Some(Path::new("/somewhere/else")),
));
}
#[test]
fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git marker");
std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
assert!(config_path_is_workspace_scoped_with_context(
&repo.join(".codewhale/config.toml"),
None,
Some(Path::new("/home/user")),
Some(Path::new("/somewhere/else")),
));
}
#[test]
fn user_global_and_custom_locations_are_not_workspace_scoped() {
let home = Path::new("/home/user");
let elsewhere = Some(Path::new("/somewhere/else"));
for global_config in [
"/home/user/.codewhale/config.toml",
"/home/user/.deepseek/config.toml",
"/home/user/team-config.toml",
"/etc/codewhale/config.toml",
] {
assert!(
!config_path_is_workspace_scoped_with_context(
Path::new(global_config),
None,
Some(home),
elsewhere,
),
"{global_config} should stay user-global"
);
}
let temp = tempfile::tempdir().expect("tempdir");
assert!(!config_path_is_workspace_scoped_with_context(
&temp.path().join(".codewhale/config.toml"),
None,
Some(home),
elsewhere,
));
}
#[test]
fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
let explicit = repo.join(".codewhale");
assert!(!config_path_is_workspace_scoped_with_context(
&explicit.join("config.toml"),
Some(&explicit),
Some(Path::new("/home/user")),
Some(&repo),
));
assert!(config_path_is_workspace_scoped_with_context(
&repo.join("other/.codewhale/config.toml"),
Some(&explicit),
Some(Path::new("/home/user")),
Some(&repo.join("other")),
));
}
}
#[must_use]
pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
}
fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
}
pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
}
pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
let path = resolve_permissions_path(config_path)?;
let (file_exists, raw, permissions) = read_permissions_state(&path)?;
let file_state = if !file_exists {
PermissionsFileState::Missing
} else if raw.is_empty() {
PermissionsFileState::Empty
} else {
PermissionsFileState::Present
};
let removal_tokens = (0..permissions.rules.len())
.map(|index| permission_removal_token(&path, &raw, index))
.collect();
Ok(PermissionsSnapshot {
path,
file_state,
permissions,
removal_tokens,
})
}
pub fn remove_permission_rule(
config_path: Option<PathBuf>,
index: usize,
expected_token: &str,
) -> Result<ToolAskRule> {
let path = resolve_permissions_path(config_path)?;
config_document::with_config_write_lock(&path, |path| {
let (file_exists, raw, permissions) = read_permissions_state(path)?;
if !file_exists {
bail!(
"permissions changed after they were listed; reload {} and retry",
quote_os_path(path)
);
}
let rule = permissions.rules.get(index).cloned().with_context(|| {
format!(
"permission rule {} no longer exists in {}; list rules again",
index + 1,
quote_os_path(path)
)
})?;
let current_token = permission_removal_token(path, &raw, index);
if current_token != expected_token {
bail!(
"permissions changed after they were listed; reload {} and retry",
quote_os_path(path)
);
}
let mut document = parse_permissions_document(path, &raw)?;
let rules_item = document.get_mut("rules").with_context(|| {
format!(
"permissions at {} no longer contain a rules array",
quote_os_path(path)
)
})?;
let orphaned_header = remove_permission_rule_item(rules_item, index)?;
if let Some(header) = orphaned_header {
let trailing = format!(
"{header}{}",
document.trailing().as_str().unwrap_or_default()
);
document.set_trailing(trailing);
}
let body = document.to_string();
let persisted = parse_generated_permissions(path, &body)?;
if persisted.rules.len() + 1 != permissions.rules.len() {
bail!(
"refusing inconsistent permission removal at {}",
quote_os_path(path)
);
}
write_permissions_atomic(path, body.as_bytes())?;
Ok(rule)
})
}
pub fn read_permissions_file(path: &Path) -> Result<String> {
read_checked_permissions_file(path)
}
fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
let permissions_path = checked_permissions_path_for_config_path(config_path)?;
let (_, _, permissions) = read_permissions_state(&permissions_path)?;
Ok(permissions)
}
fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
let file_exists = checked_path_exists(path)?;
let raw = if file_exists {
read_checked_permissions_file(path)?
} else {
String::new()
};
let permissions = if raw.trim().is_empty() {
PermissionsToml::default()
} else {
toml::from_str(&raw).map_err(|_| {
anyhow::anyhow!(
"failed to parse permissions at {}; file contents were omitted",
quote_os_path(path)
)
})?
};
Ok((file_exists, raw, permissions))
}
fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
if raw.trim().is_empty() {
Ok(toml_edit::DocumentMut::new())
} else {
raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
anyhow::anyhow!(
"failed to edit permissions at {}; file contents were omitted",
quote_os_path(path)
)
})
}
}
fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
toml::from_str(body).map_err(|_| {
anyhow::anyhow!(
"generated invalid permissions document for {}; file contents were omitted",
quote_os_path(path)
)
})
}
fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
let mut hasher = Sha256::new();
hasher.update(b"codewhale-permission-removal-v1\0");
hasher.update(quote_os_path(path).as_bytes());
hasher.update(b"\0");
hasher.update(index.to_le_bytes());
hasher.update(b"\0");
hasher.update(raw.as_bytes());
let digest = hasher.finalize();
let mut token = String::with_capacity(24);
for byte in &digest[..12] {
use std::fmt::Write as _;
let _ = write!(&mut token, "{byte:02x}");
}
token
}
fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
match item {
toml_edit::Item::ArrayOfTables(rules) => {
rules.push(permission_rule_table(rule));
Ok(())
}
toml_edit::Item::Value(value) => {
let Some(rules) = value.as_array_mut() else {
bail!("`rules` in permissions.toml must be an array");
};
rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
rule,
)));
Ok(())
}
_ => bail!("`rules` in permissions.toml must be an array"),
}
}
fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
match item {
toml_edit::Item::ArrayOfTables(rules) => {
if index >= rules.len() {
bail!("permission rule index changed before removal");
}
let file_header = if index == 0 {
rules
.get(index)
.and_then(|rule| rule.decor().prefix())
.and_then(toml_edit::RawString::as_str)
.map(str::to_owned)
} else {
None
};
rules.remove(index);
if let Some(header) = file_header.as_deref()
&& let Some(next_rule) = rules.get_mut(0)
{
let next_prefix = next_rule
.decor()
.prefix()
.and_then(toml_edit::RawString::as_str)
.unwrap_or_default()
.to_owned();
next_rule
.decor_mut()
.set_prefix(format!("{header}{next_prefix}"));
return Ok(None);
}
Ok(file_header)
}
toml_edit::Item::Value(value) => {
let Some(rules) = value.as_array_mut() else {
bail!("`rules` in permissions.toml must be an array");
};
if index >= rules.len() {
bail!("permission rule index changed before removal");
}
rules.remove(index);
Ok(None)
}
_ => bail!("`rules` in permissions.toml must be an array"),
}
}
fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
let mut table = toml_edit::Table::new();
table["tool"] = toml_edit::value(rule.tool.clone());
if let Some(command) = rule.command.as_deref() {
table["command"] = toml_edit::value(command);
}
if rule.command_exact {
table["command_exact"] = toml_edit::value(true);
}
if let Some(path) = rule.path.as_deref() {
table["path"] = toml_edit::value(path);
}
if let Some(workspace) = rule.workspace.as_deref() {
table["workspace"] = toml_edit::value(workspace);
}
if rule.action != PermissionAction::Ask {
table["action"] = toml_edit::value(match rule.action {
PermissionAction::Allow => "allow",
PermissionAction::Ask => "ask",
PermissionAction::Deny => "deny",
});
}
table
}
fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
let mut table = toml_edit::InlineTable::new();
table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
if let Some(command) = rule.command.as_deref() {
table.insert("command", toml_edit::Value::from(command));
}
if rule.command_exact {
table.insert("command_exact", toml_edit::Value::from(true));
}
if let Some(path) = rule.path.as_deref() {
table.insert("path", toml_edit::Value::from(path));
}
if let Some(workspace) = rule.workspace.as_deref() {
table.insert("workspace", toml_edit::Value::from(workspace));
}
if rule.action != PermissionAction::Ask {
table.insert(
"action",
toml_edit::Value::from(match rule.action {
PermissionAction::Allow => "allow",
PermissionAction::Ask => "ask",
PermissionAction::Deny => "deny",
}),
);
}
table
}
fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
let parent = path.parent().with_context(|| {
format!(
"permissions path has no parent directory: {}",
path.display()
)
})?;
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create permissions directory {}",
parent.display()
)
})?;
let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
format!(
"failed to create temporary permissions file in {}",
parent.display()
)
})?;
#[cfg(unix)]
temporary
.as_file()
.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| {
format!(
"failed to secure temporary permissions file for {}",
path.display()
)
})?;
temporary
.write_all(body)
.with_context(|| format!("failed to write permissions at {}", path.display()))?;
temporary
.as_file()
.sync_all()
.with_context(|| format!("failed to sync permissions at {}", path.display()))?;
temporary
.persist(path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace permissions at {}", path.display()))?;
Ok(())
}
pub fn default_config_path() -> Result<PathBuf> {
let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
if codewhale_home_is_explicit() || primary.exists() {
return Ok(primary);
}
let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
if legacy.exists() {
return Ok(legacy);
}
Ok(primary)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigMigration {
pub legacy_path: PathBuf,
pub primary_path: PathBuf,
}
impl ConfigMigration {
pub fn user_notice(&self) -> String {
format!(
"Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
self.legacy_path.display(),
self.primary_path.display()
)
}
}
pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
if codewhale_home_is_explicit() {
return Ok(None);
}
let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
if primary.exists() {
return Ok(None);
}
let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
if !legacy.exists() {
return Ok(None);
}
if let Some(parent) = primary.parent() {
std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
}
std::fs::copy(&legacy, &primary)
.context("failed to migrate config from deepseek to codewhale home")?;
tracing::info!(
"Migrated config from {} to {}",
legacy.display(),
primary.display()
);
Ok(Some(ConfigMigration {
legacy_path: legacy,
primary_path: primary,
}))
}
fn parse_bool(raw: &str) -> Result<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" | "enabled" => Ok(true),
"0" | "false" | "no" | "off" | "disabled" => Ok(false),
_ => bail!("invalid boolean '{raw}'"),
}
}
fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
let mut headers = BTreeMap::new();
for pair in raw.trim().split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let Some((name, value)) = pair.split_once('=') else {
bail!("invalid header pair '{pair}', expected name=value");
};
let name = name.trim();
let value = value.trim();
if name.is_empty() {
bail!("header name cannot be empty");
}
if value.is_empty() {
continue;
}
headers.insert(name.to_string(), value.to_string());
}
Ok(headers)
}
fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
if headers.is_empty() {
return None;
}
Some(
headers
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join(","),
)
}
fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
if headers.is_empty() {
return None;
}
Some(
headers
.iter()
.map(|(name, value)| {
let display_value = if is_sensitive_config_key(name) {
redact_secret(value)
} else {
value.clone()
};
format!("{name}={display_value}")
})
.collect::<Vec<_>>()
.join(","),
)
}
fn redact_secret(secret: &str) -> String {
let chars: Vec<char> = secret.chars().collect();
if chars.len() <= 16 {
return "********".to_string();
}
let prefix: String = chars.iter().take(4).collect();
let suffix: String = chars
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}***{suffix}")
}
#[must_use]
pub fn is_sensitive_config_key(key: &str) -> bool {
let Some(segment) = key.rsplit('.').next() else {
return false;
};
let normalized = segment
.trim()
.trim_matches('"')
.replace('-', "_")
.to_ascii_lowercase();
matches!(
normalized.as_str(),
"api_key"
| "apikey"
| "api_keys"
| "authorization"
| "bearer"
| "client_secret"
| "credential"
| "credentials"
| "id_token"
| "password"
| "passwords"
| "passwd"
| "proxy_authorization"
| "refresh_token"
| "secret"
| "secrets"
| "token"
| "tokens"
) || normalized.ends_with("_api_key")
|| normalized.ends_with("_authorization")
|| normalized.ends_with("_password")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
}
fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
redact_toml_value_for_display_inner(key, false, value).to_string()
}
fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
match value {
toml::Value::Integer(value) => u64::try_from(*value).ok(),
toml::Value::String(value) => value.trim().parse().ok(),
_ => None,
}
}
fn redact_toml_value_for_display_inner(
key: &str,
sensitive_ancestor: bool,
value: &toml::Value,
) -> toml::Value {
let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
match value {
toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
toml::Value::Array(values) => toml::Value::Array(
values
.iter()
.map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
.collect(),
),
toml::Value::Table(table) => {
let mut redacted = toml::map::Map::new();
for (child_key, child_value) in table {
let path = if key.is_empty() {
child_key.clone()
} else {
format!("{key}.{child_key}")
};
redacted.insert(
child_key.clone(),
redact_toml_value_for_display_inner(&path, sensitive, child_value),
);
}
toml::Value::Table(redacted)
}
_ if sensitive => toml::Value::String("********".to_string()),
_ => value.clone(),
}
}
fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
if path.as_os_str().is_empty() {
bail!("config path cannot be empty");
}
if path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
bail!("config path cannot contain '..' components");
}
if path.file_name().is_none() {
bail!("config path must include a file name");
}
let absolute = if path.is_absolute() {
path
} else {
std::env::current_dir()
.context("failed to resolve current directory for config path")?
.join(path)
};
let file_name = absolute
.file_name()
.map(OsString::from)
.context("config path must include a file name")?;
let parent = absolute
.parent()
.context("config path must include a parent directory")?;
let parent = match parent.canonicalize() {
Ok(parent) => parent,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
Err(err) => {
return Err(err).with_context(|| {
format!("failed to resolve config directory {}", parent.display())
});
}
};
let normalized = parent.join(file_name);
reject_path_symlink(&normalized)?;
Ok(normalized)
}
fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
if workspace.as_os_str().is_empty() {
bail!("project workspace path cannot be empty");
}
if workspace
.components()
.any(|component| matches!(component, Component::ParentDir))
{
bail!("project workspace path cannot contain '..' components");
}
let absolute = if workspace.is_absolute() {
workspace.to_path_buf()
} else {
std::env::current_dir()
.context("failed to resolve current directory for project workspace")?
.join(workspace)
};
match absolute.canonicalize() {
Ok(path) => Ok(path),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(normalize_path_components(&absolute))
}
Err(err) => Err(err).with_context(|| {
format!(
"failed to resolve project workspace {}",
workspace.display()
)
}),
}
}
fn normalize_path_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn checked_path_exists(path: &Path) -> Result<bool> {
let path = normalize_config_file_path(path.to_path_buf())?;
path.try_exists()
.with_context(|| format!("failed to inspect config path {}", path.display()))
}
fn read_checked_config_file(path: &Path) -> Result<String> {
read_checked_toml_file(path, "config")
}
fn read_checked_permissions_file(path: &Path) -> Result<String> {
read_checked_toml_file(path, "permissions")
}
fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
let path = normalize_config_file_path(path.to_path_buf())?;
read_string_no_follow(&path)
.with_context(|| format!("failed to read {label} at {}", path.display()))
}
#[cfg(unix)]
fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
let mut file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
let mut raw = String::new();
file.read_to_string(&mut raw)?;
Ok(raw)
}
#[cfg(not(unix))]
fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
fs::read_to_string(path)
}
fn reject_path_symlink(path: &Path) -> Result<()> {
let Ok(metadata) = fs::symlink_metadata(path) else {
return Ok(());
};
if metadata.file_type().is_symlink() {
bail!("config path must not be a symlink: {}", path.display());
}
Ok(())
}
#[derive(Debug, Clone, Default)]
struct EnvRuntimeOverrides {
provider: Option<ProviderKind>,
provider_source: Option<&'static str>,
model: Option<String>,
volcengine_model: Option<String>,
wanjie_ark_model: Option<String>,
openrouter_model: Option<String>,
orcarouter_model: Option<String>,
moonshot_model: Option<String>,
xiaomi_mimo_model: Option<String>,
xiaomi_mimo_mode: Option<String>,
novita_model: Option<String>,
fireworks_model: Option<String>,
arcee_model: Option<String>,
output_mode: Option<String>,
auth_mode: Option<String>,
log_level: Option<String>,
telemetry: Option<bool>,
telemetry_env_invalid: bool,
telemetry_floor: bool,
telemetry_endpoint: Option<String>,
approval_policy: Option<String>,
sandbox_mode: Option<String>,
yolo: Option<bool>,
verbosity: Option<String>,
http_headers: Option<BTreeMap<String, String>>,
deepseek_base_url: Option<String>,
deepseek_anthropic_base_url: Option<String>,
nvidia_base_url: Option<String>,
openai_base_url: Option<String>,
atlascloud_base_url: Option<String>,
volcengine_base_url: Option<String>,
wanjie_ark_base_url: Option<String>,
openrouter_base_url: Option<String>,
orcarouter_base_url: Option<String>,
xiaomi_mimo_base_url: Option<String>,
novita_base_url: Option<String>,
fireworks_base_url: Option<String>,
siliconflow_base_url: Option<String>,
siliconflow_model: Option<String>,
arcee_base_url: Option<String>,
moonshot_base_url: Option<String>,
sglang_base_url: Option<String>,
vllm_base_url: Option<String>,
ollama_base_url: Option<String>,
ollama_cloud_base_url: Option<String>,
ollama_cloud_model: Option<String>,
huggingface_base_url: Option<String>,
huggingface_model: Option<String>,
together_base_url: Option<String>,
together_model: Option<String>,
qianfan_base_url: Option<String>,
qianfan_model: Option<String>,
openai_codex_base_url: Option<String>,
openai_codex_model: Option<String>,
anthropic_base_url: Option<String>,
anthropic_model: Option<String>,
openmodel_base_url: Option<String>,
openmodel_model: Option<String>,
zai_base_url: Option<String>,
zai_model: Option<String>,
stepfun_base_url: Option<String>,
stepfun_model: Option<String>,
minimax_base_url: Option<String>,
minimax_anthropic_base_url: Option<String>,
minimax_model: Option<String>,
deepinfra_base_url: Option<String>,
deepinfra_model: Option<String>,
sakana_base_url: Option<String>,
sakana_model: Option<String>,
longcat_base_url: Option<String>,
longcat_model: Option<String>,
opencode_go_base_url: Option<String>,
opencode_go_model: Option<String>,
opencode_zen_base_url: Option<String>,
opencode_zen_model: Option<String>,
meta_base_url: Option<String>,
meta_model: Option<String>,
xai_base_url: Option<String>,
xai_model: Option<String>,
mistral_base_url: Option<String>,
mistral_model: Option<String>,
google_base_url: Option<String>,
google_model: Option<String>,
antigravity_base_url: Option<String>,
antigravity_model: Option<String>,
telecomjs_base_url: Option<String>,
telecomjs_model: Option<String>,
edenai_base_url: Option<String>,
edenai_model: Option<String>,
modelstudio_token_plan_base_url: Option<String>,
modelstudio_token_plan_model: Option<String>,
modelstudio_coding_plan_base_url: Option<String>,
modelstudio_coding_plan_model: Option<String>,
}
impl EnvRuntimeOverrides {
fn load() -> Self {
let (provider, provider_source) = Self::load_provider();
let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
let telemetry_floor = telemetry_floor_in_force();
Self {
provider,
provider_source,
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
verbosity: std::env::var("CODEWHALE_VERBOSITY")
.or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
.ok(),
output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
.or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
.ok(),
auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
.or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
.ok(),
log_level: std::env::var("CODEWHALE_LOG_LEVEL")
.or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
.ok(),
telemetry,
telemetry_env_invalid,
telemetry_floor,
telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
.or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
.ok(),
approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
.or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
.ok(),
sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
.or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
.ok(),
yolo: std::env::var("CODEWHALE_YOLO")
.or_else(|_| std::env::var("DEEPSEEK_YOLO"))
.ok()
.and_then(|v| match parse_bool(&v) {
Ok(b) => Some(b),
Err(_) => {
tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
None
}
}),
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
None
}
})
.filter(|headers| !headers.is_empty()),
deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
antigravity_base_url: std::env::var("ANTIGRAVITY_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
antigravity_model: std::env::var("ANTIGRAVITY_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
let parsed = ProviderKind::parse_config_identity(&value);
return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
}
if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
let parsed = ProviderKind::parse_config_identity(&value);
return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
}
(None, None)
}
fn load_telemetry() -> (Option<bool>, bool) {
let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
.or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
.ok()
else {
return (None, false);
};
match parse_bool(&raw) {
Ok(value) => (Some(value), false),
Err(_) => {
tracing::warn!(
"Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{raw}'; expected one of \
1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
);
(None, true)
}
}
}
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
match provider {
ProviderKind::Deepseek => self.deepseek_base_url.clone(),
ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
ProviderKind::Openai => self.openai_base_url.clone(),
ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
ProviderKind::Volcengine => self.volcengine_base_url.clone(),
ProviderKind::Openrouter => self.openrouter_base_url.clone(),
ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
ProviderKind::Novita => self.novita_base_url.clone(),
ProviderKind::Fireworks => self.fireworks_base_url.clone(),
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
self.siliconflow_base_url.clone()
}
ProviderKind::Arcee => self.arcee_base_url.clone(),
ProviderKind::Moonshot => self.moonshot_base_url.clone(),
ProviderKind::Sglang => self.sglang_base_url.clone(),
ProviderKind::Vllm => self.vllm_base_url.clone(),
ProviderKind::Ollama => self.ollama_base_url.clone(),
ProviderKind::OllamaCloud => self.ollama_cloud_base_url.clone(),
ProviderKind::Huggingface => self.huggingface_base_url.clone(),
ProviderKind::Together => self.together_base_url.clone(),
ProviderKind::Qianfan => self.qianfan_base_url.clone(),
ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
ProviderKind::Anthropic => self.anthropic_base_url.clone(),
ProviderKind::Openmodel => self.openmodel_base_url.clone(),
ProviderKind::Zai => self.zai_base_url.clone(),
ProviderKind::Stepfun => self.stepfun_base_url.clone(),
ProviderKind::Minimax => self.minimax_base_url.clone(),
ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
ProviderKind::Sakana => self.sakana_base_url.clone(),
ProviderKind::LongCat => self.longcat_base_url.clone(),
ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
ProviderKind::Meta => self.meta_base_url.clone(),
ProviderKind::Xai => self.xai_base_url.clone(),
ProviderKind::Mistral => self.mistral_base_url.clone(),
ProviderKind::Google => self.google_base_url.clone(),
ProviderKind::Antigravity => self.antigravity_base_url.clone(),
ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
ProviderKind::Edenai => self.edenai_base_url.clone(),
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
self.modelstudio_token_plan_base_url.clone()
}
ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
self.modelstudio_coding_plan_base_url.clone()
}
ProviderKind::Custom => None,
}
}
fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
let model = match provider {
ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
ProviderKind::Volcengine => self.volcengine_model.clone(),
ProviderKind::Openrouter => self.openrouter_model.clone(),
ProviderKind::Orcarouter => self.orcarouter_model.clone(),
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
self.siliconflow_model.clone()
}
ProviderKind::Arcee => self.arcee_model.clone(),
ProviderKind::Moonshot => self.moonshot_model.clone(),
ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
ProviderKind::Novita => self.novita_model.clone(),
ProviderKind::Fireworks => self.fireworks_model.clone(),
ProviderKind::Huggingface => self.huggingface_model.clone(),
ProviderKind::Together => self.together_model.clone(),
ProviderKind::Qianfan => self.qianfan_model.clone(),
ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
ProviderKind::Anthropic => self.anthropic_model.clone(),
ProviderKind::Openmodel => self.openmodel_model.clone(),
ProviderKind::Zai => self.zai_model.clone(),
ProviderKind::Stepfun => self.stepfun_model.clone(),
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
ProviderKind::Deepinfra => self.deepinfra_model.clone(),
ProviderKind::Sakana => self.sakana_model.clone(),
ProviderKind::LongCat => self.longcat_model.clone(),
ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
ProviderKind::Meta => self.meta_model.clone(),
ProviderKind::Xai => self.xai_model.clone(),
ProviderKind::Mistral => self.mistral_model.clone(),
ProviderKind::Google => self.google_model.clone(),
ProviderKind::Antigravity => self.antigravity_model.clone(),
ProviderKind::Telecomjs => self.telecomjs_model.clone(),
ProviderKind::Edenai => self.edenai_model.clone(),
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
self.modelstudio_token_plan_model.clone()
}
ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
self.modelstudio_coding_plan_model.clone()
}
ProviderKind::OllamaCloud => self.ollama_cloud_model.clone(),
_ => None,
}?;
if provider_preserves_custom_base_url_model(provider, base_url) {
Some(model.trim().to_string())
} else {
Some(normalize_model_for_provider(provider, &model))
}
}
}
#[cfg(test)]
mod tests;