use std::collections::HashMap;
use std::fmt::Write;
use std::fs;
#[cfg(unix)]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use codewhale_execpolicy::ExecPolicyEngine;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use crate::audit::log_sensitive_event;
use crate::features::{Feature, Features, FeaturesToml, is_known_feature_key};
use crate::hooks::HooksConfig;
pub const DEFAULT_MAX_SUBAGENTS: usize = 20;
pub const MAX_SUBAGENTS: usize = 20;
pub const MAX_SUBAGENT_ADMISSION: usize = 200;
pub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;
pub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;
pub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;
pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300;
pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30;
pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600;
pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300;
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS";
fn resolve_subagent_api_timeout_secs(raw: Option<u64>) -> u64 {
let raw = raw.unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);
if raw == 0 {
return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;
}
raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)
}
fn resolve_subagent_heartbeat_timeout_secs(raw: Option<u64>, api_timeout_secs: u64) -> u64 {
let raw = raw.unwrap_or(DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS);
let configured = if raw == 0 {
DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
} else {
raw.clamp(
MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
)
};
let min_for_api = api_timeout_secs.saturating_add(30).clamp(
MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
);
configured.max(min_for_api)
}
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
pub const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
pub const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
pub const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
pub const DEFAULT_VOLCENGINE_FLASH_MODEL: &str = "DeepSeek-V4-Flash";
pub const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
pub const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
pub const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
pub const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
pub const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
pub const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
pub const OPENROUTER_GLM_5_TURBO_MODEL: &str = "z-ai/glm-5-turbo";
pub const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
pub const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
pub const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
pub const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
pub const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
pub const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
pub const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
pub const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
pub const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
pub const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
pub const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
pub const OPENROUTER_NEMOTRON_3_ULTRA_MODEL: &str = "nvidia/nemotron-3-ultra-550b-a55b";
pub const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
pub const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
pub const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[
OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
OPENROUTER_MINIMAX_M3_MODEL,
OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL,
OPENROUTER_XIAOMI_MIMO_V2_5_MODEL,
OPENROUTER_QWEN_3_6_FLASH_MODEL,
OPENROUTER_QWEN_3_6_35B_A3B_MODEL,
OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL,
OPENROUTER_QWEN_3_6_27B_MODEL,
OPENROUTER_QWEN_3_6_PLUS_MODEL,
OPENROUTER_QWEN_3_7_MAX_MODEL,
OPENROUTER_MINIMAX_2_7_MODEL,
OPENROUTER_NEMOTRON_3_ULTRA_MODEL,
OPENROUTER_KIMI_K2_7_CODE_MODEL,
OPENROUTER_KIMI_K2_6_MODEL,
OPENROUTER_GLM_5_1_MODEL,
OPENROUTER_GLM_5_2_MODEL,
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL,
OPENROUTER_GEMMA_4_31B_MODEL,
OPENROUTER_GEMMA_4_26B_A4B_MODEL,
OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL,
];
pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
pub const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
pub const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
pub const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
pub const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
pub const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
pub const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
pub const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
pub const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
pub const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
pub const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
pub const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
pub const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
pub const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
pub const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
pub const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
pub const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
pub const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
pub const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
pub const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
pub const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
pub const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
pub const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
pub const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
pub const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
pub const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
pub const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
pub const OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS: u32 = 400_000;
pub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"deepseek-ai/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
];
pub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &["deepseek-v4-pro", "deepseek-v4-flash"];
pub const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
pub const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
pub const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
pub const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
pub const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
pub const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
pub const ANTHROPIC_OPUS_MODEL: &str = "claude-opus-4-8";
pub const ANTHROPIC_HAIKU_MODEL: &str = "claude-haiku-4-5";
pub const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
pub const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
pub const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
pub const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
pub const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
pub const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
pub const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
pub const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
pub const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
pub const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiProvider {
Deepseek,
DeepseekCN,
NvidiaNim,
Openai,
Atlascloud,
WanjieArk,
Volcengine,
Openrouter,
XiaomiMimo,
Novita,
Fireworks,
Siliconflow,
SiliconflowCn,
Arcee,
Moonshot,
Sglang,
Vllm,
Ollama,
Huggingface,
Together,
OpenaiCodex,
Anthropic,
Zai,
Stepfun,
Minimax,
Deepinfra,
}
impl ApiProvider {
#[must_use]
pub fn names_hint() -> String {
let mut names = Vec::with_capacity(Self::all().len() + 1);
names.push(Self::Deepseek.as_str());
names.push(Self::DeepseekCN.as_str());
names.extend(
Self::all()
.iter()
.filter(|provider| !matches!(provider, Self::Deepseek))
.map(|provider| provider.as_str()),
);
names.join(", ")
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("deepseek-cn")
|| trimmed.eq_ignore_ascii_case("deepseek_china")
|| trimmed.eq_ignore_ascii_case("deepseekcn")
|| trimmed.eq_ignore_ascii_case("deepseek-china")
{
return Some(Self::DeepseekCN);
}
codewhale_config::ProviderKind::parse(value).map(Self::from_kind)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self.kind() {
Some(kind) => kind.as_str(),
None => "deepseek-cn",
}
}
#[must_use]
pub fn display_name(self) -> &'static str {
match self.kind() {
Some(kind) => kind.provider().display_name(),
None => "DeepSeek (legacy alias)",
}
}
#[must_use]
pub fn metadata(self) -> Option<&'static dyn codewhale_config::provider::Provider> {
self.kind().map(|kind| kind.provider())
}
#[must_use]
pub fn env_vars(self) -> &'static [&'static str] {
self.metadata().map_or(
codewhale_config::ProviderKind::Deepseek
.provider()
.env_vars(),
|provider| provider.env_vars(),
)
}
#[must_use]
pub fn env_vars_label(self) -> String {
self.env_vars().join(" / ")
}
#[must_use]
pub fn sorted_for_display() -> Vec<Self> {
codewhale_config::provider::providers_sorted_for_display()
.iter()
.map(|provider| Self::from_kind(provider.kind()))
.collect()
}
#[must_use]
pub fn default_base_url(self) -> &'static str {
match self {
Self::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,
_ => self
.metadata()
.expect("ApiProvider variant missing ProviderKind metadata")
.default_base_url(),
}
}
#[must_use]
pub fn all() -> &'static [Self] {
&Self::FROM_KIND_LOOKUP
}
const KIND_LOOKUP: [Option<codewhale_config::ProviderKind>; 26] = [
Some(codewhale_config::ProviderKind::Deepseek),
None, Some(codewhale_config::ProviderKind::NvidiaNim),
Some(codewhale_config::ProviderKind::Openai),
Some(codewhale_config::ProviderKind::Atlascloud),
Some(codewhale_config::ProviderKind::WanjieArk),
Some(codewhale_config::ProviderKind::Volcengine),
Some(codewhale_config::ProviderKind::Openrouter),
Some(codewhale_config::ProviderKind::XiaomiMimo),
Some(codewhale_config::ProviderKind::Novita),
Some(codewhale_config::ProviderKind::Fireworks),
Some(codewhale_config::ProviderKind::Siliconflow),
Some(codewhale_config::ProviderKind::SiliconflowCN),
Some(codewhale_config::ProviderKind::Arcee),
Some(codewhale_config::ProviderKind::Moonshot),
Some(codewhale_config::ProviderKind::Sglang),
Some(codewhale_config::ProviderKind::Vllm),
Some(codewhale_config::ProviderKind::Ollama),
Some(codewhale_config::ProviderKind::Huggingface),
Some(codewhale_config::ProviderKind::Together),
Some(codewhale_config::ProviderKind::OpenaiCodex),
Some(codewhale_config::ProviderKind::Anthropic),
Some(codewhale_config::ProviderKind::Zai),
Some(codewhale_config::ProviderKind::Stepfun),
Some(codewhale_config::ProviderKind::Minimax),
Some(codewhale_config::ProviderKind::Deepinfra),
];
const FROM_KIND_LOOKUP: [Self; 25] = [
Self::Deepseek,
Self::NvidiaNim,
Self::Openai,
Self::Atlascloud,
Self::WanjieArk,
Self::Volcengine,
Self::Openrouter,
Self::XiaomiMimo,
Self::Novita,
Self::Fireworks,
Self::Siliconflow,
Self::Arcee,
Self::SiliconflowCn,
Self::Moonshot,
Self::Sglang,
Self::Vllm,
Self::Ollama,
Self::Huggingface,
Self::Together,
Self::OpenaiCodex,
Self::Anthropic,
Self::Zai,
Self::Stepfun,
Self::Minimax,
Self::Deepinfra,
];
#[must_use]
pub fn kind(self) -> Option<codewhale_config::ProviderKind> {
Self::KIND_LOOKUP[self as usize]
}
#[must_use]
pub fn from_kind(kind: codewhale_config::ProviderKind) -> Self {
Self::FROM_KIND_LOOKUP[kind as usize]
}
}
fn normalize_subagent_provider_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.chars()
.map(|ch| match ch {
'-' | '_' | '.' | ' ' => '_',
_ => ch,
})
.collect()
}
fn subagent_provider_key_matches(key: &str, provider: ApiProvider) -> bool {
if ApiProvider::parse(key).is_some_and(|candidate| candidate == provider) {
return true;
}
let normalized = normalize_subagent_provider_key(key);
if normalized == normalize_subagent_provider_key(provider.as_str()) {
return true;
}
match provider {
ApiProvider::Deepseek => matches!(
normalized.as_str(),
"deepseek" | "deepseek_api" | "deepseek_official"
),
ApiProvider::DeepseekCN => matches!(
normalized.as_str(),
"deepseek_cn" | "deepseek_china" | "deepseekcn"
),
ApiProvider::Openrouter => matches!(normalized.as_str(), "openrouter" | "open_router"),
ApiProvider::OpenaiCodex => matches!(
normalized.as_str(),
"openai_codex" | "codex" | "chatgpt" | "openai_chatgpt"
),
ApiProvider::Anthropic => {
matches!(
normalized.as_str(),
"anthropic" | "claude" | "anthropic_api"
)
}
ApiProvider::Zai => matches!(
normalized.as_str(),
"zai" | "z_ai" | "glm" | "zai_glm" | "z_glm"
),
_ => false,
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ProviderCapability {
pub provider: ApiProvider,
pub resolved_model: String,
pub context_window: u32,
pub max_output: u32,
pub thinking_supported: bool,
pub cache_telemetry_supported: bool,
pub request_payload_mode: RequestPayloadMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias_deprecation: Option<ModelAliasDeprecation>,
}
pub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = "2026-07-24";
pub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = "2026-07-24T15:59:00Z";
pub const DEEPSEEK_ALIAS_REPLACEMENT: &str = "deepseek-v4-flash";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ModelAliasDeprecation {
pub alias: String,
pub replacement: String,
pub retirement_date: String,
pub retirement_utc: String,
pub notice: String,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum RequestPayloadMode {
ChatCompletions,
Responses,
AnthropicMessages,
}
#[must_use]
pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {
if matches!(provider, ApiProvider::Anthropic) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: crate::models::context_window_for_model(resolved_model)
.unwrap_or(200_000),
max_output: crate::models::max_output_tokens_for_model(resolved_model)
.unwrap_or(64_000),
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: true,
request_payload_mode: RequestPayloadMode::AnthropicMessages,
alias_deprecation: None,
};
}
if matches!(provider, ApiProvider::OpenaiCodex) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
max_output: crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096),
thinking_supported: true,
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::Responses,
alias_deprecation: None,
};
}
if matches!(provider, ApiProvider::XiaomiMimo) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: crate::models::context_window_for_model(resolved_model)
.unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096),
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
if matches!(provider, ApiProvider::Arcee) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: crate::models::context_window_for_model(resolved_model)
.unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096),
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
let model_lower = resolved_model.to_ascii_lowercase();
let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
deepseek_alias_deprecation(&model_lower)
} else {
None
};
let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro";
let is_v4_flash = model_lower.contains("v4-flash")
|| model_lower == "deepseek-v4flash"
|| model_lower == "deepseek-v4"
|| alias_deprecation.is_some();
let is_reasoner = matches!(provider, ApiProvider::WanjieArk)
&& (model_lower.contains("reasoner") || model_lower.contains("r1"));
let context_window = if is_v4_pro || is_v4_flash {
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
} else if let Some(window) = crate::models::context_window_for_model(resolved_model) {
window
} else if matches!(provider, ApiProvider::Ollama) {
8192
} else {
crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
};
let max_output = if is_v4_pro || is_v4_flash {
384_000
} else {
crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096)
};
let thinking_supported = is_v4_pro
|| is_v4_flash
|| is_reasoner
|| crate::models::model_supports_reasoning(resolved_model);
let cache_telemetry_supported = matches!(
provider,
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::NvidiaNim
| ApiProvider::Volcengine
);
let request_payload_mode = RequestPayloadMode::ChatCompletions;
ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window,
max_output,
thinking_supported,
cache_telemetry_supported,
request_payload_mode,
alias_deprecation,
}
}
fn deepseek_alias_deprecation(model_lower: &str) -> Option<ModelAliasDeprecation> {
match model_lower {
"deepseek-chat" | "deepseek-reasoner" => Some(ModelAliasDeprecation {
alias: model_lower.to_string(),
replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),
retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),
retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),
notice: format!(
"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}."
),
}),
_ => None,
}
}
#[must_use]
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"),
"flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"),
_ => None,
}
}
#[must_use]
pub fn normalize_model_name(model: &str) -> Option<String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return None;
}
if let Some(canonical) = canonical_model_name(trimmed) {
return Some(canonical.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
}
#[must_use]
pub(crate) fn normalize_custom_model_id(model: &str) -> Option<String> {
let trimmed = model.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
None
} else {
Some(trimmed.to_string())
}
}
#[must_use]
pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => normalize_model_name(model),
_ => normalize_custom_model_id(model),
}
}
pub fn validate_route(provider: ApiProvider, model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err(format!(
"No model selected for provider '{}'.",
provider.as_str()
));
}
if trimmed.eq_ignore_ascii_case("auto") {
return Ok(());
}
if provider_passes_model_through(provider) {
return Ok(());
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
if normalize_model_name(trimmed).is_some() {
return Ok(());
}
return Err(format!(
"Model '{trimmed}' is not a DeepSeek model, but the active provider is '{}'. \
Use a DeepSeek model id (for example {}) or switch providers together with the model.",
provider.as_str(),
COMMON_DEEPSEEK_MODELS.join(", ")
));
}
if root_deepseek_model_is_foreign_to_direct_provider(provider, trimmed) {
return Err(format!(
"Model '{trimmed}' is a DeepSeek model and is not compatible with provider '{}'. \
Switch the provider and model together, or pick a model this provider serves.",
provider.as_str()
));
}
Ok(())
}
fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-v4-pro"
| "deepseek-v4pro"
| "deepseek-ai/deepseek-v4-pro"
| "deepseek-ai/deepseek-v4pro"
| "deepseek/deepseek-v4-pro"
| "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"),
"deepseek-v4-flash"
| "deepseek-v4flash"
| "deepseek-ai/deepseek-v4-flash"
| "deepseek-ai/deepseek-v4flash"
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"),
_ => 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_TURBO_MODEL | "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => {
Some(OPENROUTER_GLM_5_TURBO_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_2_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_2_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_NEMOTRON_3_ULTRA_MODEL
| "nvidia/nemotron-3-ultra"
| "nemotron-3-ultra"
| "nemotron-3-ultra-550b-a55b"
| "nvidia-nemotron-3-ultra"
| "nvidia-nemotron-3-ultra-550b-a55b" => Some(OPENROUTER_NEMOTRON_3_ULTRA_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_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_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),
"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_arcee_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking" => {
Some(DEFAULT_ARCEE_MODEL)
}
"arcee-trinity-mini" | ARCEE_TRINITY_MINI_MODEL => Some(ARCEE_TRINITY_MINI_MODEL),
"arcee-trinity-large-preview" | ARCEE_TRINITY_LARGE_PREVIEW_MODEL => {
Some(ARCEE_TRINITY_LARGE_PREVIEW_MODEL)
}
_ => None,
}
}
fn canonical_moonshot_model_id(model: &str) -> Option<&'static str> {
let normalized = model.trim().to_ascii_lowercase();
let normalized = normalized.replace(['_', ' '], "-");
match normalized.as_str() {
"kimi"
| "kimi-k2"
| "kimi-k2.7"
| "kimi-k2-7"
| "kimi-k2.7-code"
| "kimi-k2-7-code"
| "kimi-code"
| "moonshot-kimi-k2.7-code" => Some(DEFAULT_MOONSHOT_MODEL),
"kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => Some(MOONSHOT_KIMI_K2_6_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(DEFAULT_ZAI_MODEL),
"glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_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,
}
}
#[must_use]
pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
if matches!(provider, ApiProvider::Openrouter)
&& let Some(canonical) = canonical_openrouter_recent_model_id(model)
{
return Some(canonical.to_string());
}
if matches!(provider, ApiProvider::XiaomiMimo)
&& let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
{
return Some(canonical.to_string());
}
if matches!(provider, ApiProvider::Arcee) {
return canonical_arcee_model_id(model)
.map(ToString::to_string)
.or_else(|| normalize_custom_model_id(model));
}
if matches!(provider, ApiProvider::Moonshot) {
return canonical_moonshot_model_id(model)
.map(ToString::to_string)
.or_else(|| normalize_custom_model_id(model));
}
if matches!(provider, ApiProvider::Zai) {
return canonical_zai_model_id(model)
.map(ToString::to_string)
.or_else(|| normalize_custom_model_id(model));
}
if matches!(provider, ApiProvider::Minimax) {
return canonical_minimax_model_id(model)
.map(ToString::to_string)
.or_else(|| normalize_custom_model_id(model));
}
if matches!(provider, ApiProvider::Huggingface) {
return normalize_custom_model_id(model);
}
let normalized = normalize_model_name(model)?;
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& let Some(canonical) = canonical_official_deepseek_model_id(&normalized)
{
if canonical.eq_ignore_ascii_case(&normalized)
|| normalized.to_ascii_lowercase() == canonical
{
return Some(normalized);
}
return Some(canonical.to_string());
}
if matches!(
provider,
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
) {
let provider_model = model_for_provider(provider, normalized.clone());
if provider_model != normalized {
return Some(provider_model);
}
}
if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {
return Some(model_for_provider(provider, canonical.to_string()));
}
Some(normalized)
}
#[must_use]
pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String {
let trimmed = model.trim();
if trimmed.is_empty() {
return trimmed.to_string();
}
if matches!(provider, ApiProvider::XiaomiMimo) {
return normalize_model_name_for_provider(provider, trimmed)
.unwrap_or_else(|| trimmed.to_string());
}
if provider_passes_model_through(provider) {
return trimmed.to_string();
}
normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string())
}
#[must_use]
pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),
ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],
ApiProvider::Openrouter => {
let mut models = vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL];
models.extend_from_slice(RECENT_OPENROUTER_LARGE_MODELS);
models
}
ApiProvider::XiaomiMimo => vec![DEFAULT_XIAOMI_MIMO_MODEL, XIAOMI_MIMO_V2_5_OMNI_MODEL],
ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],
ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => {
vec![DEFAULT_SILICONFLOW_MODEL, DEFAULT_SILICONFLOW_FLASH_MODEL]
}
ApiProvider::Arcee => vec![DEFAULT_ARCEE_MODEL, ARCEE_TRINITY_LARGE_PREVIEW_MODEL],
ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL],
ApiProvider::Huggingface => {
vec![DEFAULT_HUGGINGFACE_MODEL, DEFAULT_HUGGINGFACE_FLASH_MODEL]
}
ApiProvider::Deepinfra => vec![DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPINFRA_FLASH_MODEL],
ApiProvider::WanjieArk => {
vec![
DEFAULT_WANJIE_ARK_MODEL,
"deepseek-v4-pro",
"deepseek-v4-flash",
]
}
ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],
ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],
ApiProvider::Volcengine => vec![DEFAULT_VOLCENGINE_MODEL, DEFAULT_VOLCENGINE_FLASH_MODEL],
ApiProvider::Ollama => Vec::new(),
ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(),
ApiProvider::Together => vec![DEFAULT_TOGETHER_MODEL],
ApiProvider::OpenaiCodex => vec![DEFAULT_OPENAI_CODEX_MODEL],
ApiProvider::Zai => vec![DEFAULT_ZAI_MODEL, ZAI_GLM_5_1_MODEL, ZAI_GLM_5_TURBO_MODEL],
ApiProvider::Stepfun => vec![DEFAULT_STEPFUN_MODEL],
ApiProvider::Anthropic => vec![
ANTHROPIC_OPUS_MODEL,
DEFAULT_ANTHROPIC_MODEL,
ANTHROPIC_HAIKU_MODEL,
],
ApiProvider::Minimax => vec![
DEFAULT_MINIMAX_MODEL,
MINIMAX_M2_7_MODEL,
MINIMAX_M2_7_HIGHSPEED_MODEL,
MINIMAX_M2_5_MODEL,
MINIMAX_M2_5_HIGHSPEED_MODEL,
MINIMAX_M2_1_MODEL,
MINIMAX_M2_1_HIGHSPEED_MODEL,
MINIMAX_M2_MODEL,
],
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RetryConfig {
pub enabled: Option<bool>,
pub max_retries: Option<u32>,
pub initial_delay: Option<f64>,
pub max_delay: Option<f64>,
pub exponential_base: Option<f64>,
}
fn deser_status_items<'de, D>(deserializer: D) -> Result<Option<Vec<StatusItem>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<Vec<String>> = Option::deserialize(deserializer)?;
Ok(raw.map(|strings| {
strings
.into_iter()
.filter_map(|s| {
StatusItem::from_key(&s).or_else(|| {
tracing::warn!("ignoring unknown status item {s:?} in config");
None
})
})
.collect()
}))
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TuiConfig {
pub alternate_screen: Option<String>,
pub mouse_capture: Option<bool>,
pub terminal_probe_timeout_ms: Option<u64>,
pub stream_chunk_timeout_secs: Option<u64>,
#[serde(default, deserialize_with = "deser_status_items")]
pub status_items: Option<Vec<StatusItem>>,
pub osc8_links: Option<bool>,
pub notification_condition: Option<NotificationCondition>,
#[serde(default)]
pub composer_arrows_scroll: Option<bool>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NotificationCondition {
Always,
Never,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum NotificationMethod {
#[default]
Auto,
Osc9,
Bel,
Kitty,
Ghostty,
Off,
}
fn default_threshold_secs() -> u64 {
30
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CompletionSound {
Off,
#[default]
Beep,
Bell,
File,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct NotificationsConfig {
#[serde(default)]
pub method: NotificationMethod,
#[serde(default = "default_threshold_secs")]
pub threshold_secs: u64,
#[serde(default)]
pub include_summary: bool,
#[serde(default)]
pub completion_sound: CompletionSound,
#[serde(default)]
pub sound_file: Option<PathBuf>,
}
fn default_snapshots_enabled() -> bool {
true
}
fn default_snapshot_max_age_days() -> u64 {
crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
}
fn default_snapshot_max_workspace_gb() -> u64 {
crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
}
#[derive(Debug, Clone, Deserialize)]
pub struct SnapshotsConfig {
#[serde(default = "default_snapshots_enabled")]
pub enabled: bool,
#[serde(default = "default_snapshot_max_age_days")]
pub max_age_days: u64,
#[serde(default = "default_snapshot_max_workspace_gb")]
pub max_workspace_gb: u64,
}
impl Default for SnapshotsConfig {
fn default() -> Self {
Self {
enabled: default_snapshots_enabled(),
max_age_days: default_snapshot_max_age_days(),
max_workspace_gb: default_snapshot_max_workspace_gb(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MemoryConfig {
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SpeechConfig {
#[serde(default)]
pub output_dir: Option<String>,
}
impl SnapshotsConfig {
#[must_use]
pub fn max_age(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchProvider {
Bing,
#[default]
#[serde(alias = "duckduckgo")]
DuckDuckGo,
Tavily,
Bocha,
#[serde(alias = "metaso")]
Metaso,
#[serde(
alias = "baidu-search",
alias = "baidu_ai_search",
alias = "baidu_search",
alias = "baidu-ai-search"
)]
Baidu,
#[serde(
alias = "volcengine",
alias = "ark",
alias = "volc",
alias = "volcengine-ark",
alias = "volcengine_ark",
alias = "volc-ark"
)]
Volcengine,
Sofya,
}
impl SearchProvider {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"bing" => Some(Self::Bing),
"duckduckgo" | "duck-duck-go" | "duck_duck_go" | "ddg" => Some(Self::DuckDuckGo),
"tavily" => Some(Self::Tavily),
"bocha" => Some(Self::Bocha),
"metaso" => Some(Self::Metaso),
"baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" | "baidu_ai_search" => {
Some(Self::Baidu)
}
"volcengine" | "ark" | "volc" | "volcengine-ark" => Some(Self::Volcengine),
"sofya" => Some(Self::Sofya),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Bing => "bing",
Self::DuckDuckGo => "duckduckgo",
Self::Tavily => "tavily",
Self::Bocha => "bocha",
Self::Metaso => "metaso",
Self::Baidu => "baidu",
Self::Volcengine => "volcengine",
Self::Sofya => "sofya",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchProviderSource {
Default,
Config,
EnvOverride,
}
impl SearchProviderSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Config => "config",
Self::EnvOverride => "env override",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchProviderResolution {
pub provider: SearchProvider,
pub source: SearchProviderSource,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SearchConfig {
#[serde(default)]
pub provider: Option<SearchProvider>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ToolsConfig {
#[serde(default)]
pub always_load: Vec<String>,
#[serde(default)]
pub plugin_dir: Option<String>,
#[serde(default)]
pub overrides: Option<HashMap<String, ToolOverride>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[serde(rename_all = "snake_case")]
pub enum StatusItem {
Mode,
Model,
Cost,
Status,
Agents,
ReasoningReplay,
PrefixStability,
Cache,
ContextPercent,
GitBranch,
LastToolElapsed,
RateLimit,
Tokens,
Balance,
}
impl StatusItem {
#[must_use]
pub fn default_footer() -> Vec<StatusItem> {
vec![
StatusItem::Mode,
StatusItem::Model,
StatusItem::Cost,
StatusItem::Status,
StatusItem::Agents,
StatusItem::ReasoningReplay,
StatusItem::Cache,
StatusItem::GitBranch,
StatusItem::Tokens,
]
}
#[must_use]
pub fn key(self) -> &'static str {
match self {
StatusItem::Mode => "mode",
StatusItem::Model => "model",
StatusItem::Cost => "cost",
StatusItem::Status => "status",
StatusItem::Agents => "agents",
StatusItem::ReasoningReplay => "reasoning_replay",
StatusItem::PrefixStability => "prefix_stability",
StatusItem::Cache => "cache",
StatusItem::ContextPercent => "context_percent",
StatusItem::GitBranch => "git_branch",
StatusItem::LastToolElapsed => "last_tool_elapsed",
StatusItem::RateLimit => "rate_limit",
StatusItem::Tokens => "tokens",
StatusItem::Balance => "balance",
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
match key {
"mode" => Some(Self::Mode),
"model" => Some(Self::Model),
"cost" => Some(Self::Cost),
"status" => Some(Self::Status),
"agents" => Some(Self::Agents),
"reasoning_replay" => Some(Self::ReasoningReplay),
"prefix_stability" => Some(Self::PrefixStability),
"cache" => Some(Self::Cache),
"context_percent" => Some(Self::ContextPercent),
"git_branch" => Some(Self::GitBranch),
"last_tool_elapsed" => Some(Self::LastToolElapsed),
"rate_limit" => Some(Self::RateLimit),
"tokens" => Some(Self::Tokens),
"balance" => Some(Self::Balance),
_ => None,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
StatusItem::Mode => "Mode",
StatusItem::Model => "Model",
StatusItem::Cost => "Session cost",
StatusItem::Status => "Activity (idle/busy/draft/working)",
StatusItem::Agents => "Sub-agents in flight",
StatusItem::ReasoningReplay => "Reasoning replay tokens",
StatusItem::PrefixStability => "Prefix stability",
StatusItem::Cache => "Prompt cache hit rate",
StatusItem::ContextPercent => "Context window %",
StatusItem::GitBranch => "Git branch",
StatusItem::LastToolElapsed => "Last tool elapsed",
StatusItem::RateLimit => "Rate-limit remaining",
StatusItem::Tokens => "Session tokens",
StatusItem::Balance => "Account balance",
}
}
#[must_use]
pub fn hint(self) -> &'static str {
match self {
StatusItem::Mode => "agent · yolo · plan",
StatusItem::Model => "the model id you'll send to",
StatusItem::Cost => "running total for this session",
StatusItem::Status => "what the agent is doing right now",
StatusItem::Agents => "agents or RLM work in progress",
StatusItem::ReasoningReplay => "thinking tokens replayed each turn",
StatusItem::PrefixStability => "whether system/tools stayed cacheable",
StatusItem::Cache => "% of prompt served from cache",
StatusItem::ContextPercent => "tokens used / model context window",
StatusItem::GitBranch => "current workspace branch",
StatusItem::LastToolElapsed => "ms of the most recent tool call (reserved)",
StatusItem::RateLimit => "remaining requests in the budget (reserved)",
StatusItem::Tokens => "input / cache-hit / output token totals",
StatusItem::Balance => "topped-up + granted balance from DeepSeek",
}
}
#[must_use]
pub fn all() -> &'static [StatusItem] {
&[
StatusItem::Mode,
StatusItem::Model,
StatusItem::Cost,
StatusItem::Balance,
StatusItem::Status,
StatusItem::Agents,
StatusItem::ReasoningReplay,
StatusItem::PrefixStability,
StatusItem::Cache,
StatusItem::ContextPercent,
StatusItem::GitBranch,
StatusItem::LastToolElapsed,
StatusItem::RateLimit,
StatusItem::Tokens,
]
}
#[must_use]
pub fn is_left_cluster(self) -> bool {
matches!(
self,
StatusItem::Mode
| StatusItem::Model
| StatusItem::Cost
| StatusItem::Status
| StatusItem::Balance
)
}
#[must_use]
pub fn is_available_for(self, provider: ApiProvider) -> bool {
match self {
StatusItem::Balance => {
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
}
_ => true,
}
}
}
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub enabled: bool,
pub max_retries: u32,
pub initial_delay: f64,
pub max_delay: f64,
pub exponential_base: f64,
}
impl RetryPolicy {
#[must_use]
#[allow(dead_code)] pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
let delay = self.initial_delay * self.exponential_base.powi(exponent);
let delay = delay.min(self.max_delay);
let delay = delay.clamp(0.0, 300.0);
std::time::Duration::from_secs_f64(delay)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ContextConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub project_pack: Option<bool>,
#[serde(default)]
pub verbatim_window_turns: Option<usize>,
#[serde(default)]
pub l1_threshold: Option<usize>,
#[serde(default)]
pub l2_threshold: Option<usize>,
#[serde(default)]
pub l3_threshold: Option<usize>,
#[serde(default)]
pub seam_model: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SubagentsConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub worker_model: Option<String>,
#[serde(default)]
pub explorer_model: Option<String>,
#[serde(default)]
pub awaiter_model: Option<String>,
#[serde(default)]
pub review_model: Option<String>,
#[serde(default)]
pub custom_model: Option<String>,
#[serde(default)]
pub models: Option<HashMap<String, String>>,
#[serde(default)]
pub max_concurrent: Option<usize>,
#[serde(default)]
pub max_depth: Option<u32>,
#[serde(default)]
pub launch_concurrency: Option<usize>,
#[serde(default, alias = "max_total", alias = "admission_limit")]
pub max_admitted: Option<usize>,
#[serde(default)]
pub token_budget: Option<u64>,
#[serde(default, rename = "interactive_max_launch")]
pub interactive_max_launch_legacy: Option<usize>,
#[serde(default)]
pub api_timeout_secs: Option<u64>,
#[serde(default)]
pub heartbeat_timeout_secs: Option<u64>,
#[serde(default)]
pub providers: Option<HashMap<String, SubagentProviderConfig>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SubagentProviderConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub max_concurrent: Option<usize>,
#[serde(default)]
pub max_depth: Option<u32>,
#[serde(default)]
pub launch_concurrency: Option<usize>,
#[serde(default, alias = "max_total", alias = "admission_limit")]
pub max_admitted: Option<usize>,
#[serde(default)]
pub token_budget: Option<u64>,
#[serde(default)]
pub api_timeout_secs: Option<u64>,
#[serde(default)]
pub heartbeat_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AutoConfig {
#[serde(default)]
pub cost_saving: Option<bool>,
}
fn default_update_check_for_updates() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct UpdateConfig {
#[serde(default = "default_update_check_for_updates")]
pub check_for_updates: bool,
#[serde(default)]
pub update_uri: Option<String>,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
check_for_updates: true,
update_uri: None,
}
}
}
impl UpdateConfig {
#[must_use]
pub fn update_uri(&self) -> Option<&str> {
self.update_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
pub provider: Option<String>,
#[serde(alias = "apiKey")]
pub api_key: Option<String>,
#[serde(alias = "baseUrl")]
pub base_url: Option<String>,
#[serde(alias = "httpHeaders")]
pub http_headers: Option<HashMap<String, String>>,
#[serde(alias = "defaultTextModel")]
pub default_text_model: Option<String>,
#[serde(alias = "authMode")]
pub auth_mode: Option<String>,
pub reasoning_effort: Option<String>,
pub tools_file: Option<String>,
#[serde(default)]
pub tools: Option<ToolsConfig>,
pub skills_dir: Option<String>,
pub mcp_config_path: Option<String>,
pub notes_path: Option<String>,
pub memory_path: Option<String>,
pub strict_tool_mode: Option<bool>,
pub instructions: Option<Vec<String>>,
pub allow_shell: Option<bool>,
pub prompt_suggestion: Option<bool>,
#[serde(alias = "approvalPolicy")]
pub approval_policy: Option<String>,
#[serde(alias = "sandboxMode")]
pub sandbox_mode: Option<String>,
#[serde(default, alias = "fallbackProviders")]
pub fallback_providers: Vec<codewhale_config::ProviderKind>,
pub yolo: Option<bool>,
pub verbosity: Option<String>,
#[serde(alias = "sandboxBackend")]
pub sandbox_backend: Option<String>,
#[serde(alias = "sandboxUrl")]
pub sandbox_url: Option<String>,
#[serde(alias = "sandboxApiKey")]
pub sandbox_api_key: Option<String>,
#[serde(alias = "preferBwrap")]
pub prefer_bwrap: Option<bool>,
#[serde(alias = "managedConfigPath")]
pub managed_config_path: Option<String>,
#[serde(alias = "requirementsPath")]
pub requirements_path: Option<String>,
#[serde(alias = "maxSubagents")]
pub max_subagents: Option<usize>,
pub retry: Option<RetryConfig>,
pub features: Option<FeaturesToml>,
#[serde(default)]
pub auto_review: Option<AutoReviewConfig>,
pub tui: Option<TuiConfig>,
#[serde(default)]
pub hooks: Option<HooksConfig>,
#[serde(default)]
pub providers: Option<ProvidersConfig>,
#[serde(default)]
pub notifications: Option<NotificationsConfig>,
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
#[serde(default)]
pub skills: Option<SkillsConfig>,
#[serde(default)]
pub snapshots: Option<SnapshotsConfig>,
#[serde(default)]
pub search: Option<SearchConfig>,
#[serde(default)]
pub memory: Option<MemoryConfig>,
#[serde(default)]
pub speech: Option<SpeechConfig>,
#[serde(default)]
pub auto: Option<AutoConfig>,
#[serde(default)]
pub hotbar: Option<Vec<codewhale_config::HotbarBindingToml>>,
#[serde(default)]
pub update: Option<UpdateConfig>,
#[serde(default)]
pub lsp: Option<LspConfigToml>,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub fleet: Option<codewhale_config::FleetConfigToml>,
#[serde(default)]
pub subagents: Option<SubagentsConfig>,
#[serde(default)]
pub runtime_api: Option<RuntimeApiConfig>,
#[serde(default)]
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
#[serde(default)]
pub vision_model: Option<VisionModelConfig>,
#[serde(skip)]
pub exec_policy_engine: ExecPolicyEngine,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AutoReviewConfig {
#[serde(default, alias = "guidance", alias = "naturalLanguageGuidance")]
pub natural_language_guidance: Option<String>,
#[serde(default)]
pub allow: Vec<AutoReviewRuleConfig>,
#[serde(default)]
pub block: Vec<AutoReviewRuleConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AutoReviewRuleConfig {
pub id: Option<String>,
#[serde(default, alias = "toolName", alias = "tool_name")]
pub tool: Option<String>,
#[serde(default, alias = "actionKind", alias = "action_kind")]
pub action_kind: Option<String>,
#[serde(default, alias = "textContains", alias = "text_contains")]
pub text_contains: Option<String>,
pub reason: Option<String>,
}
impl AutoReviewConfig {
fn to_runtime_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
crate::tui::auto_review::AutoReviewPolicy {
allow_rules: self
.allow
.iter()
.enumerate()
.map(|(index, rule)| {
rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Allow)
})
.collect(),
block_rules: self
.block
.iter()
.enumerate()
.map(|(index, rule)| {
rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Block)
})
.collect(),
natural_language_guidance: self
.natural_language_guidance
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
}
}
fn validate(&self) -> Result<()> {
validate_auto_review_rules("allow", &self.allow)?;
validate_auto_review_rules("block", &self.block)?;
Ok(())
}
}
impl AutoReviewRuleConfig {
fn to_runtime_rule(
&self,
index: usize,
action: crate::tui::auto_review::AutoReviewAction,
) -> crate::tui::auto_review::AutoReviewRule {
let id_prefix = match action {
crate::tui::auto_review::AutoReviewAction::Allow => "allow",
crate::tui::auto_review::AutoReviewAction::Block => "block",
crate::tui::auto_review::AutoReviewAction::AskUser => "ask",
crate::tui::auto_review::AutoReviewAction::HoldForReview => "hold",
};
let id = self
.id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
let reason = self
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
let mut rule = match action {
crate::tui::auto_review::AutoReviewAction::Allow => {
crate::tui::auto_review::AutoReviewRule::allow(id, reason)
}
crate::tui::auto_review::AutoReviewAction::Block => {
crate::tui::auto_review::AutoReviewRule::block(id, reason)
}
crate::tui::auto_review::AutoReviewAction::AskUser
| crate::tui::auto_review::AutoReviewAction::HoldForReview => {
crate::tui::auto_review::AutoReviewRule::block(id, reason)
}
};
if let Some(tool) = self
.tool
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
rule = rule.tool_name(tool.to_string());
}
if let Some(action_kind) = self
.action_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(parse_auto_review_action_kind)
{
rule = rule.action_kind(action_kind);
}
if let Some(text) = self
.text_contains
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
rule = rule.text_contains(text.to_string());
}
rule
}
fn has_matcher(&self) -> bool {
self.tool
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| self
.action_kind
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| self
.text_contains
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
}
fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Result<()> {
for (index, rule) in rules.iter().enumerate() {
if !rule.has_matcher() {
anyhow::bail!(
"Invalid auto_review.{kind}[{index}]: set at least one of tool, action_kind, or text_contains."
);
}
if let Some(action_kind) = rule.action_kind.as_deref()
&& parse_auto_review_action_kind(action_kind.trim()).is_none()
{
anyhow::bail!(
"Invalid auto_review.{kind}[{index}].action_kind '{action_kind}': expected read, write, shell, network, git, mcp_read, mcp_action, browser, secret, publish, destructive, or unknown."
);
}
}
Ok(())
}
fn parse_auto_review_action_kind(raw: &str) -> Option<crate::tui::auto_review::ToolActionKind> {
match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"read" => Some(crate::tui::auto_review::ToolActionKind::Read),
"write" => Some(crate::tui::auto_review::ToolActionKind::Write),
"shell" => Some(crate::tui::auto_review::ToolActionKind::Shell),
"network" => Some(crate::tui::auto_review::ToolActionKind::Network),
"git" => Some(crate::tui::auto_review::ToolActionKind::Git),
"mcp_read" => Some(crate::tui::auto_review::ToolActionKind::McpRead),
"mcp_action" => Some(crate::tui::auto_review::ToolActionKind::McpAction),
"browser" => Some(crate::tui::auto_review::ToolActionKind::Browser),
"secret" => Some(crate::tui::auto_review::ToolActionKind::Secret),
"publish" => Some(crate::tui::auto_review::ToolActionKind::Publish),
"destructive" => Some(crate::tui::auto_review::ToolActionKind::Destructive),
"unknown" => Some(crate::tui::auto_review::ToolActionKind::Unknown),
_ => None,
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolOverride {
Script {
path: String,
#[serde(default)]
args: Option<Vec<String>>,
},
Command {
command: String,
#[serde(default)]
args: Option<Vec<String>>,
},
Disabled,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VisionModelConfig {
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuntimeApiConfig {
#[serde(default)]
pub cors_origins: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SkillsConfig {
#[serde(default)]
pub registry_url: Option<String>,
#[serde(default)]
pub max_install_size_bytes: Option<u64>,
#[serde(default, alias = "scanCodewhaleOnly")]
pub scan_codewhale_only: Option<bool>,
}
impl SkillsConfig {
#[must_use]
pub fn registry_url(&self) -> String {
self.registry_url
.clone()
.unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())
}
#[must_use]
pub fn max_install_size_bytes(&self) -> u64 {
self.max_install_size_bytes
.unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)
}
#[must_use]
pub fn scan_codewhale_only(&self) -> bool {
self.scan_codewhale_only.unwrap_or(false)
}
}
#[derive(Debug, Clone, 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 = "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(),
audit: default_network_audit(),
}
}
}
impl NetworkPolicyToml {
#[must_use]
pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {
crate::network_policy::NetworkPolicy {
default: crate::network_policy::Decision::parse(&self.default).into(),
allow: self.allow,
deny: self.deny,
proxy: self.proxy,
audit: self.audit,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LspConfigToml {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub poll_after_edit_ms: Option<u64>,
#[serde(default)]
pub max_diagnostics_per_file: Option<usize>,
#[serde(default)]
pub include_warnings: Option<bool>,
#[serde(default)]
pub servers: Option<HashMap<String, Vec<String>>>,
}
impl LspConfigToml {
#[must_use]
pub fn into_runtime(self) -> crate::lsp::LspConfig {
let defaults = crate::lsp::LspConfig::default();
crate::lsp::LspConfig {
enabled: self.enabled.unwrap_or(defaults.enabled),
poll_after_edit_ms: self
.poll_after_edit_ms
.unwrap_or(defaults.poll_after_edit_ms),
max_diagnostics_per_file: self
.max_diagnostics_per_file
.unwrap_or(defaults.max_diagnostics_per_file),
include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
servers: self.servers.unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProviderConfig {
#[serde(alias = "apiKey")]
pub api_key: Option<String>,
#[serde(alias = "baseUrl")]
pub base_url: Option<String>,
pub model: Option<String>,
pub mode: Option<String>,
#[serde(alias = "authMode")]
pub auth_mode: Option<String>,
#[serde(alias = "insecureSkipTlsVerify")]
pub insecure_skip_tls_verify: Option<bool>,
#[serde(alias = "httpHeaders")]
pub http_headers: Option<HashMap<String, String>>,
#[serde(alias = "pathSuffix")]
pub path_suffix: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProvidersConfig {
#[serde(default)]
pub deepseek: ProviderConfig,
#[serde(default, alias = "deepseekCn")]
pub deepseek_cn: ProviderConfig,
#[serde(default, alias = "nvidiaNim")]
pub nvidia_nim: ProviderConfig,
#[serde(default)]
pub openai: ProviderConfig,
#[serde(default)]
pub atlascloud: ProviderConfig,
#[serde(default, alias = "wanjieArk")]
pub wanjie_ark: ProviderConfig,
#[serde(default)]
pub volcengine: ProviderConfig,
#[serde(default)]
pub openrouter: ProviderConfig,
#[serde(
default,
alias = "xiaomi",
alias = "mimo",
alias = "xiaomimimo",
alias = "xiaomiMimo"
)]
pub xiaomi_mimo: ProviderConfig,
#[serde(default)]
pub novita: ProviderConfig,
#[serde(default)]
pub fireworks: ProviderConfig,
#[serde(default)]
pub siliconflow: ProviderConfig,
#[serde(
default,
alias = "siliconflow-CN",
alias = "siliconflow-cn",
alias = "siliconflowCn"
)]
pub siliconflow_cn: ProviderConfig,
#[serde(default)]
pub arcee: ProviderConfig,
#[serde(default)]
pub moonshot: ProviderConfig,
#[serde(default)]
pub sglang: ProviderConfig,
#[serde(default)]
pub vllm: ProviderConfig,
#[serde(default)]
pub ollama: ProviderConfig,
#[serde(default, alias = "hugging-face", alias = "hf")]
pub huggingface: ProviderConfig,
#[serde(default, alias = "deep-infra", alias = "deep_infra")]
pub deepinfra: ProviderConfig,
#[serde(default, alias = "together-ai")]
pub together: ProviderConfig,
#[serde(
default,
alias = "openai-codex",
alias = "openaiCodex",
alias = "codex",
alias = "chatgpt"
)]
pub openai_codex: ProviderConfig,
#[serde(default, alias = "claude")]
pub anthropic: ProviderConfig,
#[serde(default)]
pub zai: ProviderConfig,
#[serde(default)]
pub stepfun: ProviderConfig,
#[serde(default)]
pub minimax: ProviderConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ConfigFile {
#[serde(flatten)]
base: Config,
profiles: Option<HashMap<String, Config>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct RequirementsFile {
#[serde(default)]
allowed_approval_policies: Vec<String>,
#[serde(default)]
allowed_sandbox_modes: Vec<String>,
}
impl Config {
#[must_use]
pub fn search_provider_resolution(&self) -> SearchProviderResolution {
if let Ok(raw) = std::env::var("DEEPSEEK_SEARCH_PROVIDER")
&& let Some(provider) = SearchProvider::parse(&raw)
{
return SearchProviderResolution {
provider,
source: SearchProviderSource::EnvOverride,
};
}
if let Some(provider) = self.search.as_ref().and_then(|search| search.provider) {
return SearchProviderResolution {
provider,
source: SearchProviderSource::Config,
};
}
SearchProviderResolution {
provider: SearchProvider::default(),
source: SearchProviderSource::Default,
}
}
#[must_use]
pub fn search_provider(&self) -> SearchProvider {
self.search_provider_resolution().provider
}
#[must_use]
pub fn auto_cost_saving(&self) -> bool {
self.auto
.as_ref()
.and_then(|a| a.cost_saving)
.unwrap_or(false)
}
#[must_use]
pub fn tools_always_load(&self) -> std::collections::HashSet<String> {
self.tools
.as_ref()
.map(|tools| {
tools
.always_load
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn auto_review_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
self.auto_review
.as_ref()
.map(AutoReviewConfig::to_runtime_policy)
.unwrap_or_default()
}
pub fn load(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
let path = resolve_load_config_path(path);
let mut config = if let Some(path) = path.as_ref() {
if path.exists() {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let parsed: ConfigFile = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
if let Some(msg) = warn_on_misplaced_top_level_keys(&contents) {
tracing::warn!("{msg}");
}
apply_profile(parsed, profile)?
} else {
Config::default()
}
} else {
Config::default()
};
apply_env_overrides(&mut config);
apply_managed_overrides(&mut config)?;
apply_requirements(&mut config)?;
normalize_model_config(&mut config);
config.exec_policy_engine = load_sibling_exec_policy_engine(path.as_deref())?;
config.validate()?;
config.warn_on_misplaced_root_base_url();
Ok(config)
}
fn warn_on_misplaced_root_base_url(&self) {
let Some(root_base) = self.base_url.as_deref().map(str::trim) else {
return;
};
if root_base.is_empty() {
return;
}
let provider = self.api_provider();
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return;
}
if matches!(provider, ApiProvider::NvidiaNim)
&& root_base.contains("integrate.api.nvidia.com")
{
return;
}
let has_provider_base = self
.provider_config_for(provider)
.and_then(|p| p.base_url.as_deref().map(str::trim))
.is_some_and(|s| !s.is_empty());
if has_provider_base {
return;
}
let Ok(table) = provider_config_table_name(provider) else {
return;
};
tracing::warn!(
"Top-level `base_url = \"{root_base}\"` is ignored for the {provider:?} provider. \
Move it under `[{table}]` (e.g. `[{table}]\\nbase_url = \"...\"`) \
or set the corresponding `*_BASE_URL` env var. (#1308)"
);
}
pub fn validate(&self) -> Result<()> {
if let Some(provider) = self.provider.as_deref()
&& ApiProvider::parse(provider).is_none()
{
anyhow::bail!(
"Invalid provider '{provider}': expected {}.",
ApiProvider::names_hint()
);
}
if let Some(ref key) = self.api_key
&& key.trim().is_empty()
{
anyhow::bail!("api_key cannot be empty string");
}
if let Some(features) = &self.features {
for key in features.entries.keys() {
if !is_known_feature_key(key) {
anyhow::bail!("Unknown feature flag: {key}");
}
}
}
if let Some(model) = self.default_text_model.as_deref()
&& !model.trim().eq_ignore_ascii_case("auto")
&& !provider_passes_model_through(self.api_provider())
&& !self.active_provider_preserves_custom_base_url_model()
&& normalize_model_name(model).is_none()
{
anyhow::bail!(
"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro)."
);
}
if let Some(policy) = self.approval_policy.as_deref() {
let normalized = policy.trim().to_ascii_lowercase();
if !matches!(
normalized.as_str(),
"on-request" | "untrusted" | "never" | "auto" | "suggest"
) {
anyhow::bail!(
"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
);
}
}
if let Some(v) = self.verbosity.as_deref() {
let normalized = v.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "normal" | "concise") {
anyhow::bail!("Invalid verbosity '{v}': expected normal or concise.");
}
}
if let Some(mode) = self.sandbox_mode.as_deref() {
let normalized = mode.trim().to_ascii_lowercase();
if !matches!(
normalized.as_str(),
"read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
) {
anyhow::bail!(
"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
);
}
}
if let Some(tui) = &self.tui
&& let Some(mode) = tui.alternate_screen.as_deref()
{
let mode = mode.to_ascii_lowercase();
if !matches!(mode.as_str(), "auto" | "always" | "never") {
anyhow::bail!(
"Invalid tui.alternate_screen '{mode}': expected auto, always, or never."
);
}
}
if let Some(auto_review) = &self.auto_review {
auto_review.validate()?;
}
Ok(())
}
#[must_use]
pub fn api_provider(&self) -> ApiProvider {
self.provider
.as_deref()
.and_then(ApiProvider::parse)
.unwrap_or_else(|| {
self.base_url
.as_deref()
.filter(|base| base.contains("integrate.api.nvidia.com"))
.map(|_| ApiProvider::NvidiaNim)
.or_else(|| {
self.base_url
.as_deref()
.filter(|base| base.contains("api.deepseeki.com"))
.map(|_| ApiProvider::DeepseekCN)
})
.unwrap_or(ApiProvider::Deepseek)
})
}
pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
let providers = self.providers.as_ref()?;
Some(match provider {
ApiProvider::Deepseek => &providers.deepseek,
ApiProvider::DeepseekCN => &providers.deepseek_cn,
ApiProvider::NvidiaNim => &providers.nvidia_nim,
ApiProvider::Openai => &providers.openai,
ApiProvider::Atlascloud => &providers.atlascloud,
ApiProvider::WanjieArk => &providers.wanjie_ark,
ApiProvider::Openrouter => &providers.openrouter,
ApiProvider::XiaomiMimo => &providers.xiaomi_mimo,
ApiProvider::Novita => &providers.novita,
ApiProvider::Fireworks => &providers.fireworks,
ApiProvider::Siliconflow => &providers.siliconflow,
ApiProvider::SiliconflowCn => &providers.siliconflow_cn,
ApiProvider::Arcee => &providers.arcee,
ApiProvider::Moonshot => &providers.moonshot,
ApiProvider::Sglang => &providers.sglang,
ApiProvider::Vllm => &providers.vllm,
ApiProvider::Ollama => &providers.ollama,
ApiProvider::Volcengine => &providers.volcengine,
ApiProvider::Huggingface => &providers.huggingface,
ApiProvider::Deepinfra => &providers.deepinfra,
ApiProvider::Together => &providers.together,
ApiProvider::OpenaiCodex => &providers.openai_codex,
ApiProvider::Anthropic => &providers.anthropic,
ApiProvider::Zai => &providers.zai,
ApiProvider::Stepfun => &providers.stepfun,
ApiProvider::Minimax => &providers.minimax,
})
}
pub(crate) fn subagent_provider_config(
&self,
provider: ApiProvider,
) -> Option<&SubagentProviderConfig> {
let providers = self.subagents.as_ref()?.providers.as_ref()?;
providers.iter().find_map(|(key, config)| {
subagent_provider_key_matches(key, provider).then_some(config)
})
}
pub(crate) fn provider_config_for_mut(&mut self, provider: ApiProvider) -> &mut ProviderConfig {
let providers = self.providers.get_or_insert_with(ProvidersConfig::default);
match provider {
ApiProvider::Deepseek => &mut providers.deepseek,
ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Atlascloud => &mut providers.atlascloud,
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Siliconflow => &mut providers.siliconflow,
ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
ApiProvider::Arcee => &mut providers.arcee,
ApiProvider::Moonshot => &mut providers.moonshot,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
ApiProvider::Volcengine => &mut providers.volcengine,
ApiProvider::Huggingface => &mut providers.huggingface,
ApiProvider::Deepinfra => &mut providers.deepinfra,
ApiProvider::Together => &mut providers.together,
ApiProvider::OpenaiCodex => &mut providers.openai_codex,
ApiProvider::Anthropic => &mut providers.anthropic,
ApiProvider::Zai => &mut providers.zai,
ApiProvider::Stepfun => &mut providers.stepfun,
ApiProvider::Minimax => &mut providers.minimax,
}
}
pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {
self.provider_config_for(self.api_provider())
}
fn provider_config_string_with_runtime_fallback<F>(
&self,
provider: ApiProvider,
get: F,
) -> Option<String>
where
F: Fn(&ProviderConfig) -> Option<String>,
{
if let Some(value) = self.provider_config_for(provider).and_then(&get) {
return Some(value);
}
if provider == ApiProvider::SiliconflowCn {
return self
.provider_config_for(ApiProvider::Siliconflow)
.and_then(get);
}
None
}
#[must_use]
pub fn insecure_skip_tls_verify(&self) -> bool {
self.provider_config()
.and_then(|provider| provider.insecure_skip_tls_verify)
.unwrap_or(false)
}
#[must_use]
pub fn http_headers(&self) -> HashMap<String, String> {
let mut headers = self.http_headers.clone().unwrap_or_default();
if let Some(provider_headers) = self
.provider_config()
.and_then(|provider| provider.http_headers.as_ref())
{
headers.extend(provider_headers.clone());
}
headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
headers
}
#[must_use]
pub fn default_model(&self) -> String {
let provider = self.api_provider();
if let Some(model) =
self.provider_config_string_with_runtime_fallback(provider, |entry| entry.model.clone())
{
let model = model.trim();
if provider_passes_model_through(provider)
|| self.active_provider_preserves_custom_base_url_model()
{
return model.to_string();
}
if let Some(normalized) = normalize_model_for_provider(provider, model) {
return normalized;
}
if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& !model.is_empty()
{
return model.to_string();
}
}
if provider == ApiProvider::OpenaiCodex {
return DEFAULT_OPENAI_CODEX_MODEL.to_string();
}
let moonshot_config = (provider == ApiProvider::Moonshot)
.then(|| self.provider_config())
.flatten();
let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| {
provider_config_uses_kimi_oauth(config)
|| config
.base_url
.as_deref()
.is_some_and(moonshot_base_url_uses_kimi_code)
});
if moonshot_uses_kimi_code {
return DEFAULT_KIMI_CODE_MODEL.to_string();
}
if let Some(model) = self.default_text_model.as_deref()
&& model.trim().eq_ignore_ascii_case("auto")
{
return "auto".to_string();
}
if provider == ApiProvider::XiaomiMimo
&& let Some(model) = self.default_text_model.as_deref()
&& let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
{
return canonical.to_string();
}
if provider == ApiProvider::XiaomiMimo {
return DEFAULT_XIAOMI_MIMO_MODEL.to_string();
}
if let Some(model) = self.default_text_model.as_deref()
&& (provider_passes_model_through(provider)
|| self.active_provider_preserves_custom_base_url_model())
{
return model.trim().to_string();
}
if let Some(model) = self.default_text_model.as_deref()
&& !root_deepseek_model_is_foreign_to_direct_provider(provider, model)
&& let Some(normalized) = normalize_model_name_for_provider(provider, model)
{
return normalized;
}
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
ApiProvider::Openai => DEFAULT_OPENAI_MODEL,
ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,
ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
ApiProvider::Novita => DEFAULT_NOVITA_MODEL,
ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_MODEL,
ApiProvider::Arcee => DEFAULT_ARCEE_MODEL,
ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL,
ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,
ApiProvider::Vllm => DEFAULT_VLLM_MODEL,
ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,
ApiProvider::Volcengine => DEFAULT_VOLCENGINE_MODEL,
ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
ApiProvider::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
ApiProvider::Together => DEFAULT_TOGETHER_MODEL,
ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
ApiProvider::Zai => DEFAULT_ZAI_MODEL,
ApiProvider::Stepfun => DEFAULT_STEPFUN_MODEL,
ApiProvider::Anthropic => DEFAULT_ANTHROPIC_MODEL,
ApiProvider::Minimax => DEFAULT_MINIMAX_MODEL,
}
.to_string()
}
#[must_use]
pub fn deepseek_base_url(&self) -> String {
let provider = self.api_provider();
let provider_base = self
.provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
let root_base = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),
ApiProvider::NvidiaNim => self
.base_url
.as_ref()
.filter(|base| base.contains("integrate.api.nvidia.com"))
.cloned(),
ApiProvider::Openai
| ApiProvider::Anthropic
| ApiProvider::Atlascloud
| ApiProvider::WanjieArk
| ApiProvider::Openrouter
| ApiProvider::XiaomiMimo
| ApiProvider::Novita
| ApiProvider::Fireworks
| ApiProvider::Siliconflow
| ApiProvider::SiliconflowCn
| ApiProvider::Arcee
| ApiProvider::Moonshot
| ApiProvider::Sglang
| ApiProvider::Vllm
| ApiProvider::Ollama
| ApiProvider::Volcengine
| ApiProvider::Huggingface
| ApiProvider::Deepinfra
| ApiProvider::Together
| ApiProvider::OpenaiCodex
| ApiProvider::Zai
| ApiProvider::Stepfun
| ApiProvider::Minimax => None,
};
let configured_base_url = provider_base.or(root_base);
let base = if provider == ApiProvider::XiaomiMimo {
let config_api_key = self
.provider_config_for(provider)
.and_then(|provider| provider.api_key.as_deref());
let mode = self
.provider_config_for(provider)
.and_then(|provider| provider.mode.as_deref());
let env_api_key =
xiaomi_mimo_env_api_key_for_runtime(mode, configured_base_url.as_deref());
let api_key = config_api_key.or(env_api_key.as_deref());
resolve_xiaomi_mimo_base_url(configured_base_url, api_key, mode)
} else {
configured_base_url
.or_else(env_base_url_override)
.unwrap_or_else(|| {
match provider {
ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,
ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL,
ApiProvider::Moonshot => {
if self
.provider_config()
.is_some_and(provider_config_uses_kimi_oauth)
{
DEFAULT_KIMI_CODE_BASE_URL
} else {
DEFAULT_MOONSHOT_BASE_URL
}
}
ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,
ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,
ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
ApiProvider::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
ApiProvider::Together => DEFAULT_TOGETHER_BASE_URL,
ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
ApiProvider::Zai => DEFAULT_ZAI_BASE_URL,
ApiProvider::Stepfun => DEFAULT_STEPFUN_BASE_URL,
ApiProvider::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
ApiProvider::Minimax => DEFAULT_MINIMAX_BASE_URL,
}
.to_string()
})
};
normalize_base_url(&base)
}
fn active_provider_preserves_custom_base_url_model(&self) -> bool {
let provider = self.api_provider();
provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())
}
pub(crate) fn model_ids_pass_through(&self) -> bool {
let provider = self.api_provider();
provider_passes_model_through(provider)
|| self.active_provider_preserves_custom_base_url_model()
}
pub fn deepseek_api_key(&self) -> Result<String> {
let provider = self.api_provider();
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& std::env::var("DEEPSEEK_API_KEY_SOURCE").as_deref() == Ok("cli")
&& let Some(env_key) = provider_env_api_key(provider)
&& !env_key.trim().is_empty()
{
return Ok(env_key);
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& let Some(configured) = self.api_key.as_ref()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
{
return Ok(configured.clone());
}
if provider == ApiProvider::Moonshot
&& self
.provider_config_for(provider)
.is_some_and(provider_config_uses_kimi_oauth)
{
return kimi_cli_oauth_access_token();
}
if provider == ApiProvider::OpenaiCodex {
return Ok(crate::oauth::get_credentials()?.access_token);
}
if let Some(configured) = self
.provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
&& !configured.trim().is_empty()
{
return Ok(configured);
}
if provider == ApiProvider::XiaomiMimo {
let mode = self
.provider_config_for(provider)
.and_then(|provider| provider.mode.as_deref());
if let Some(value) =
xiaomi_mimo_env_api_key_for_runtime(mode, Some(&self.deepseek_base_url()))
&& !value.trim().is_empty()
{
return Ok(value);
}
}
if let Some(value) = provider_env_api_key(provider) {
return Ok(value);
}
if base_url_uses_local_host(&self.deepseek_base_url()) {
return Ok(String::new());
}
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
"DeepSeek API key not found.\n\
\n\
1. Get a key: https://platform.deepseek.com/api_keys\n\
2. Save it (works in every folder, no OS prompts):\n\
codewhale auth set --provider deepseek\n\
\n\
Alternatives:\n\
• export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
also note: zsh users — exports in ~/.zshrc only reach interactive\n\
shells, prefer ~/.zshenv for everything)\n\
• api_key = \"<your-key>\" in ~/.codewhale/config.toml"
),
ApiProvider::SiliconflowCn => anyhow::bail!(
"SiliconFlow China API key not found. Run 'codewhale auth set --provider siliconflow-CN', \
set {}, or add [{}] api_key in ~/.codewhale/config.toml. \
[providers.siliconflow] remains a fallback when the CN table omits api_key.",
provider.env_vars_label(),
provider_config_table_name(provider)?
),
ApiProvider::Moonshot => anyhow::bail!(
"Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \
set {}, or add [{}] api_key. \
For a Kimi Code plan key, set [providers.moonshot] base_url = \
\"https://api.kimi.com/coding/v1\" and model = \"kimi-for-coding\".",
provider.env_vars_label(),
provider_config_table_name(provider)?
),
ApiProvider::Anthropic => anyhow::bail!(
"{} Keys are created at https://platform.claude.com/.",
missing_provider_api_key_message(provider)?
),
ApiProvider::OpenaiCodex => anyhow::bail!(
"OpenAI Codex OAuth credentials not found.\n\
\n\
CodeWhale uses your existing ChatGPT/Codex login.\n\
1. Run: codex login (or use the Codex CLI to authenticate)\n\
2. CodeWhale will read credentials from ~/.codex/auth.json\n\
\n\
Env overrides:\n\
OPENAI_CODEX_ACCESS_TOKEN or CODEX_ACCESS_TOKEN"
),
ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),
_ => anyhow::bail!("{}", missing_provider_api_key_message(provider)?),
}
}
#[must_use]
pub fn skills_dir(&self) -> PathBuf {
self.skills_dir
.as_deref()
.map(expand_path)
.or_else(default_skills_dir)
.unwrap_or_else(|| PathBuf::from("./skills"))
}
#[must_use]
pub fn mcp_config_path(&self) -> PathBuf {
self.mcp_config_path
.as_deref()
.map(expand_path)
.or_else(default_mcp_config_path)
.unwrap_or_else(|| PathBuf::from("./mcp.json"))
}
#[must_use]
pub fn notes_path(&self) -> PathBuf {
self.notes_path
.as_deref()
.map(expand_path)
.or_else(default_notes_path)
.unwrap_or_else(|| PathBuf::from("./notes.txt"))
}
#[must_use]
pub fn memory_path(&self) -> PathBuf {
self.memory_path
.as_deref()
.map(expand_path)
.or_else(default_memory_path)
.unwrap_or_else(|| PathBuf::from("./memory.md"))
}
#[must_use]
pub fn speech_output_dir(&self) -> Option<PathBuf> {
std::env::var("XIAOMI_MIMO_SPEECH_OUTPUT_DIR")
.or_else(|_| std::env::var("MIMO_SPEECH_OUTPUT_DIR"))
.or_else(|_| std::env::var("XIAOMIMIMO_SPEECH_OUTPUT_DIR"))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| expand_path(&value))
.or_else(|| {
self.speech
.as_ref()
.and_then(|speech| speech.output_dir.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(expand_path)
})
}
#[must_use]
pub fn instructions_paths(&self) -> Vec<PathBuf> {
self.instructions
.as_deref()
.unwrap_or(&[])
.iter()
.map(String::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(expand_path)
.collect()
}
#[must_use]
pub fn memory_enabled(&self) -> bool {
self.memory
.as_ref()
.and_then(|m| m.enabled)
.unwrap_or(false)
}
#[must_use]
pub fn vision_model_config(&self) -> Option<VisionModelConfig> {
let mut config = self.vision_model.clone()?;
if config.api_key.is_none() {
config.api_key = self.api_key.clone();
}
Some(config)
}
#[must_use]
pub fn project_context_pack_enabled(&self) -> bool {
self.context.project_pack.unwrap_or(true)
}
#[must_use]
pub fn allow_shell(&self) -> bool {
self.allow_shell.unwrap_or(false)
}
pub fn prompt_suggestion_enabled(&self) -> bool {
self.prompt_suggestion.unwrap_or(false)
}
#[must_use]
pub fn max_subagents(&self) -> usize {
if let Some(subagents_cfg) = self.subagents.as_ref()
&& let Some(max) = subagents_cfg.max_concurrent
{
return max.clamp(1, MAX_SUBAGENTS);
}
self.max_subagents
.unwrap_or(DEFAULT_MAX_SUBAGENTS)
.clamp(1, MAX_SUBAGENTS)
}
#[must_use]
pub fn max_subagents_for_provider(&self, provider: ApiProvider) -> usize {
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.max_concurrent)
.map(|max| max.clamp(1, MAX_SUBAGENTS))
.unwrap_or_else(|| self.max_subagents())
}
#[must_use]
pub fn subagents_enabled(&self) -> bool {
self.subagents_disabled_reason().is_none()
}
#[must_use]
pub fn subagents_enabled_for_provider(&self, provider: ApiProvider) -> bool {
if !self.subagents_enabled() {
return false;
}
let Some(provider_cfg) = self.subagent_provider_config(provider) else {
return true;
};
provider_cfg.enabled != Some(false)
&& provider_cfg.max_concurrent != Some(0)
&& provider_cfg.max_depth != Some(0)
}
#[must_use]
pub fn subagents_disabled_reason(&self) -> Option<&'static str> {
if !self.features().enabled(Feature::Subagents) {
return Some("features.subagents=false");
}
let subagents_cfg = self.subagents.as_ref()?;
if subagents_cfg.enabled == Some(false) {
return Some("subagents.enabled=false");
}
if subagents_cfg.max_concurrent == Some(0) {
return Some("subagents.max_concurrent=0");
}
if subagents_cfg.max_depth == Some(0) {
return Some("subagents.max_depth=0");
}
None
}
#[must_use]
pub fn subagent_max_spawn_depth(&self) -> u32 {
self.subagents
.as_ref()
.and_then(|cfg| cfg.max_depth)
.unwrap_or(codewhale_config::DEFAULT_SPAWN_DEPTH)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
}
#[must_use]
pub fn subagent_max_spawn_depth_for_provider(&self, provider: ApiProvider) -> u32 {
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.max_depth)
.unwrap_or_else(|| self.subagent_max_spawn_depth())
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
}
#[must_use]
pub fn launch_concurrency(&self) -> usize {
let max = self.max_subagents();
self.subagents
.as_ref()
.and_then(|cfg| cfg.launch_concurrency.or(cfg.interactive_max_launch_legacy))
.unwrap_or(max)
.clamp(1, max)
}
#[must_use]
pub fn launch_concurrency_for_provider(&self, provider: ApiProvider) -> usize {
let max = self.max_subagents_for_provider(provider);
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.launch_concurrency)
.or_else(|| {
self.subagents
.as_ref()
.and_then(|cfg| cfg.launch_concurrency.or(cfg.interactive_max_launch_legacy))
})
.unwrap_or(max)
.clamp(1, max)
}
#[must_use]
pub fn max_admitted_subagents(&self) -> usize {
let max_concurrent = self.max_subagents();
self.subagents
.as_ref()
.and_then(|cfg| cfg.max_admitted)
.unwrap_or(MAX_SUBAGENT_ADMISSION)
.clamp(max_concurrent, MAX_SUBAGENT_ADMISSION)
}
#[must_use]
pub fn max_admitted_subagents_for_provider(&self, provider: ApiProvider) -> usize {
let max_concurrent = self.max_subagents_for_provider(provider);
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.max_admitted)
.or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.max_admitted))
.unwrap_or(MAX_SUBAGENT_ADMISSION)
.clamp(max_concurrent, MAX_SUBAGENT_ADMISSION)
}
#[must_use]
pub fn subagent_token_budget(&self) -> Option<u64> {
self.subagents
.as_ref()
.and_then(|cfg| cfg.token_budget)
.filter(|budget| *budget > 0)
}
#[must_use]
pub fn subagent_token_budget_for_provider(&self, provider: ApiProvider) -> Option<u64> {
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.token_budget)
.or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.token_budget))
.filter(|budget| *budget > 0)
}
#[must_use]
pub fn subagent_api_timeout_secs(&self) -> u64 {
resolve_subagent_api_timeout_secs(
self.subagents.as_ref().and_then(|cfg| cfg.api_timeout_secs),
)
}
#[must_use]
pub fn subagent_api_timeout_secs_for_provider(&self, provider: ApiProvider) -> u64 {
resolve_subagent_api_timeout_secs(
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.api_timeout_secs)
.or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.api_timeout_secs)),
)
}
#[must_use]
pub fn subagent_heartbeat_timeout_secs(&self) -> u64 {
resolve_subagent_heartbeat_timeout_secs(
self.subagents
.as_ref()
.and_then(|cfg| cfg.heartbeat_timeout_secs),
self.subagent_api_timeout_secs(),
)
}
#[must_use]
pub fn subagent_heartbeat_timeout_secs_for_provider(&self, provider: ApiProvider) -> u64 {
let api_timeout = self.subagent_api_timeout_secs_for_provider(provider);
resolve_subagent_heartbeat_timeout_secs(
self.subagent_provider_config(provider)
.and_then(|cfg| cfg.heartbeat_timeout_secs)
.or_else(|| {
self.subagents
.as_ref()
.and_then(|cfg| cfg.heartbeat_timeout_secs)
}),
api_timeout,
)
}
#[must_use]
pub fn stream_chunk_timeout_secs(&self) -> u64 {
let raw = self
.tui
.as_ref()
.and_then(|cfg| cfg.stream_chunk_timeout_secs)
.or_else(|| {
std::env::var(STREAM_CHUNK_TIMEOUT_ENV)
.ok()
.and_then(|value| value.parse::<u64>().ok())
})
.unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
if raw == 0 {
return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS;
}
raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
}
#[must_use]
pub fn subagent_model_overrides(&self) -> HashMap<String, String> {
let mut overrides = HashMap::new();
let Some(cfg) = self.subagents.as_ref() else {
return overrides;
};
let mut insert = |key: &str, value: &Option<String>| {
if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
overrides.insert(key.to_string(), model.to_string());
}
};
insert("default", &cfg.default_model);
insert("worker", &cfg.worker_model);
insert("general", &cfg.worker_model);
insert("explorer", &cfg.explorer_model);
insert("explore", &cfg.explorer_model);
insert("awaiter", &cfg.awaiter_model);
insert("plan", &cfg.awaiter_model);
insert("review", &cfg.review_model);
insert("custom", &cfg.custom_model);
if let Some(models) = cfg.models.as_ref() {
for (key, model) in models {
let key = key.trim();
let model = model.trim();
if !key.is_empty() && !model.is_empty() {
overrides.insert(key.to_ascii_lowercase(), model.to_string());
}
}
}
overrides
}
#[must_use]
pub fn reasoning_effort(&self) -> Option<&str> {
self.reasoning_effort.as_deref()
}
pub fn hooks_config(&self) -> HooksConfig {
self.hooks.clone().unwrap_or_default()
}
#[must_use]
pub fn notifications_config(&self) -> NotificationsConfig {
self.notifications.clone().unwrap_or_default()
}
#[must_use]
pub fn snapshots_config(&self) -> SnapshotsConfig {
self.snapshots.clone().unwrap_or_default()
}
#[must_use]
pub fn skills_config(&self) -> SkillsConfig {
self.skills.clone().unwrap_or_default()
}
#[must_use]
pub fn update_config(&self) -> UpdateConfig {
self.update.clone().unwrap_or_default()
}
#[must_use]
pub fn resolve_hotbar_bindings(
&self,
known_action_ids: &[&str],
) -> codewhale_config::HotbarConfigResolution {
codewhale_config::resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
}
#[must_use]
pub fn features(&self) -> Features {
let mut features = Features::with_defaults();
if let Some(table) = &self.features {
features.apply_map(&table.entries);
}
features
}
pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {
if !is_known_feature_key(key) {
anyhow::bail!("Unknown feature flag: {key}");
}
let table = self.features.get_or_insert_with(FeaturesToml::default);
table.entries.insert(key.to_string(), enabled);
Ok(())
}
#[must_use]
pub fn retry_policy(&self) -> RetryPolicy {
let defaults = RetryPolicy {
enabled: true,
max_retries: 3,
initial_delay: 1.0,
max_delay: 60.0,
exponential_base: 2.0,
};
let Some(cfg) = &self.retry else {
return defaults;
};
RetryPolicy {
enabled: cfg.enabled.unwrap_or(defaults.enabled),
max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),
initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),
max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),
exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),
}
}
}
fn root_deepseek_model_is_foreign_to_direct_provider(provider: ApiProvider, model: &str) -> bool {
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|| provider_passes_model_through(provider)
{
return false;
}
if matches!(
provider,
ApiProvider::NvidiaNim
| ApiProvider::Openrouter
| ApiProvider::Novita
| ApiProvider::Fireworks
| ApiProvider::Siliconflow
| ApiProvider::SiliconflowCn
| ApiProvider::Deepinfra
| ApiProvider::Sglang
| ApiProvider::Vllm
| ApiProvider::Volcengine
| ApiProvider::Atlascloud
| ApiProvider::WanjieArk
) {
return false;
}
normalize_model_name(model).is_some()
}
fn default_config_path() -> Option<PathBuf> {
env_config_path().or_else(home_config_path)
}
fn codewhale_home_dir() -> Option<PathBuf> {
std::env::var_os("CODEWHALE_HOME").and_then(|path| {
let path = PathBuf::from(path);
(!path.as_os_str().is_empty()).then_some(path)
})
}
pub(crate) fn effective_home_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("HOME") {
let path = PathBuf::from(path);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
if let Some(path) = std::env::var_os("USERPROFILE") {
let path = PathBuf::from(path);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
#[cfg(windows)]
{
if let (Some(drive), Some(homepath)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut path = PathBuf::from(drive);
path.push(homepath);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
}
dirs::home_dir()
}
fn home_config_path() -> Option<PathBuf> {
if let Some(home) = codewhale_home_dir() {
return Some(home.join("config.toml"));
}
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("config.toml");
if primary.exists() {
return primary;
}
let legacy = home.join(".deepseek").join("config.toml");
if legacy.exists() {
return legacy;
}
primary
})
}
pub(crate) fn workspace_trust_config_candidate_paths() -> Vec<PathBuf> {
if let Some(path) = env_config_path() {
return vec![path];
}
if let Some(codewhale_home) = codewhale_home_dir() {
return vec![codewhale_home.join("config.toml")];
}
let Some(home) = effective_home_dir() else {
return Vec::new();
};
vec![
home.join(".codewhale").join("config.toml"),
home.join(".deepseek").join("config.toml"),
]
}
#[must_use]
pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {
let Some(config_path) = default_config_path() else {
return false;
};
let Ok(raw) = fs::read_to_string(config_path) else {
return false;
};
let Ok(doc) = toml::from_str::<toml::Value>(&raw) else {
return false;
};
workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)
}
pub(crate) fn save_workspace_trust(workspace: &Path) -> Result<PathBuf> {
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let mut doc = if config_path.exists() {
let raw = fs::read_to_string(&config_path)?;
toml::from_str::<toml::Value>(&raw)
.with_context(|| format!("Failed to parse config at {}", config_path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let root = doc
.as_table_mut()
.context("Config root must be a TOML table.")?;
let projects = root
.entry("projects".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.context("`projects` must be a table.")?;
let project = projects
.entry(workspace_config_key(workspace))
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.context("Project entry must be a table.")?;
project.insert(
"trust_level".to_string(),
toml::Value::String("trusted".to_string()),
);
let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
write_config_file_secure(&config_path, &serialized)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
Ok(config_path)
}
fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {
let workspace = canonicalize_or_keep(workspace);
let projects = doc.get("projects")?.as_table()?;
for (raw_path, project) in projects {
let project_path = canonicalize_or_keep(&expand_path(raw_path));
if project_path == workspace {
return project.get("trust_level").and_then(toml::Value::as_str);
}
}
None
}
fn is_trusted_level(level: &str) -> bool {
level.trim().eq_ignore_ascii_case("trusted")
}
fn workspace_config_key(workspace: &Path) -> String {
canonicalize_or_keep(workspace)
.to_string_lossy()
.into_owned()
}
fn canonicalize_or_keep(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn env_config_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(expand_path(trimmed));
}
}
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(expand_path(trimmed));
}
}
None
}
fn expand_pathbuf(path: PathBuf) -> PathBuf {
if let Some(raw) = path.to_str() {
return expand_path(raw);
}
path
}
pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
if let Some(path) = path {
return Some(expand_pathbuf(path));
}
if let Some(path) = env_config_path() {
if path.exists() {
return Some(path);
}
if let Some(home_path) = home_config_path()
&& home_path.exists()
{
return Some(home_path);
}
return Some(path);
}
home_config_path()
}
pub fn ensure_config_file_exists(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
let config_path = path
.map(expand_pathbuf)
.or_else(default_config_path)
.context("Failed to resolve config path: home directory not found.")?;
if config_path.exists() {
return Ok(None);
}
ensure_parent_dir(&config_path)?;
let content = format!(
r#"# codewhale Configuration
# Get your API key from https://platform.deepseek.com
# Save it with: codewhale auth set --provider deepseek
# Base URL (default: https://api.deepseek.com/beta)
# Set https://api.deepseek.com to opt out of beta features.
# base_url = "https://api.deepseek.com/beta"
# Default model
default_text_model = "{DEFAULT_TEXT_MODEL}"
# Thinking mode (DeepSeek V4 reasoning effort):
# "auto" | "off" | "low" | "medium" | "high" | "max"
# Shift+Tab in the TUI cycles between off / high / max.
reasoning_effort = "auto"
# Startup update check
[update]
check_for_updates = true
# update_uri = "https://internal.mirror.example/codewhale/releases/latest"
"#
);
write_config_file_secure(&config_path, &content)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
Ok(Some(config_path))
}
fn default_managed_config_path() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/etc/deepseek/managed_config.toml"))
}
#[cfg(not(unix))]
{
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("managed_config.toml");
if primary.exists() {
return primary;
}
home.join(".deepseek").join("managed_config.toml")
})
}
}
fn default_requirements_path() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/etc/deepseek/requirements.toml"))
}
#[cfg(not(unix))]
{
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("requirements.toml");
if primary.exists() {
return primary;
}
home.join(".deepseek").join("requirements.toml")
})
}
}
pub(crate) fn expand_path(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix('~')
&& (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
&& let Some(mut home) = effective_home_dir()
{
let suffix = stripped.trim_start_matches(['/', '\\']);
if !suffix.is_empty() {
home.push(suffix);
}
return home;
}
let expanded = shellexpand::tilde(path);
PathBuf::from(expanded.as_ref())
}
fn default_skills_dir() -> Option<PathBuf> {
effective_home_dir().map(|home| home.join(".codewhale").join("skills"))
}
fn default_mcp_config_path() -> Option<PathBuf> {
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("mcp.json");
if primary.exists() {
return primary;
}
let legacy = home.join(".deepseek").join("mcp.json");
if legacy.exists() {
return legacy;
}
primary
})
}
fn default_notes_path() -> Option<PathBuf> {
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("notes.txt");
if primary.exists() {
return primary;
}
let legacy = home.join(".deepseek").join("notes.txt");
if legacy.exists() {
return legacy;
}
primary
})
}
fn default_memory_path() -> Option<PathBuf> {
effective_home_dir().map(|home| {
let primary = home.join(".codewhale").join("memory.md");
if primary.exists() {
return primary;
}
let legacy = home.join(".deepseek").join("memory.md");
if legacy.exists() {
return legacy;
}
primary
})
}
fn env_base_url_override() -> Option<String> {
codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}
fn codewhale_env_var(
codewhale_name: &str,
legacy_name: &str,
) -> Result<String, std::env::VarError> {
std::env::var(codewhale_name)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var(legacy_name)
.ok()
.filter(|value| !value.trim().is_empty())
})
.ok_or(std::env::VarError::NotPresent)
}
fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER") {
config.provider = Some(value);
}
if let Ok(value) = codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL") {
match config.api_provider() {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
config.base_url = Some(value);
}
ApiProvider::NvidiaNim => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.nvidia_nim
.base_url = Some(value);
}
ApiProvider::Openai => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openai
.base_url = Some(value);
}
ApiProvider::Anthropic => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.anthropic
.base_url = Some(value);
}
ApiProvider::Openrouter => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openrouter
.base_url = Some(value);
}
ApiProvider::XiaomiMimo => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.xiaomi_mimo
.base_url = Some(value);
}
ApiProvider::WanjieArk => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.wanjie_ark
.base_url = Some(value);
}
ApiProvider::Novita => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.novita
.base_url = Some(value);
}
ApiProvider::Fireworks => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.fireworks
.base_url = Some(value);
}
ApiProvider::Siliconflow => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.siliconflow
.base_url = Some(value);
}
ApiProvider::SiliconflowCn => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.siliconflow_cn
.base_url = Some(value);
}
ApiProvider::Arcee => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.arcee
.base_url = Some(value);
}
ApiProvider::Moonshot => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.moonshot
.base_url = Some(value);
}
ApiProvider::Sglang => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.sglang
.base_url = Some(value);
}
ApiProvider::Vllm => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.vllm
.base_url = Some(value);
}
ApiProvider::Ollama => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.ollama
.base_url = Some(value);
}
ApiProvider::Volcengine => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.volcengine
.base_url = Some(value);
}
ApiProvider::Atlascloud => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.atlascloud
.base_url = Some(value);
}
ApiProvider::Huggingface => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.huggingface
.base_url = Some(value);
}
ApiProvider::Deepinfra => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.deepinfra
.base_url = Some(value);
}
ApiProvider::Together => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.together
.base_url = Some(value);
}
ApiProvider::OpenaiCodex => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openai_codex
.base_url = Some(value);
}
ApiProvider::Zai => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.zai
.base_url = Some(value);
}
ApiProvider::Stepfun => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.stepfun
.base_url = Some(value);
}
ApiProvider::Minimax => {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.minimax
.base_url = Some(value);
}
}
}
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
&& let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.nvidia_nim
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Openai)
&& let Ok(value) = std::env::var("OPENAI_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openai
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Atlascloud)
&& let Ok(value) = std::env::var("ATLASCLOUD_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.atlascloud
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Openrouter)
&& let Ok(value) = std::env::var("OPENROUTER_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openrouter
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
&& let Ok(value) =
std::env::var("XIAOMI_MIMO_BASE_URL").or_else(|_| std::env::var("MIMO_BASE_URL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.xiaomi_mimo
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
&& let Ok(value) = std::env::var("XIAOMI_MIMO_MODE").or_else(|_| std::env::var("MIMO_MODE"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.xiaomi_mimo
.mode = Some(value);
}
if matches!(config.api_provider(), ApiProvider::WanjieArk)
&& let Ok(value) = std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.wanjie_ark
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Volcengine)
&& let Ok(value) = std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.volcengine
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Novita)
&& let Ok(value) = std::env::var("NOVITA_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.novita
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Fireworks)
&& let Ok(value) = std::env::var("FIREWORKS_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.fireworks
.base_url = Some(value);
}
let active_provider = config.api_provider();
if matches!(
active_provider,
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
) && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL")
&& !value.trim().is_empty()
{
config.provider_config_for_mut(active_provider).base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Arcee)
&& let Ok(value) = std::env::var("ARCEE_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.arcee
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Huggingface)
&& let Ok(value) =
std::env::var("HUGGINGFACE_BASE_URL").or_else(|_| std::env::var("HF_BASE_URL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.huggingface
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Moonshot)
&& let Ok(value) =
std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.moonshot
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Sglang)
&& let Ok(value) = std::env::var("SGLANG_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.sglang
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Vllm)
&& let Ok(value) = std::env::var("VLLM_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.vllm
.base_url = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_HTTP_HEADERS")
&& let Ok(headers) = parse_http_headers(&value)
&& !headers.is_empty()
{
let mut root_headers = config.http_headers.clone().unwrap_or_default();
root_headers.extend(headers.clone());
config.http_headers = Some(root_headers);
let provider = config.api_provider();
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry = match provider {
ApiProvider::Deepseek => &mut providers.deepseek,
ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Atlascloud => &mut providers.atlascloud,
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Siliconflow => &mut providers.siliconflow,
ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
ApiProvider::Arcee => &mut providers.arcee,
ApiProvider::Moonshot => &mut providers.moonshot,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
ApiProvider::Volcengine => &mut providers.volcengine,
ApiProvider::Huggingface => &mut providers.huggingface,
ApiProvider::Deepinfra => &mut providers.deepinfra,
ApiProvider::Together => &mut providers.together,
ApiProvider::OpenaiCodex => &mut providers.openai_codex,
ApiProvider::Anthropic => &mut providers.anthropic,
ApiProvider::Zai => &mut providers.zai,
ApiProvider::Stepfun => &mut providers.stepfun,
ApiProvider::Minimax => &mut providers.minimax,
};
let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
provider_headers.extend(headers);
entry.http_headers = Some(provider_headers);
}
if matches!(config.api_provider(), ApiProvider::Ollama)
&& let Ok(value) = std::env::var("OLLAMA_BASE_URL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.ollama
.base_url = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Sglang)
&& let Ok(value) = std::env::var("SGLANG_MODEL")
{
config.default_text_model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Vllm)
&& let Ok(value) = std::env::var("VLLM_MODEL")
{
config.default_text_model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Ollama)
&& let Ok(value) = std::env::var("OLLAMA_MODEL")
{
config.default_text_model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Openai)
&& let Ok(value) = std::env::var("OPENAI_MODEL")
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openai
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
&& let Ok(value) =
std::env::var("XIAOMI_MIMO_MODEL").or_else(|_| std::env::var("MIMO_MODEL"))
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.xiaomi_mimo
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Atlascloud)
&& let Ok(value) = std::env::var("ATLASCLOUD_MODEL")
{
config.default_text_model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::WanjieArk)
&& let Ok(value) = std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.wanjie_ark
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Openrouter)
&& let Ok(value) = std::env::var("OPENROUTER_MODEL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.openrouter
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Volcengine)
&& let Ok(value) =
std::env::var("VOLCENGINE_MODEL").or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.volcengine
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Novita)
&& let Ok(value) = std::env::var("NOVITA_MODEL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.novita
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Fireworks)
&& let Ok(value) = std::env::var("FIREWORKS_MODEL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.fireworks
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Moonshot)
&& let Ok(value) = std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.moonshot
.model = Some(value);
}
let active_provider = config.api_provider();
if matches!(
active_provider,
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
) && let Ok(value) = std::env::var("SILICONFLOW_MODEL")
&& !value.trim().is_empty()
{
config.provider_config_for_mut(active_provider).model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Arcee)
&& let Ok(value) = std::env::var("ARCEE_MODEL")
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.arcee
.model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::Huggingface)
&& let Ok(value) = std::env::var("HUGGINGFACE_MODEL").or_else(|_| std::env::var("HF_MODEL"))
&& !value.trim().is_empty()
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.huggingface
.model = Some(value);
}
if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL")
.ok()
.or_else(|| {
std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")
.ok()
.filter(|value| !value.trim().is_empty())
})
{
let provider = config.api_provider();
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
config.default_text_model = Some(value);
} else {
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(
"DeepSeek providers are handled in the if branch above (issue #1714)"
),
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Atlascloud => &mut providers.atlascloud,
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Siliconflow => &mut providers.siliconflow,
ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
ApiProvider::Arcee => &mut providers.arcee,
ApiProvider::Moonshot => &mut providers.moonshot,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
ApiProvider::Volcengine => &mut providers.volcengine,
ApiProvider::Huggingface => &mut providers.huggingface,
ApiProvider::Deepinfra => &mut providers.deepinfra,
ApiProvider::Together => &mut providers.together,
ApiProvider::OpenaiCodex => &mut providers.openai_codex,
ApiProvider::Anthropic => &mut providers.anthropic,
ApiProvider::Zai => &mut providers.zai,
ApiProvider::Stepfun => &mut providers.stepfun,
ApiProvider::Minimax => &mut providers.minimax,
};
entry.model = Some(value);
}
}
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
&& let Ok(value) = std::env::var("NVIDIA_NIM_MODEL")
{
config.default_text_model = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SKILLS_DIR") {
config.skills_dir = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MCP_CONFIG") {
config.mcp_config_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_NOTES_PATH") {
config.notes_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MEMORY_PATH") {
config.memory_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MEMORY") {
let on = matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "on" | "true" | "yes" | "y" | "enabled"
);
config
.memory
.get_or_insert_with(MemoryConfig::default)
.enabled = Some(on);
}
if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") {
config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
if let Ok(value) = std::env::var("DEEPSEEK_APPROVAL_POLICY") {
config.approval_policy = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_MODE") {
config.sandbox_mode = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_YOLO") {
config.yolo = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
if let Ok(value) =
std::env::var("CODEWHALE_VERBOSITY").or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
{
config.verbosity = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_BACKEND") {
config.sandbox_backend = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_URL") {
config.sandbox_url = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_API_KEY") {
config.sandbox_api_key = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
config.managed_config_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SEARCH_API_KEY")
&& !value.trim().is_empty()
{
config
.search
.get_or_insert_with(SearchConfig::default)
.api_key = Some(value);
}
if let Ok(value) = codewhale_env_var("CODEWHALE_SEARCH_BASE_URL", "DEEPSEEK_SEARCH_BASE_URL") {
config
.search
.get_or_insert_with(SearchConfig::default)
.base_url = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
config.requirements_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MAX_SUBAGENTS")
&& let Ok(parsed) = value.parse::<usize>()
{
config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
}
}
fn normalize_model_config(config: &mut Config) {
if let Some(model) = config.default_text_model.as_deref()
&& !provider_passes_model_through(config.api_provider())
&& !config.active_provider_preserves_custom_base_url_model()
&& let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
{
config.default_text_model = Some(normalized);
}
if let Some(providers) = config.providers.as_mut() {
if let Some(model) = providers.deepseek.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
{
providers.deepseek.model = Some(normalized);
}
if let Some(model) = providers.deepseek_cn.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)
{
providers.deepseek_cn.model = Some(normalized);
}
if let Some(model) = providers.nvidia_nim.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
{
providers.nvidia_nim.model = Some(normalized);
}
if let Some(model) = providers.openrouter.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)
{
providers.openrouter.model = Some(normalized);
}
if let Some(model) = providers.novita.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)
{
providers.novita.model = Some(normalized);
}
if let Some(model) = providers.fireworks.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)
{
providers.fireworks.model = Some(normalized);
}
if let Some(model) = providers.siliconflow.model.as_deref()
&& !provider_entry_uses_custom_base_url(
ApiProvider::Siliconflow,
&providers.siliconflow,
)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Siliconflow, model)
{
providers.siliconflow.model = Some(normalized);
}
if let Some(model) = providers.siliconflow_cn.model.as_deref()
&& !provider_entry_uses_custom_base_url(
ApiProvider::SiliconflowCn,
&providers.siliconflow_cn,
)
&& let Some(normalized) =
normalize_model_for_provider(ApiProvider::SiliconflowCn, model)
{
providers.siliconflow_cn.model = Some(normalized);
}
if let Some(model) = providers.moonshot.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model)
{
providers.moonshot.model = Some(normalized);
}
if let Some(model) = providers.sglang.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)
{
providers.sglang.model = Some(normalized);
}
if let Some(model) = providers.vllm.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)
{
providers.vllm.model = Some(normalized);
}
if let Some(model) = providers.deepinfra.model.as_deref()
&& !provider_entry_uses_custom_base_url(ApiProvider::Deepinfra, &providers.deepinfra)
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepinfra, model)
{
providers.deepinfra.model = Some(normalized);
}
}
}
fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
if matches!(provider, ApiProvider::XiaomiMimo)
&& let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
{
return Some(canonical.to_string());
}
if provider_passes_model_through(provider) {
return None;
}
normalize_model_name_for_provider(provider, model)
}
pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {
matches!(
provider,
ApiProvider::Openai
| ApiProvider::Atlascloud
| ApiProvider::WanjieArk
| ApiProvider::Volcengine
| ApiProvider::XiaomiMimo
| ApiProvider::Moonshot
| ApiProvider::Ollama
| ApiProvider::Huggingface
)
}
fn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {
entry
.base_url
.as_deref()
.is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))
}
fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
provider.default_base_url()
}
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 = normalize_base_url(base_url).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!(
normalize_base_url(base_url).to_ascii_lowercase().as_str(),
"https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
)
}
fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {
if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn)
&& siliconflow_base_url_is_official(base_url)
{
return false;
}
if provider == ApiProvider::XiaomiMimo
&& (xiaomi_mimo_base_url_uses_token_plan(base_url)
|| xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
{
return false;
}
normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))
}
fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {
base_url_is_custom_for_provider(provider, base_url)
}
fn siliconflow_base_url_is_official(base_url: &str) -> bool {
matches!(
normalize_base_url(base_url).to_ascii_lowercase().as_str(),
"https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
)
}
fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
let normalized = normalize_base_url(base_url).to_ascii_lowercase();
normalized == DEFAULT_KIMI_CODE_BASE_URL
|| normalized == "https://api.kimi.com/coding"
|| normalized.starts_with("https://api.kimi.com/coding/")
}
fn provider_config_uses_kimi_oauth(config: &ProviderConfig) -> bool {
config
.auth_mode
.as_deref()
.is_some_and(auth_mode_uses_kimi_oauth)
}
fn auth_mode_uses_kimi_oauth(mode: &str) -> bool {
matches!(
normalize_auth_mode(mode).as_str(),
"kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
)
}
fn normalize_auth_mode(mode: &str) -> String {
mode.trim().to_ascii_lowercase().replace(['-', ' '], "_")
}
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())
}
fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
let lowered = normalized.to_ascii_lowercase();
match (provider, lowered.as_str()) {
(ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
(ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
(ApiProvider::Openrouter, "deepseek-v4-pro") => DEFAULT_OPENROUTER_MODEL.to_string(),
(ApiProvider::Openrouter, "deepseek-v4-flash") => {
DEFAULT_OPENROUTER_FLASH_MODEL.to_string()
}
(ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(),
(ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
(ApiProvider::Fireworks, "deepseek-v4-pro") => DEFAULT_FIREWORKS_MODEL.to_string(),
(
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
"deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1",
) => DEFAULT_SILICONFLOW_MODEL.to_string(),
(
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
"deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3",
) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
(ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(),
(ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
(ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(),
(ApiProvider::Vllm, "deepseek-v4-flash") => DEFAULT_VLLM_FLASH_MODEL.to_string(),
(ApiProvider::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_DEEPINFRA_MODEL.to_string()
}
(ApiProvider::Deepinfra, "deepseek-v4-flash" | "deepseek-chat" | "deepseek-reasoner") => {
DEFAULT_DEEPINFRA_FLASH_MODEL.to_string()
}
(
ApiProvider::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(),
(ApiProvider::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
MOONSHOT_KIMI_K2_6_MODEL.to_string()
}
_ => normalized,
}
}
fn normalize_base_url(base: &str) -> String {
let trimmed = base.trim_end_matches('/');
let deepseek_domains = ["api.deepseek.com", "api.deepseeki.com"];
if deepseek_domains
.iter()
.any(|domain| trimmed.contains(domain))
{
return trimmed.trim_end_matches("/v1").to_string();
}
trimmed.to_string()
}
fn parse_http_headers(raw: &str) -> Result<HashMap<String, String>> {
let mut headers = HashMap::new();
for pair in raw.trim().split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let Some((name, value)) = pair.split_once('=') else {
anyhow::bail!("invalid header pair '{pair}', expected name=value");
};
let name = name.trim();
let value = value.trim();
if name.is_empty() {
anyhow::bail!("header name cannot be empty");
}
if value.is_empty() {
continue;
}
headers.insert(name.to_string(), value.to_string());
}
Ok(headers)
}
fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
if let Some(profile_name) = profile {
let profiles = config.profiles.as_ref();
match profiles.and_then(|profiles| profiles.get(profile_name)) {
Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),
None => {
let available = profiles
.map(|profiles| {
let mut keys = profiles.keys().cloned().collect::<Vec<_>>();
keys.sort();
if keys.is_empty() {
"none".to_string()
} else {
keys.join(", ")
}
})
.unwrap_or_else(|| "none".to_string());
anyhow::bail!("Profile '{profile_name}' not found. Available profiles: {available}")
}
}
} else {
Ok(config.base)
}
}
fn merge_config(base: Config, override_cfg: Config) -> Config {
Config {
provider: override_cfg.provider.or(base.provider),
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
http_headers: override_cfg.http_headers.or(base.http_headers),
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
tools_file: override_cfg.tools_file.or(base.tools_file),
tools: override_cfg.tools.or(base.tools),
skills_dir: override_cfg.skills_dir.or(base.skills_dir),
mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
notes_path: override_cfg.notes_path.or(base.notes_path),
memory_path: override_cfg.memory_path.or(base.memory_path),
vision_model: override_cfg.vision_model.or(base.vision_model),
instructions: override_cfg.instructions.or(base.instructions),
allow_shell: override_cfg.allow_shell.or(base.allow_shell),
prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion),
yolo: override_cfg.yolo.or(base.yolo),
verbosity: override_cfg.verbosity.or(base.verbosity),
approval_policy: override_cfg.approval_policy.or(base.approval_policy),
sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
fallback_providers: if override_cfg.fallback_providers.is_empty() {
base.fallback_providers
} else {
override_cfg.fallback_providers
},
sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),
sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),
sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),
prefer_bwrap: override_cfg.prefer_bwrap.or(base.prefer_bwrap),
managed_config_path: override_cfg
.managed_config_path
.or(base.managed_config_path),
requirements_path: override_cfg.requirements_path.or(base.requirements_path),
max_subagents: override_cfg.max_subagents.or(base.max_subagents),
retry: override_cfg.retry.or(base.retry),
auto_review: override_cfg.auto_review.or(base.auto_review),
tui: override_cfg.tui.or(base.tui),
hooks: override_cfg.hooks.or(base.hooks),
providers: merge_providers(base.providers, override_cfg.providers),
features: merge_features(base.features, override_cfg.features),
notifications: override_cfg.notifications.or(base.notifications),
network: override_cfg.network.or(base.network),
skills: merge_skills_config(base.skills, override_cfg.skills),
snapshots: override_cfg.snapshots.or(base.snapshots),
search: override_cfg.search.or(base.search),
memory: override_cfg.memory.or(base.memory),
speech: override_cfg.speech.or(base.speech),
auto: override_cfg.auto.or(base.auto),
hotbar: override_cfg.hotbar.or(base.hotbar),
update: override_cfg.update.or(base.update),
lsp: override_cfg.lsp.or(base.lsp),
context: ContextConfig {
enabled: override_cfg.context.enabled.or(base.context.enabled),
project_pack: override_cfg
.context
.project_pack
.or(base.context.project_pack),
verbatim_window_turns: override_cfg
.context
.verbatim_window_turns
.or(base.context.verbatim_window_turns),
l1_threshold: override_cfg
.context
.l1_threshold
.or(base.context.l1_threshold),
l2_threshold: override_cfg
.context
.l2_threshold
.or(base.context.l2_threshold),
l3_threshold: override_cfg
.context
.l3_threshold
.or(base.context.l3_threshold),
seam_model: override_cfg.context.seam_model.or(base.context.seam_model),
},
fleet: override_cfg.fleet.or(base.fleet),
subagents: override_cfg.subagents.or(base.subagents),
strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),
runtime_api: override_cfg.runtime_api.or(base.runtime_api),
workshop: override_cfg.workshop.or(base.workshop),
exec_policy_engine: override_cfg.exec_policy_engine,
}
}
fn load_sibling_exec_policy_engine(config_path: Option<&Path>) -> Result<ExecPolicyEngine> {
let Some(config_path) = config_path else {
return Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()));
};
let permissions_path = codewhale_config::permissions_path_for_config_path(config_path);
if !permissions_path.exists() {
return Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()));
}
let raw = fs::read_to_string(&permissions_path).with_context(|| {
format!(
"Failed to read permissions file: {}",
permissions_path.display()
)
})?;
let permissions: codewhale_config::PermissionsToml =
toml::from_str(&raw).with_context(|| {
format!(
"Failed to parse permissions file: {}",
permissions_path.display()
)
})?;
if permissions.is_empty() {
Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()))
} else {
Ok(ExecPolicyEngine::with_rulesets(vec![permissions.ruleset()]))
}
}
fn merge_skills_config(
base: Option<SkillsConfig>,
override_cfg: Option<SkillsConfig>,
) -> Option<SkillsConfig> {
match (base, override_cfg) {
(None, None) => None,
(Some(base), None) => Some(base),
(None, Some(override_cfg)) => Some(override_cfg),
(Some(base), Some(override_cfg)) => Some(SkillsConfig {
registry_url: override_cfg.registry_url.or(base.registry_url),
max_install_size_bytes: override_cfg
.max_install_size_bytes
.or(base.max_install_size_bytes),
scan_codewhale_only: override_cfg
.scan_codewhale_only
.or(base.scan_codewhale_only),
}),
}
}
fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {
ProviderConfig {
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
model: override_cfg.model.or(base.model),
mode: override_cfg.mode.or(base.mode),
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
insecure_skip_tls_verify: override_cfg
.insecure_skip_tls_verify
.or(base.insecure_skip_tls_verify),
http_headers: override_cfg.http_headers.or(base.http_headers),
path_suffix: override_cfg.path_suffix.or(base.path_suffix),
}
}
fn merge_providers(
base: Option<ProvidersConfig>,
override_cfg: Option<ProvidersConfig>,
) -> Option<ProvidersConfig> {
match (base, override_cfg) {
(None, None) => None,
(Some(base), None) => Some(base),
(None, Some(override_cfg)) => Some(override_cfg),
(Some(base), Some(override_cfg)) => Some(ProvidersConfig {
deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),
deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),
nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),
openai: merge_provider_config(base.openai, override_cfg.openai),
anthropic: merge_provider_config(base.anthropic, override_cfg.anthropic),
atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),
wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),
openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),
xiaomi_mimo: merge_provider_config(base.xiaomi_mimo, override_cfg.xiaomi_mimo),
novita: merge_provider_config(base.novita, override_cfg.novita),
fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),
siliconflow: merge_provider_config(base.siliconflow, override_cfg.siliconflow),
siliconflow_cn: merge_provider_config(base.siliconflow_cn, override_cfg.siliconflow_cn),
arcee: merge_provider_config(base.arcee, override_cfg.arcee),
moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot),
sglang: merge_provider_config(base.sglang, override_cfg.sglang),
vllm: merge_provider_config(base.vllm, override_cfg.vllm),
ollama: merge_provider_config(base.ollama, override_cfg.ollama),
volcengine: merge_provider_config(base.volcengine, override_cfg.volcengine),
huggingface: merge_provider_config(base.huggingface, override_cfg.huggingface),
deepinfra: merge_provider_config(base.deepinfra, override_cfg.deepinfra),
together: merge_provider_config(base.together, override_cfg.together),
openai_codex: merge_provider_config(base.openai_codex, override_cfg.openai_codex),
zai: merge_provider_config(base.zai, override_cfg.zai),
stepfun: merge_provider_config(base.stepfun, override_cfg.stepfun),
minimax: merge_provider_config(base.minimax, override_cfg.minimax),
}),
}
}
fn load_single_config_file(path: &Path) -> Result<Config> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let parsed: ConfigFile = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(parsed.base)
}
fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
let doc = toml::from_str::<toml::Value>(raw).ok()?;
const UNKNOWN_SECTIONS: &[&str] = &["general", "sandbox"];
const TOP_LEVEL_KEYS: &[&str] = &[
"allow_shell",
"sandbox_mode",
"approval_policy",
"verbosity",
];
let mut hits: Vec<String> = Vec::new();
for section in UNKNOWN_SECTIONS {
let Some(table) = doc.get(*section).and_then(toml::Value::as_table) else {
continue;
};
for key in TOP_LEVEL_KEYS {
if table.contains_key(*key) {
hits.push(format!("`{section}.{key}`"));
}
}
}
if hits.is_empty() {
return None;
}
Some(format!(
"Ignoring {} — CodeWhale has no `[general]` or `[sandbox]` section, so these \
keys are silently dropped. Move them to the TOP of the config file (above any \
`[section]` header), e.g. `allow_shell = true`. Until then, shell tools stay \
disabled. (#2589)",
hits.join(", ")
))
}
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
let path = config
.managed_config_path
.as_deref()
.map(expand_path)
.or_else(default_managed_config_path);
let Some(path) = path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let managed = load_single_config_file(&path)?;
*config = merge_config(config.clone(), managed);
Ok(())
}
fn apply_requirements(config: &mut Config) -> Result<()> {
let path = config
.requirements_path
.as_deref()
.map(expand_path)
.or_else(default_requirements_path);
let Some(path) = path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read requirements file: {}", path.display()))?;
let requirements: RequirementsFile = toml::from_str(&contents)
.with_context(|| format!("Failed to parse requirements file: {}", path.display()))?;
if !requirements.allowed_approval_policies.is_empty()
&& let Some(policy) = config.approval_policy.as_ref()
{
let policy = policy.to_ascii_lowercase();
if !requirements
.allowed_approval_policies
.iter()
.any(|p| p.eq_ignore_ascii_case(&policy))
{
anyhow::bail!(
"approval_policy '{policy}' is not allowed by requirements ({})",
requirements.allowed_approval_policies.join(", ")
);
}
}
if !requirements.allowed_sandbox_modes.is_empty()
&& let Some(mode) = config.sandbox_mode.as_ref()
{
let mode = mode.to_ascii_lowercase();
if !requirements
.allowed_sandbox_modes
.iter()
.any(|m| m.eq_ignore_ascii_case(&mode))
{
anyhow::bail!(
"sandbox_mode '{mode}' is not allowed by requirements ({})",
requirements.allowed_sandbox_modes.join(", ")
);
}
}
Ok(())
}
fn merge_features(
base: Option<FeaturesToml>,
override_cfg: Option<FeaturesToml>,
) -> Option<FeaturesToml> {
match (base, override_cfg) {
(None, None) => None,
(Some(mut base), Some(override_cfg)) => {
for (key, value) in override_cfg.entries {
base.entries.insert(key, value);
}
Some(base)
}
(Some(base), None) => Some(base),
(None, Some(override_cfg)) => Some(override_cfg),
}
}
pub fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
#[cfg(unix)]
{
if let Ok(meta) = fs::metadata(parent) {
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
let mut perms = meta.permissions();
perms.set_mode(mode & !0o077);
if let Err(err) = fs::set_permissions(parent, perms) {
tracing::warn!(
target: "codewhale::config",
path = %parent.display(),
error = %err,
"could not tighten parent dir permissions; \
filesystem may not support Unix chmod \
(Docker bind-mount, NTFS, network share). \
Continuing — the file will still be written."
);
}
}
}
}
}
Ok(())
}
fn write_config_file_secure(path: &Path, content: &str) -> Result<()> {
#[cfg(unix)]
{
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(content.as_bytes())?;
if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {
tracing::warn!(
target: "codewhale::config",
path = %path.display(),
error = %err,
"could not enforce 0o600 on config file; filesystem may \
not support Unix chmod. File contents written; rely on \
host ACLs for access control."
);
}
}
#[cfg(not(unix))]
{
fs::write(path, content)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavedCredential {
KeyringAndConfigFile {
backend: String,
path: PathBuf,
},
ConfigFile(PathBuf),
}
impl SavedCredential {
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::KeyringAndConfigFile { backend, path } => {
format!("OS keyring ({backend}) and {}", path.display())
}
Self::ConfigFile(path) => path.display().to_string(),
}
}
}
pub fn save_api_key(api_key: &str) -> Result<SavedCredential> {
let trimmed = api_key.trim();
if trimmed.is_empty() {
anyhow::bail!("Refusing to save an empty API key.");
}
let path = save_api_key_to_config_file(trimmed)?;
#[cfg(not(test))]
{
let secrets = codewhale_secrets::Secrets::auto_detect();
match secrets.set("deepseek", trimmed) {
Ok(()) => {
let backend = secrets.backend_name().to_string();
log_sensitive_event(
"credential.save",
json!({
"backend": backend.clone(),
"config_path": path.display().to_string(),
"dual_write": true,
}),
);
return Ok(SavedCredential::KeyringAndConfigFile { backend, path });
}
Err(err) => {
tracing::warn!("OS keyring write failed; key saved to config.toml only: {err}");
}
}
}
Ok(SavedCredential::ConfigFile(path))
}
fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
fn is_api_key_assignment(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed
.strip_prefix("api_key")
.is_some_and(|rest| rest.trim_start().starts_with('='))
}
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let key_to_write = api_key.to_string();
let content = if config_path.exists() {
let existing = fs::read_to_string(&config_path)?;
if existing.contains("api_key") {
let mut result = String::new();
for line in existing.lines() {
if is_api_key_assignment(line) {
let _ = writeln!(result, "api_key = \"{key_to_write}\"");
} else {
result.push_str(line);
result.push('\n');
}
}
result
} else {
format!("api_key = \"{key_to_write}\"\n{existing}")
}
} else {
format!(
r#"# codewhale Configuration
# Get your API key from https://platform.deepseek.com
# Or set DEEPSEEK_API_KEY environment variable
api_key = "{key_to_write}"
# Base URL (default: https://api.deepseek.com/beta)
# Set https://api.deepseek.com to opt out of beta features.
# base_url = "https://api.deepseek.com/beta"
# Default model
default_text_model = "{DEFAULT_TEXT_MODEL}"
# Thinking mode (DeepSeek V4 reasoning effort):
# "off" | "low" | "medium" | "high" | "max"
# Shift+Tab in the TUI cycles between off / high / max.
reasoning_effort = "max"
"#
)
};
write_config_file_secure(&config_path, &content)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.save",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
pub fn has_api_key(config: &Config) -> bool {
has_api_key_for(config, config.api_provider())
}
#[must_use]
pub fn active_provider_has_config_api_key(config: &Config) -> bool {
let provider = config.api_provider();
if provider == ApiProvider::Moonshot
&& config
.provider_config_for(provider)
.is_some_and(provider_config_uses_kimi_oauth)
{
return kimi_cli_credentials_present();
}
if provider == ApiProvider::OpenaiCodex {
return crate::oauth::auth_file_path().exists();
}
if matches!(provider, ApiProvider::Huggingface)
&& std::env::var("HUGGINGFACE_API_KEY")
.or_else(|_| std::env::var("HF_TOKEN"))
.is_ok_and(|k| !k.trim().is_empty())
{
return true;
}
if config
.provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
}
#[must_use]
pub fn active_provider_has_env_api_key(config: &Config) -> bool {
provider_env_api_key(config.api_provider()).is_some()
}
#[must_use]
pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
}
#[must_use]
pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
if provider
.env_vars()
.iter()
.any(|var| std::env::var(var).is_ok_and(|k| !k.trim().is_empty()))
{
return true;
}
if provider == ApiProvider::Moonshot
&& config
.provider_config_for(provider)
.is_some_and(provider_config_uses_kimi_oauth)
{
return kimi_cli_credentials_present();
}
if provider == ApiProvider::OpenaiCodex {
return crate::oauth::auth_file_path().exists();
}
if matches!(
provider,
ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama
) {
return true;
}
if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {
return true;
}
if config
.provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
false
}
pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return match save_api_key(api_key)? {
SavedCredential::KeyringAndConfigFile { path, .. }
| SavedCredential::ConfigFile(path) => Ok(path),
};
}
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let key_inside = provider_config_key(provider).context("provider api key table")?;
let table_name = format!("providers.{key_inside}");
let mut doc: toml::Value = if config_path.exists() {
let raw = fs::read_to_string(&config_path)?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config at {}", config_path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("Config root must be a TOML table.")?;
let providers = table
.entry("providers".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.context("`providers` must be a table.")?;
let entry = providers
.entry(key_inside.to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.with_context(|| format!("`{table_name}` must be a table."))?;
entry.insert(
"api_key".to_string(),
toml::Value::String(api_key.to_string()),
);
let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
write_config_file_secure(&config_path, &serialized)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.save",
json!({
"backend": "config_file",
"provider": provider.as_str(),
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
pub fn save_provider_auth_mode_for(provider: ApiProvider, auth_mode: &str) -> Result<PathBuf> {
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let mut doc: toml::Value = if config_path.exists() {
let raw = fs::read_to_string(&config_path)?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config at {}", config_path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("Config root must be a TOML table.")?;
let providers = table
.entry("providers".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.context("`providers` must be a table.")?;
let key_inside = provider_config_key(provider).context("provider auth mode key")?;
let entry = providers
.entry(key_inside.to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.with_context(|| format!("`providers.{key_inside}` must be a table."))?;
entry.insert(
"auth_mode".to_string(),
toml::Value::String(auth_mode.to_string()),
);
let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
write_config_file_secure(&config_path, &serialized)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.auth_mode.set",
json!({
"backend": "config_file",
"provider": provider.as_str(),
"auth_mode": auth_mode,
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
fn provider_config_key(provider: ApiProvider) -> Result<&'static str> {
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
anyhow::bail!("DeepSeek stores auth at the root config level");
}
provider
.metadata()
.map(|metadata| metadata.provider_config_key())
.context("provider config key")
}
fn provider_config_table_name(provider: ApiProvider) -> Result<String> {
Ok(format!("providers.{}", provider_config_key(provider)?))
}
fn provider_env_api_key(provider: ApiProvider) -> Option<String> {
if provider == ApiProvider::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())
});
}
provider.env_vars().iter().find_map(|var| {
std::env::var(var)
.ok()
.filter(|value| !value.trim().is_empty())
})
}
fn missing_provider_api_key_message(provider: ApiProvider) -> Result<String> {
Ok(format!(
"{} API key not found. Run 'codewhale auth set --provider {}', set {}, or add [{}] api_key in ~/.codewhale/config.toml.",
provider.display_name(),
provider.as_str(),
provider.env_vars_label(),
provider_config_table_name(provider)?
))
}
const KIMI_CODE_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
const KIMI_CODE_CREDENTIAL_FILE: &str = "kimi-code.json";
#[derive(Debug, Clone, Deserialize, Serialize)]
struct KimiOAuthCredential {
access_token: Option<String>,
refresh_token: Option<String>,
expires_at: Option<f64>,
expires_in: Option<f64>,
scope: Option<String>,
token_type: Option<String>,
}
fn kimi_cli_oauth_access_token() -> Result<String> {
let path = kimi_cli_oauth_credentials_path()?;
let raw = fs::read_to_string(&path).with_context(|| {
format!(
"Kimi OAuth credentials not found at {}. Run `kimi login`, then set \
[providers.moonshot] auth_mode = \"kimi_oauth\".",
path.display()
)
})?;
let mut credential: KimiOAuthCredential =
serde_json::from_str(&raw).context("Failed to parse Kimi OAuth credentials")?;
if kimi_oauth_access_token_is_fresh(&credential) {
return credential
.access_token
.filter(|token| !token.trim().is_empty())
.context("Kimi OAuth access token is empty");
}
let refresh_token = credential
.refresh_token
.as_deref()
.filter(|token| !token.trim().is_empty())
.context("Kimi OAuth refresh token is empty. Run `kimi login` again.")?;
credential = refresh_kimi_oauth_token(refresh_token)?;
write_kimi_oauth_credential(&path, &credential)?;
credential
.access_token
.filter(|token| !token.trim().is_empty())
.context("Kimi OAuth refresh returned an empty access token")
}
fn kimi_oauth_access_token_is_fresh(credential: &KimiOAuthCredential) -> bool {
let Some(now) = now_unix_secs() else {
return false;
};
credential
.access_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty())
&& credential
.expires_at
.is_some_and(|expires_at| expires_at - now > 60.0)
}
fn refresh_kimi_oauth_token(refresh_token: &str) -> Result<KimiOAuthCredential> {
let oauth_host = std::env::var("KIMI_CODE_OAUTH_HOST")
.or_else(|_| std::env::var("KIMI_OAUTH_HOST"))
.unwrap_or_else(|_| "https://auth.kimi.com".to_string());
let url = format!("{}/api/oauth/token", oauth_host.trim_end_matches('/'));
let client = crate::tls::reqwest_blocking_client_builder()
.timeout(Duration::from_secs(15))
.build()
.context("Failed to build Kimi OAuth refresh client")?;
let params = [
("client_id", KIMI_CODE_CLIENT_ID),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
];
let response = client
.post(url)
.header("X-Msh-Platform", "kimi_cli")
.header("X-Msh-Version", env!("CARGO_PKG_VERSION"))
.form(¶ms)
.send()
.context("Kimi OAuth refresh request failed")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("Kimi OAuth refresh failed with HTTP {status}. Run `kimi login` again.");
}
let mut refreshed: KimiOAuthCredential = response
.json()
.context("Failed to parse Kimi OAuth refresh response")?;
if let Some(expires_in) = refreshed.expires_in
&& let Some(now) = now_unix_secs()
{
refreshed.expires_at = Some(now + expires_in);
}
Ok(refreshed)
}
fn kimi_cli_oauth_credentials_path() -> Result<PathBuf> {
if let Some(kimi_code_home) = kimi_code_home_override() {
return Ok(kimi_oauth_credential_path(kimi_code_home));
}
let modern_path = effective_home_dir()
.map(|home| kimi_oauth_credential_path(home.join(".kimi-code")))
.context("Failed to resolve Kimi Code home directory")?;
if modern_path.exists() {
return Ok(modern_path);
}
if let Some(legacy_share_dir) = kimi_legacy_share_dir_override() {
return Ok(kimi_oauth_credential_path(legacy_share_dir));
}
if let Some(legacy_path) = effective_home_dir()
.map(|home| kimi_oauth_credential_path(home.join(".kimi")))
.filter(|path| path.exists())
{
return Ok(legacy_path);
}
Ok(modern_path)
}
fn kimi_code_home_override() -> Option<PathBuf> {
std::env::var_os("KIMI_CODE_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn kimi_legacy_share_dir_override() -> Option<PathBuf> {
std::env::var_os("KIMI_SHARE_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn kimi_oauth_credential_path(home: PathBuf) -> PathBuf {
home.join("credentials").join(KIMI_CODE_CREDENTIAL_FILE)
}
fn write_kimi_oauth_credential(path: &Path, credential: &KimiOAuthCredential) -> Result<()> {
let serialized = serde_json::to_vec_pretty(credential)
.context("Failed to serialize Kimi OAuth credentials")?;
crate::utils::write_atomic(path, &serialized).with_context(|| {
format!(
"Failed to write Kimi OAuth credentials to {}",
path.display()
)
})?;
#[cfg(unix)]
if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
tracing::warn!(
target: "codewhale::config",
path = %path.display(),
error = %err,
"could not enforce 0o600 on Kimi OAuth credentials; relying on host ACLs"
);
}
Ok(())
}
fn now_unix_secs() -> Option<f64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs_f64())
.ok()
}
#[must_use]
pub fn kimi_cli_credentials_present() -> bool {
kimi_cli_oauth_credentials_path().is_ok_and(|path| path.exists())
}
pub fn clear_api_key() -> Result<()> {
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if !config_path.exists() {
return Ok(());
}
let existing = fs::read_to_string(&config_path)?;
let mut result = String::new();
for line in existing.lines() {
let trimmed = line.trim_start();
if trimmed.strip_prefix("api_key").is_some_and(|rest| {
let rest = rest.trim_start();
rest.is_empty() || rest.starts_with('=')
}) {
continue;
}
result.push_str(line);
result.push('\n');
}
write_config_file_secure(&config_path, &result)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.clear",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
"scope": "root_and_provider_keys",
}),
);
Ok(())
}
pub fn clear_active_provider_api_key(provider: &str) -> Result<()> {
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if !config_path.exists() {
return Ok(());
}
let existing = fs::read_to_string(&config_path)?;
let mut result = String::new();
let target_section = format!("[providers.{provider}]");
let mut in_target_section = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[providers.") {
in_target_section = trimmed == target_section;
} else if trimmed.starts_with('[') {
in_target_section = false;
}
let is_root_key = !in_target_section
&& provider == "deepseek"
&& trimmed.strip_prefix("api_key").is_some_and(|rest| {
let rest = rest.trim_start();
rest.is_empty() || rest.starts_with('=')
});
let is_provider_key = in_target_section
&& trimmed.strip_prefix("api_key").is_some_and(|rest| {
let rest = rest.trim_start();
rest.is_empty() || rest.starts_with('=')
});
if is_root_key || is_provider_key {
continue;
}
result.push_str(line);
result.push('\n');
}
write_config_file_secure(&config_path, &result)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.clear",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
"scope": provider,
}),
);
Ok(())
}
#[cfg(test)]
mod tests;