use serde::{Deserialize, Serialize};
use crate::entities::sampling::SamplingConfig;
use crate::shared::embed_prefix::EmbedConvention;
use crate::shared::gguf::display_name;
use crate::shared::secrets::{ExternalSlot, SecretKey};
pub const SCHEMA_VERSION: u32 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServerMode {
#[default]
Managed,
External,
#[serde(rename = "openai")]
OpenAi,
Gemini,
Claude,
Grok,
#[serde(rename = "openrouter")]
OpenRouter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudProvider {
OpenAi,
Gemini,
Claude,
Grok,
OpenRouter,
}
impl CloudProvider {
pub const ALL: [CloudProvider; 5] = [
CloudProvider::OpenAi,
CloudProvider::Gemini,
CloudProvider::Claude,
CloudProvider::Grok,
CloudProvider::OpenRouter,
];
fn index(self) -> usize {
match self {
CloudProvider::OpenAi => 0,
CloudProvider::Gemini => 1,
CloudProvider::Claude => 2,
CloudProvider::Grok => 3,
CloudProvider::OpenRouter => 4,
}
}
pub fn base_url(self) -> &'static str {
match self {
CloudProvider::OpenAi => "https://api.openai.com/v1",
CloudProvider::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai",
CloudProvider::Claude => "https://api.anthropic.com",
CloudProvider::Grok => "https://api.x.ai/v1",
CloudProvider::OpenRouter => "https://openrouter.ai/api/v1",
}
}
pub fn chat_base_url(self) -> &'static str {
match self {
CloudProvider::Gemini => "https://generativelanguage.googleapis.com/v1beta",
CloudProvider::OpenAi
| CloudProvider::Claude
| CloudProvider::Grok
| CloudProvider::OpenRouter => self.base_url(),
}
}
pub fn key(self) -> &'static str {
match self {
CloudProvider::OpenAi => "openai",
CloudProvider::Gemini => "gemini",
CloudProvider::Claude => "claude",
CloudProvider::Grok => "grok",
CloudProvider::OpenRouter => "openrouter",
}
}
pub fn display_name(self) -> &'static str {
match self {
CloudProvider::OpenAi => "OpenAI",
CloudProvider::Gemini => "Gemini",
CloudProvider::Claude => "Claude",
CloudProvider::Grok => "Grok",
CloudProvider::OpenRouter => "OpenRouter",
}
}
}
impl ServerMode {
pub fn cloud_provider(self) -> Option<CloudProvider> {
match self {
ServerMode::OpenAi => Some(CloudProvider::OpenAi),
ServerMode::Gemini => Some(CloudProvider::Gemini),
ServerMode::Claude => Some(CloudProvider::Claude),
ServerMode::Grok => Some(CloudProvider::Grok),
ServerMode::OpenRouter => Some(CloudProvider::OpenRouter),
ServerMode::Managed | ServerMode::External => None,
}
}
pub fn key(self) -> &'static str {
match self {
ServerMode::Managed => "managed",
ServerMode::External => "external",
ServerMode::OpenAi => "openai",
ServerMode::Gemini => "gemini",
ServerMode::Claude => "claude",
ServerMode::Grok => "grok",
ServerMode::OpenRouter => "openrouter",
}
}
pub fn from_key(key: &str) -> Option<Self> {
ServerMode::ALL.into_iter().find(|m| m.key() == key)
}
pub const ALL: [ServerMode; 7] = [
ServerMode::Managed,
ServerMode::External,
ServerMode::OpenAi,
ServerMode::Gemini,
ServerMode::Claude,
ServerMode::Grok,
ServerMode::OpenRouter,
];
pub fn supports_continuation(self, model: Option<&str>, catalogued: bool) -> bool {
match self {
ServerMode::OpenRouter => model.is_some_and(gateway_model_continues),
ServerMode::External if catalogued => model.is_some_and(gateway_model_continues),
ServerMode::Managed | ServerMode::External | ServerMode::Gemini => true,
ServerMode::Claude => model.is_some_and(anthropic_model_continues),
ServerMode::OpenAi | ServerMode::Grok => false,
}
}
}
fn anthropic_model_continues(model: &str) -> bool {
let mut nums = model
.split(['-', '.', '@'])
.filter(|s| !s.is_empty() && s.len() <= 2 && s.bytes().all(|b| b.is_ascii_digit()))
.filter_map(|s| s.parse::<u32>().ok());
let Some(major) = nums.next() else {
return false;
};
let minor = nums.next().unwrap_or(0);
major < 4 || (major == 4 && minor <= 5)
}
fn gateway_model_continues(slug: &str) -> bool {
let slug = slug.split(':').next().unwrap_or_default();
let Some((vendor, model)) = slug.split_once('/') else {
return false;
};
if vendor.eq_ignore_ascii_case("anthropic") {
anthropic_model_continues(model)
} else if vendor.eq_ignore_ascii_case("google") {
model.to_ascii_lowercase().starts_with("gemini")
} else {
false
}
}
pub const DEFAULT_GPU_LAYERS: i32 = 99;
pub const DEFAULT_CONTEXT_SIZE: u32 = 8192;
pub const DEFAULT_SESSIONS: u32 = 1;
pub const DEFAULT_CONCURRENT_CALLS_LOCAL: u32 = 1;
pub const DEFAULT_CONCURRENT_CALLS_CLOUD: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashAttn {
#[default]
Auto,
On,
Off,
}
impl FlashAttn {
pub const ALL: [FlashAttn; 3] = [FlashAttn::Auto, FlashAttn::On, FlashAttn::Off];
pub fn as_arg(self) -> Option<&'static str> {
match self {
FlashAttn::Auto => None,
FlashAttn::On => Some("on"),
FlashAttn::Off => Some("off"),
}
}
pub fn label(self) -> &'static str {
match self {
FlashAttn::Auto => "auto",
FlashAttn::On => "on",
FlashAttn::Off => "off",
}
}
pub fn cycle(self, dir: i32) -> Self {
let idx = Self::ALL.iter().position(|x| *x == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SpecType {
#[default]
None,
DraftSimple,
DraftEagle3,
DraftMtp,
NgramSimple,
NgramMapK,
NgramMapK4v,
NgramMod,
NgramCache,
}
impl SpecType {
pub const ALL: [SpecType; 9] = [
SpecType::None,
SpecType::DraftSimple,
SpecType::DraftEagle3,
SpecType::DraftMtp,
SpecType::NgramSimple,
SpecType::NgramMapK,
SpecType::NgramMapK4v,
SpecType::NgramMod,
SpecType::NgramCache,
];
pub fn as_arg(self) -> Option<&'static str> {
match self {
SpecType::None => Option::None,
SpecType::DraftSimple => Some("draft-simple"),
SpecType::DraftEagle3 => Some("draft-eagle3"),
SpecType::DraftMtp => Some("draft-mtp"),
SpecType::NgramSimple => Some("ngram-simple"),
SpecType::NgramMapK => Some("ngram-map-k"),
SpecType::NgramMapK4v => Some("ngram-map-k4v"),
SpecType::NgramMod => Some("ngram-mod"),
SpecType::NgramCache => Some("ngram-cache"),
}
}
pub fn needs_draft_model(self) -> bool {
matches!(
self,
SpecType::DraftSimple | SpecType::DraftEagle3 | SpecType::DraftMtp
)
}
pub fn label(self) -> &'static str {
self.as_arg().unwrap_or("none")
}
pub fn cycle(self, dir: i32) -> Self {
let idx = Self::ALL.iter().position(|x| *x == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ManagedSettings {
pub binary: Option<String>,
pub model_path: Option<String>,
pub mmproj: Option<String>,
pub gpu_layers: i32,
pub context_size: u32,
pub batch_size: Option<u32>,
pub sessions: u32,
pub concurrent_calls: u32,
pub jinja: bool,
pub reasoning_format: Option<String>,
pub no_mmap: bool,
pub flash_attn: FlashAttn,
pub spec_type: SpecType,
pub draft_model: Option<String>,
pub draft_gpu_layers: Option<i32>,
pub draft_n_max: Option<u32>,
pub draft_n_min: Option<u32>,
pub host: String,
pub port: u16,
pub extra_args: Vec<String>,
}
impl Default for ManagedSettings {
fn default() -> Self {
Self {
binary: None,
model_path: None,
mmproj: None,
gpu_layers: DEFAULT_GPU_LAYERS,
context_size: DEFAULT_CONTEXT_SIZE,
batch_size: None,
sessions: DEFAULT_SESSIONS,
concurrent_calls: DEFAULT_CONCURRENT_CALLS_LOCAL,
jinja: true,
reasoning_format: None,
no_mmap: false,
flash_attn: FlashAttn::default(),
spec_type: SpecType::default(),
draft_model: None,
draft_gpu_layers: None,
draft_n_max: None,
draft_n_min: None,
host: "127.0.0.1".to_string(),
port: 8000,
extra_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ExternalSettings {
pub url: Option<String>,
pub model_name: Option<String>,
pub sessions: u32,
pub concurrent_calls: u32,
pub api_key_env: Option<String>,
}
impl Default for ExternalSettings {
fn default() -> Self {
Self {
url: None,
model_name: None,
sessions: DEFAULT_SESSIONS,
concurrent_calls: DEFAULT_CONCURRENT_CALLS_LOCAL,
api_key_env: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CloudSettings {
pub model_name: Option<String>,
pub api_key_env: Option<String>,
pub url: Option<String>,
pub sessions: u32,
pub concurrent_calls: u32,
}
impl Default for CloudSettings {
fn default() -> Self {
Self {
model_name: None,
api_key_env: None,
url: None,
sessions: DEFAULT_SESSIONS,
concurrent_calls: DEFAULT_CONCURRENT_CALLS_CLOUD,
}
}
}
pub fn named_key_env_vars(cfg: &AppConfig) -> Vec<String> {
let engine = |e: &EngineSettings| {
[
e.external.api_key_env.clone(),
e.openai.api_key_env.clone(),
e.gemini.api_key_env.clone(),
e.claude.api_key_env.clone(),
e.grok.api_key_env.clone(),
e.openrouter.api_key_env.clone(),
]
};
let imp = &cfg.impersonation_engine;
let embed = &cfg.embed;
engine(&cfg.engine)
.into_iter()
.chain([
imp.external.api_key_env.clone(),
imp.openai.api_key_env.clone(),
imp.gemini.api_key_env.clone(),
imp.claude.api_key_env.clone(),
imp.grok.api_key_env.clone(),
imp.openrouter.api_key_env.clone(),
embed.external.api_key_env.clone(),
embed.openai.api_key_env.clone(),
embed.gemini.api_key_env.clone(),
embed.claude.api_key_env.clone(),
embed.grok.api_key_env.clone(),
embed.openrouter.api_key_env.clone(),
cfg.tts.openai.api_key_env.clone(),
cfg.tts.gemini.api_key_env.clone(),
cfg.tts.openrouter.api_key_env.clone(),
cfg.tts.external.api_key_env.clone(),
cfg.tools.web_tavily_key_env.clone(),
cfg.video.api_key_env.clone(),
cfg.video.openrouter.api_key_env.clone(),
])
.flatten()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EngineSettings {
pub mode: ServerMode,
pub managed: ManagedSettings,
pub external: ExternalSettings,
pub openai: CloudSettings,
pub gemini: CloudSettings,
pub claude: CloudSettings,
pub grok: CloudSettings,
pub openrouter: CloudSettings,
}
impl EngineSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[
&self.openai,
&self.gemini,
&self.claude,
&self.grok,
&self.openrouter,
],
)
}
pub fn cloud_mut(&mut self) -> Option<&mut CloudSettings> {
cloud_mut(
self.mode.cloud_provider(),
[
&mut self.openai,
&mut self.gemini,
&mut self.claude,
&mut self.grok,
&mut self.openrouter,
],
)
}
pub fn active_sessions(&self) -> u32 {
let n = match self.mode {
ServerMode::Managed => self.managed.sessions,
ServerMode::External => self.external.sessions,
ServerMode::OpenAi
| ServerMode::Gemini
| ServerMode::Claude
| ServerMode::Grok
| ServerMode::OpenRouter => self.cloud().map_or(DEFAULT_SESSIONS, |c| c.sessions),
};
n.max(1)
}
pub fn active_concurrent_calls(&self) -> u32 {
let n = match self.mode {
ServerMode::Managed => self.managed.concurrent_calls,
ServerMode::External => self.external.concurrent_calls,
ServerMode::OpenAi
| ServerMode::Gemini
| ServerMode::Claude
| ServerMode::Grok
| ServerMode::OpenRouter => self
.cloud()
.map_or(DEFAULT_CONCURRENT_CALLS_CLOUD, |c| c.concurrent_calls),
};
n.max(1)
}
pub fn active_model_name(&self) -> Option<String> {
match self.mode {
ServerMode::Managed => self.managed.model_path.as_deref().and_then(display_name),
ServerMode::External => self.external.model_name.clone().filter(|m| !m.is_empty()),
ServerMode::OpenAi
| ServerMode::Gemini
| ServerMode::Claude
| ServerMode::Grok
| ServerMode::OpenRouter => self
.cloud()
.and_then(|c| c.model_name.clone())
.filter(|m| !m.is_empty()),
}
}
}
fn cloud_ref(
provider: Option<CloudProvider>,
all: [&CloudSettings; CloudProvider::ALL.len()],
) -> Option<&CloudSettings> {
Some(all[provider?.index()])
}
fn cloud_mut(
provider: Option<CloudProvider>,
all: [&mut CloudSettings; CloudProvider::ALL.len()],
) -> Option<&mut CloudSettings> {
let idx = provider?.index();
all.into_iter().nth(idx)
}
pub trait SecretSlot {
fn provider(&self) -> Option<CloudProvider>;
fn external_slot(&self) -> Option<ExternalSlot>;
fn secret_key(&self) -> Option<SecretKey> {
match (self.provider(), self.external_slot()) {
(Some(p), _) => Some(SecretKey::Provider(p)),
(None, Some(slot)) => Some(SecretKey::External(slot)),
(None, None) => None,
}
}
}
impl SecretSlot for EngineSettings {
fn provider(&self) -> Option<CloudProvider> {
self.mode.cloud_provider()
}
fn external_slot(&self) -> Option<ExternalSlot> {
(self.mode == ServerMode::External).then_some(ExternalSlot::Chat)
}
}
impl SecretSlot for ImpersonationEngineSettings {
fn provider(&self) -> Option<CloudProvider> {
self.mode.cloud_provider()
}
fn external_slot(&self) -> Option<ExternalSlot> {
(self.mode == ImpersonationMode::External).then_some(ExternalSlot::Impersonation)
}
}
impl SecretSlot for EmbedSettings {
fn provider(&self) -> Option<CloudProvider> {
self.mode.cloud_provider()
}
fn external_slot(&self) -> Option<ExternalSlot> {
(self.mode == ServerMode::External).then_some(ExternalSlot::Embed)
}
}
impl SecretSlot for TtsSettings {
fn provider(&self) -> Option<CloudProvider> {
self.mode.cloud_provider()
}
fn external_slot(&self) -> Option<ExternalSlot> {
(self.mode == TtsMode::External).then_some(ExternalSlot::Tts)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImpersonationMode {
#[default]
Shared,
Managed,
External,
#[serde(rename = "openai")]
OpenAi,
Gemini,
Claude,
Grok,
#[serde(rename = "openrouter")]
OpenRouter,
}
impl ImpersonationMode {
pub fn cloud_provider(self) -> Option<CloudProvider> {
match self {
ImpersonationMode::OpenAi => Some(CloudProvider::OpenAi),
ImpersonationMode::Gemini => Some(CloudProvider::Gemini),
ImpersonationMode::Claude => Some(CloudProvider::Claude),
ImpersonationMode::Grok => Some(CloudProvider::Grok),
ImpersonationMode::OpenRouter => Some(CloudProvider::OpenRouter),
ImpersonationMode::Shared
| ImpersonationMode::Managed
| ImpersonationMode::External => None,
}
}
}
pub const DEFAULT_IMPERSONATION_PORT: u16 = 8002;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImpersonationEngineSettings {
pub mode: ImpersonationMode,
pub managed: ManagedSettings,
pub external: ExternalSettings,
pub openai: CloudSettings,
pub gemini: CloudSettings,
pub claude: CloudSettings,
pub grok: CloudSettings,
pub openrouter: CloudSettings,
}
impl Default for ImpersonationEngineSettings {
fn default() -> Self {
Self {
mode: ImpersonationMode::Shared,
managed: ManagedSettings {
port: DEFAULT_IMPERSONATION_PORT,
..Default::default()
},
external: ExternalSettings::default(),
openai: CloudSettings::default(),
gemini: CloudSettings::default(),
claude: CloudSettings::default(),
grok: CloudSettings::default(),
openrouter: CloudSettings::default(),
}
}
}
impl ImpersonationEngineSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[
&self.openai,
&self.gemini,
&self.claude,
&self.grok,
&self.openrouter,
],
)
}
pub fn cloud_mut(&mut self) -> Option<&mut CloudSettings> {
cloud_mut(
self.mode.cloud_provider(),
[
&mut self.openai,
&mut self.gemini,
&mut self.claude,
&mut self.grok,
&mut self.openrouter,
],
)
}
}
pub const DEFAULT_EMBED_PORT: u16 = 8001;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ManagedEmbedSettings {
pub binary: Option<String>,
pub model_path: Option<String>,
pub gpu_layers: i32,
pub port: u16,
pub extra_args: Vec<String>,
}
impl Default for ManagedEmbedSettings {
fn default() -> Self {
Self {
binary: None,
model_path: None,
gpu_layers: DEFAULT_GPU_LAYERS,
port: DEFAULT_EMBED_PORT,
extra_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbedSettings {
pub mode: ServerMode,
pub managed: ManagedEmbedSettings,
pub external: ExternalSettings,
pub openai: CloudSettings,
pub gemini: CloudSettings,
pub claude: CloudSettings,
pub grok: CloudSettings,
pub openrouter: CloudSettings,
pub convention: EmbedConvention,
}
impl EmbedSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[
&self.openai,
&self.gemini,
&self.claude,
&self.grok,
&self.openrouter,
],
)
}
pub fn cloud_mut(&mut self) -> Option<&mut CloudSettings> {
cloud_mut(
self.mode.cloud_provider(),
[
&mut self.openai,
&mut self.gemini,
&mut self.claude,
&mut self.grok,
&mut self.openrouter,
],
)
}
pub fn active_model_name(&self) -> Option<String> {
match self.mode {
ServerMode::Managed => self.managed.model_path.as_deref().and_then(display_name),
ServerMode::External => self.external.model_name.clone().filter(|m| !m.is_empty()),
ServerMode::OpenAi
| ServerMode::Gemini
| ServerMode::Claude
| ServerMode::Grok
| ServerMode::OpenRouter => self
.cloud()
.and_then(|c| c.model_name.clone())
.filter(|m| !m.is_empty()),
}
}
}
pub const DEFAULT_SUBAGENT_MAX_TOKENS: usize = 4096;
pub const DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS: u64 = 600;
pub const DEFAULT_SUBAGENT_PARALLEL: u32 = 1;
pub const DEFAULT_SUBAGENT_BACKGROUND_MAX: u32 = 2;
pub const DEFAULT_DIALOGUE_RUN_TIMEOUT_SECS: u64 = 1800;
pub const DEFAULT_PYTHON_WASM_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebProvider {
#[default]
Auto,
FreeOnly,
}
impl WebProvider {
pub const ALL: [WebProvider; 2] = [WebProvider::Auto, WebProvider::FreeOnly];
pub fn cycle(self, dir: i32) -> Self {
let idx = Self::ALL.iter().position(|x| *x == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PythonMode {
#[default]
Wasmer,
Local,
}
impl PythonMode {
pub const ALL: [PythonMode; 2] = [PythonMode::Wasmer, PythonMode::Local];
pub fn dirs(self) -> (&'static str, &'static str) {
match self {
PythonMode::Wasmer => ("/w/in", "/w/out"),
PythonMode::Local => ("in", "out"),
}
}
pub fn cycle(self, dir: i32) -> Self {
let idx = Self::ALL.iter().position(|x| *x == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolSettings {
pub web_enabled: bool,
pub web_fetch_content: bool,
#[serde(default)]
pub web_allow_private: bool,
#[serde(default)]
pub web_provider: WebProvider,
#[serde(default)]
pub web_tavily_key_env: Option<String>,
pub python_enabled: bool,
pub python_mode: PythonMode,
pub python_path: Option<String>,
pub python_net_enabled: bool,
pub python_wasm_timeout_secs: u64,
pub python_wasm_memory_mb: Option<u64>,
pub python_local_memory_mb: Option<u64>,
pub fs_enabled: bool,
pub fs_root: Option<String>,
pub subagent_max_tokens: usize,
pub subagent_run_timeout_secs: u64,
pub dialogue_run_timeout_secs: u64,
#[serde(default)]
pub quit_settle_secs: Option<u32>,
pub subagent_parallel: u32,
pub subagent_background: bool,
pub subagent_background_wake: bool,
pub subagent_background_max: u32,
pub confirm_dangerous: bool,
pub mcp_images: bool,
pub python_images: bool,
}
impl Default for ToolSettings {
fn default() -> Self {
Self {
web_enabled: false,
web_fetch_content: true,
web_allow_private: false,
web_provider: WebProvider::default(),
web_tavily_key_env: None,
python_enabled: false,
python_mode: PythonMode::default(),
python_path: None,
python_net_enabled: true,
python_wasm_timeout_secs: DEFAULT_PYTHON_WASM_TIMEOUT_SECS,
python_wasm_memory_mb: None,
python_local_memory_mb: None,
fs_enabled: false,
fs_root: None,
subagent_max_tokens: DEFAULT_SUBAGENT_MAX_TOKENS,
subagent_run_timeout_secs: DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS,
dialogue_run_timeout_secs: DEFAULT_DIALOGUE_RUN_TIMEOUT_SECS,
quit_settle_secs: None,
subagent_parallel: DEFAULT_SUBAGENT_PARALLEL,
subagent_background: false,
subagent_background_wake: true,
subagent_background_max: DEFAULT_SUBAGENT_BACKGROUND_MAX,
confirm_dangerous: false,
mcp_images: true,
python_images: true,
}
}
}
pub const DEFAULT_WORKSPACE_TIMEOUT_SECS: u64 = 300;
pub const DEFAULT_WORKSPACE_OUTPUT_CHARS: usize = 10_000;
pub const DEFAULT_WORKSPACE_MAX_ROUNDS: u32 = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WorkspaceSettings {
pub command_timeout_secs: u64,
pub output_limit_chars: usize,
pub max_rounds: u32,
}
impl Default for WorkspaceSettings {
fn default() -> Self {
Self {
command_timeout_secs: DEFAULT_WORKSPACE_TIMEOUT_SECS,
output_limit_chars: DEFAULT_WORKSPACE_OUTPUT_CHARS,
max_rounds: DEFAULT_WORKSPACE_MAX_ROUNDS,
}
}
}
pub const DEFAULT_CHUNK_TARGET_CHARS: usize = 800;
pub const DEFAULT_CHUNK_OVERLAP_CHARS: usize = 150;
pub const DEFAULT_CHUNK_MAX_CHARS: usize = 1200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RagSettings {
pub chunk_target_chars: usize,
pub chunk_overlap_chars: usize,
pub chunk_max_chars: usize,
}
impl Default for RagSettings {
fn default() -> Self {
Self {
chunk_target_chars: DEFAULT_CHUNK_TARGET_CHARS,
chunk_overlap_chars: DEFAULT_CHUNK_OVERLAP_CHARS,
chunk_max_chars: DEFAULT_CHUNK_MAX_CHARS,
}
}
}
pub const DEFAULT_ATTACH_MAX_FILE_TOKENS: usize = 4000;
pub const DEFAULT_ATTACH_MAX_TOTAL_TOKENS: usize = 8000;
pub const DEFAULT_ATTACH_EXCERPT_TOKENS: usize = 300;
pub const DEFAULT_ATTACH_PAGE_TOKENS: usize = 1500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AttachmentSettings {
pub max_file_tokens: usize,
pub max_total_tokens: usize,
pub excerpt_tokens: usize,
pub page_tokens: usize,
}
impl Default for AttachmentSettings {
fn default() -> Self {
Self {
max_file_tokens: DEFAULT_ATTACH_MAX_FILE_TOKENS,
max_total_tokens: DEFAULT_ATTACH_MAX_TOTAL_TOKENS,
excerpt_tokens: DEFAULT_ATTACH_EXCERPT_TOKENS,
page_tokens: DEFAULT_ATTACH_PAGE_TOKENS,
}
}
}
pub const DEFAULT_IMAGE_MAX_COUNT: usize = 8;
pub const DEFAULT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024;
pub const DEFAULT_IMAGE_DOWNSCALE_PX: u32 = 1568;
pub const MAX_TOOL_RESULT_IMAGES: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImageSettings {
pub max_count: usize,
pub max_bytes: u64,
pub downscale_px: u32,
}
impl Default for ImageSettings {
fn default() -> Self {
Self {
max_count: DEFAULT_IMAGE_MAX_COUNT,
max_bytes: DEFAULT_IMAGE_MAX_BYTES,
downscale_px: DEFAULT_IMAGE_DOWNSCALE_PX,
}
}
}
pub const DEFAULT_COMPACTION_ENABLED: bool = true;
pub const DEFAULT_COMPACTION_SUMMARY_WORDS: usize = 250;
pub const DEFAULT_COMPACTION_TAIL_TOKENS: usize = 2048;
pub const DEFAULT_COMPACTION_THRESHOLD_PCT: u8 = 75;
pub const DEFAULT_COMPACTION_PAGE_TOKENS: usize = 800;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CompactionSettings {
pub enabled: bool,
pub summary_words: usize,
pub tail_tokens: usize,
pub threshold_pct: u8,
pub context_tokens: Option<usize>,
pub page_tokens: usize,
}
impl Default for CompactionSettings {
fn default() -> Self {
Self {
enabled: DEFAULT_COMPACTION_ENABLED,
summary_words: DEFAULT_COMPACTION_SUMMARY_WORDS,
tail_tokens: DEFAULT_COMPACTION_TAIL_TOKENS,
threshold_pct: DEFAULT_COMPACTION_THRESHOLD_PCT,
context_tokens: None,
page_tokens: DEFAULT_COMPACTION_PAGE_TOKENS,
}
}
}
pub const DEFAULT_SELF_MODEL_MAX_NARRATIVE: usize = 50;
pub const DEFAULT_SELF_MODEL_NARRATIVE_IN_PROMPT: usize = 3;
pub const DEFAULT_SELF_MODEL_PROMPT_CAP: usize = 4000;
pub const DEFAULT_SELF_MODEL_MAINTENANCE_PROTOCOL: bool = true;
pub const DEFAULT_SELF_MODEL_MAX_CLOSED_GOALS: usize = 10;
pub const DEFAULT_SELF_MODEL_SUMMARY_TARGET: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SelfModelSettings {
pub max_narrative: usize,
pub narrative_in_prompt: usize,
pub prompt_cap: usize,
pub max_closed_goals: usize,
pub summary_target_chars: usize,
pub auto_reflect_every: usize,
pub auto_consolidate_every: usize,
pub maintenance_protocol: bool,
}
impl Default for SelfModelSettings {
fn default() -> Self {
Self {
max_narrative: DEFAULT_SELF_MODEL_MAX_NARRATIVE,
narrative_in_prompt: DEFAULT_SELF_MODEL_NARRATIVE_IN_PROMPT,
prompt_cap: DEFAULT_SELF_MODEL_PROMPT_CAP,
max_closed_goals: DEFAULT_SELF_MODEL_MAX_CLOSED_GOALS,
summary_target_chars: DEFAULT_SELF_MODEL_SUMMARY_TARGET,
auto_reflect_every: 0,
auto_consolidate_every: 0,
maintenance_protocol: DEFAULT_SELF_MODEL_MAINTENANCE_PROTOCOL,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NotesSettings {
pub auto_consolidate_every: usize,
pub recall_includes_self: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Auto,
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
#[default]
System,
Full,
Mono,
}
static ENVIRONMENT_MODE: std::sync::OnceLock<ThemeMode> = std::sync::OnceLock::new();
pub fn set_environment_mode(mode: ThemeMode) {
let _ = ENVIRONMENT_MODE.set(mode);
}
impl ThemeMode {
pub const ALL: [ThemeMode; 3] = [ThemeMode::System, ThemeMode::Full, ThemeMode::Mono];
pub fn name(self) -> &'static str {
match self {
ThemeMode::System => "system",
ThemeMode::Full => "full",
ThemeMode::Mono => "mono",
}
}
pub fn for_environment(no_color: Option<&std::ffi::OsStr>) -> Self {
match no_color {
Some(value) if !value.is_empty() => ThemeMode::Mono,
_ => ThemeMode::System,
}
}
pub fn environment_default() -> Self {
ENVIRONMENT_MODE.get().copied().unwrap_or_default()
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.name() == name)
}
pub fn cycle(self, dir: i32) -> Self {
let n = Self::ALL.len() as i32;
let at = Self::ALL.iter().position(|m| *m == self).unwrap_or(0) as i32;
Self::ALL[(at + dir).rem_euclid(n) as usize]
}
}
fn lenient_theme_mode<'de, D>(de: D) -> Result<Option<ThemeMode>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(de)?;
let mode = raw.as_str().and_then(ThemeMode::from_name);
if mode.is_none() && !raw.is_null() {
tracing::warn!(
value = %raw,
"interface.theme_mode is not a colour mode this version knows — following the default"
);
}
Ok(mode)
}
pub const DEFAULT_FULL_THEME: &str = "dark";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoTitleMode {
AfterUserMessage,
#[default]
AfterAssistantReply,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NoteOrder {
#[default]
NewestFirst,
OldestFirst,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CodeBlockBackground {
#[default]
Inverted,
Normal,
}
impl CodeBlockBackground {
pub const ALL: [CodeBlockBackground; 2] =
[CodeBlockBackground::Inverted, CodeBlockBackground::Normal];
pub fn name(self) -> &'static str {
match self {
CodeBlockBackground::Inverted => "inverted",
CodeBlockBackground::Normal => "normal",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.name() == name)
}
pub fn cycle(self, dir: i32) -> Self {
let n = Self::ALL.len() as i32;
let at = Self::ALL.iter().position(|b| *b == self).unwrap_or(0) as i32;
Self::ALL[(at + dir).rem_euclid(n) as usize]
}
}
fn lenient_code_block_background<'de, D>(de: D) -> Result<CodeBlockBackground, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(de)?;
Ok(raw
.as_str()
.and_then(CodeBlockBackground::from_name)
.unwrap_or_else(|| {
tracing::warn!(
value = %raw,
"interface.code_block_background is not a background this version knows — using the default"
);
CodeBlockBackground::default()
}))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct InterfaceSettings {
pub theme: Theme,
#[serde(
deserialize_with = "lenient_theme_mode",
skip_serializing_if = "Option::is_none"
)]
pub theme_mode: Option<ThemeMode>,
pub full_theme: String,
pub spellcheck_enabled: bool,
pub selected_dictionaries: Vec<String>,
pub confirm_destructive_keys: bool,
pub terminal_compat: bool,
pub table_row_separators: bool,
pub render_mermaid: bool,
#[serde(deserialize_with = "lenient_code_block_background")]
pub code_block_background: CodeBlockBackground,
pub show_model_name: bool,
pub language: crate::shared::i18n::Lang,
pub clipboard_osc52: crate::shared::osc52::Osc52Mode,
pub auto_title: AutoTitleMode,
pub self_model_note_order: NoteOrder,
pub input_max_rows: u16,
}
pub const DEFAULT_INPUT_MAX_ROWS: u16 = 6;
pub const INPUT_MAX_ROWS_LIMIT: u16 = 50;
impl InterfaceSettings {
pub fn input_rows_ceiling(&self) -> u16 {
self.input_max_rows.clamp(1, INPUT_MAX_ROWS_LIMIT)
}
pub fn mode(&self) -> ThemeMode {
self.mode_under(ThemeMode::environment_default())
}
pub fn set_mode(&mut self, mode: ThemeMode) {
self.set_mode_under(mode, ThemeMode::environment_default());
}
pub(crate) fn mode_under(&self, default: ThemeMode) -> ThemeMode {
self.theme_mode.unwrap_or(default)
}
pub(crate) fn set_mode_under(&mut self, mode: ThemeMode, default: ThemeMode) {
self.theme_mode = (mode != default).then_some(mode);
}
}
impl Default for InterfaceSettings {
fn default() -> Self {
Self {
theme: Theme::default(),
theme_mode: None,
full_theme: DEFAULT_FULL_THEME.to_string(),
spellcheck_enabled: true,
selected_dictionaries: Vec::new(),
confirm_destructive_keys: false,
terminal_compat: false,
table_row_separators: false,
render_mermaid: true,
code_block_background: CodeBlockBackground::default(),
show_model_name: false,
language: crate::shared::i18n::Lang::default(),
clipboard_osc52: crate::shared::osc52::Osc52Mode::default(),
auto_title: AutoTitleMode::default(),
self_model_note_order: NoteOrder::default(),
input_max_rows: DEFAULT_INPUT_MAX_ROWS,
}
}
}
pub const DEFAULT_MCP_TOOL_TIMEOUT_SECS: u64 = 60;
pub const DEFAULT_MCP_MAX_RESULT_CHARS: usize = 20_000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct McpServerConfig {
pub id: String,
pub command: String,
pub args: Vec<String>,
pub env: std::collections::BTreeMap<String, String>,
pub enabled: bool,
pub tool_timeout_secs: u64,
pub max_result_chars: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned_catalog: Option<String>,
}
impl Default for McpServerConfig {
fn default() -> Self {
Self {
id: String::new(),
command: String::new(),
args: Vec::new(),
env: std::collections::BTreeMap::new(),
enabled: true,
tool_timeout_secs: DEFAULT_MCP_TOOL_TIMEOUT_SECS,
max_result_chars: DEFAULT_MCP_MAX_RESULT_CHARS,
pinned_catalog: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct McpSettings {
pub enabled: bool,
pub servers: Vec<McpServerConfig>,
}
pub const DEFAULT_TTS_OPENAI_MODEL: &str = "gpt-4o-mini-tts";
pub const DEFAULT_TTS_OPENAI_VOICE: &str = "onyx";
pub const DEFAULT_TTS_GEMINI_MODEL: &str = "gemini-2.5-flash-preview-tts";
pub const DEFAULT_TTS_GEMINI_VOICE: &str = "Kore";
pub const DEFAULT_VIDEO_MODEL: &str = "gemini-3.5-flash";
pub const DEFAULT_VIDEO_MAX_MINUTES: u32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MediaResolution {
#[default]
Low,
Medium,
}
impl MediaResolution {
pub const ALL: [MediaResolution; 2] = [MediaResolution::Low, MediaResolution::Medium];
pub fn as_arg(self) -> &'static str {
match self {
MediaResolution::Low => "MEDIA_RESOLUTION_LOW",
MediaResolution::Medium => "MEDIA_RESOLUTION_MEDIUM",
}
}
pub fn cycle(self, dir: i32) -> Self {
let i = Self::ALL.iter().position(|v| *v == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(i + dir).rem_euclid(n) as usize]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoProvider {
#[default]
Gemini,
#[serde(rename = "openrouter")]
OpenRouter,
}
impl VideoProvider {
pub const ALL: [VideoProvider; 2] = [VideoProvider::Gemini, VideoProvider::OpenRouter];
pub fn label(self) -> &'static str {
match self {
VideoProvider::Gemini => "gemini",
VideoProvider::OpenRouter => "openrouter",
}
}
pub fn cloud_provider(self) -> CloudProvider {
match self {
VideoProvider::Gemini => CloudProvider::Gemini,
VideoProvider::OpenRouter => CloudProvider::OpenRouter,
}
}
pub fn cycle(self, dir: i32) -> Self {
let i = Self::ALL.iter().position(|v| *v == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(i + dir).rem_euclid(n) as usize]
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoGatewaySettings {
pub model_name: Option<String>,
pub url: Option<String>,
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoSettings {
pub provider: VideoProvider,
pub openrouter: VideoGatewaySettings,
pub model_name: Option<String>,
pub url: Option<String>,
pub api_key_env: Option<String>,
pub media_resolution: MediaResolution,
pub max_minutes: u32,
}
impl VideoSettings {
pub fn secret_key(&self) -> crate::shared::secrets::SecretKey {
crate::shared::secrets::SecretKey::Provider(self.provider.cloud_provider())
}
}
impl Default for VideoSettings {
fn default() -> Self {
Self {
provider: VideoProvider::default(),
openrouter: VideoGatewaySettings::default(),
model_name: Some(DEFAULT_VIDEO_MODEL.into()),
url: None,
api_key_env: None,
media_resolution: MediaResolution::default(),
max_minutes: DEFAULT_VIDEO_MAX_MINUTES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TtsMode {
#[default]
OpenAi,
Gemini,
#[serde(rename = "openrouter")]
OpenRouter,
External,
}
impl TtsMode {
pub const ALL: [TtsMode; 4] = [
TtsMode::OpenAi,
TtsMode::Gemini,
TtsMode::OpenRouter,
TtsMode::External,
];
pub fn label(self) -> &'static str {
match self {
TtsMode::OpenAi => "openai",
TtsMode::Gemini => "gemini",
TtsMode::OpenRouter => "openrouter",
TtsMode::External => "external",
}
}
pub fn cloud_provider(self) -> Option<CloudProvider> {
match self {
TtsMode::OpenAi => Some(CloudProvider::OpenAi),
TtsMode::Gemini => Some(CloudProvider::Gemini),
TtsMode::OpenRouter => Some(CloudProvider::OpenRouter),
TtsMode::External => None,
}
}
pub fn cycle(self, dir: i32) -> Self {
let idx = Self::ALL.iter().position(|x| *x == self).unwrap_or(0) as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TtsCloudSettings {
pub model_name: Option<String>,
pub voice: Option<String>,
pub user_voice: Option<String>,
pub instructions: Option<String>,
pub api_key_env: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TtsExternalSettings {
pub url: Option<String>,
pub model_name: Option<String>,
pub voice: Option<String>,
pub user_voice: Option<String>,
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TtsSettings {
pub mode: TtsMode,
pub openai: TtsCloudSettings,
pub gemini: TtsCloudSettings,
pub openrouter: TtsCloudSettings,
pub external: TtsExternalSettings,
pub speed: f32,
pub speak_roles: bool,
pub stop_on_chat_switch: bool,
pub stop_on_generation_start: bool,
}
impl Default for TtsSettings {
fn default() -> Self {
Self {
mode: TtsMode::default(),
openai: TtsCloudSettings {
model_name: Some(DEFAULT_TTS_OPENAI_MODEL.into()),
voice: Some(DEFAULT_TTS_OPENAI_VOICE.into()),
..Default::default()
},
gemini: TtsCloudSettings {
model_name: Some(DEFAULT_TTS_GEMINI_MODEL.into()),
voice: Some(DEFAULT_TTS_GEMINI_VOICE.into()),
..Default::default()
},
openrouter: TtsCloudSettings::default(),
external: TtsExternalSettings::default(),
speed: 1.0,
speak_roles: false,
stop_on_chat_switch: true,
stop_on_generation_start: false,
}
}
}
impl TtsSettings {
pub fn cloud(&self) -> Option<&TtsCloudSettings> {
match self.mode.cloud_provider()? {
CloudProvider::OpenAi => Some(&self.openai),
CloudProvider::Gemini => Some(&self.gemini),
CloudProvider::OpenRouter => Some(&self.openrouter),
CloudProvider::Claude | CloudProvider::Grok => None,
}
}
pub fn active_voices(&self) -> (Option<&str>, Option<&str>) {
fn nonblank(v: &Option<String>) -> Option<&str> {
v.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
let (voice, user) = match self.cloud() {
Some(c) => (&c.voice, &c.user_voice),
None => (&self.external.voice, &self.external.user_voice),
};
(nonblank(voice), nonblank(user))
}
pub fn cloud_mut(&mut self) -> Option<&mut TtsCloudSettings> {
match self.mode.cloud_provider()? {
CloudProvider::OpenAi => Some(&mut self.openai),
CloudProvider::Gemini => Some(&mut self.gemini),
CloudProvider::OpenRouter => Some(&mut self.openrouter),
CloudProvider::Claude | CloudProvider::Grok => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CopySettings {
pub copy_thoughts: bool,
pub copy_tool_calls: bool,
pub copy_tool_results: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImpersonationProfile {
pub id: uuid::Uuid,
pub name: String,
#[serde(default)]
pub system_message: String,
}
impl ImpersonationProfile {
pub fn new(name: impl Into<String>, system_message: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4(),
name: name.into(),
system_message: system_message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OpenRouterSettings {
pub attribution: bool,
}
impl Default for OpenRouterSettings {
fn default() -> Self {
Self { attribution: true }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub schema_version: u32,
pub default_sampling: SamplingConfig,
pub impersonation_sampling: SamplingConfig,
pub engine: EngineSettings,
pub impersonation_engine: ImpersonationEngineSettings,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub impersonation_profiles: Vec<ImpersonationProfile>,
pub embed: EmbedSettings,
pub openrouter: OpenRouterSettings,
pub max_tool_rounds: u32,
pub tools: ToolSettings,
#[serde(default)]
pub workspace: WorkspaceSettings,
pub rag: RagSettings,
pub attachments: AttachmentSettings,
pub images: ImageSettings,
pub compaction: CompactionSettings,
pub self_model: SelfModelSettings,
pub notes: NotesSettings,
pub interface: InterfaceSettings,
pub copy: CopySettings,
pub mcp: McpSettings,
pub tts: TtsSettings,
pub video: VideoSettings,
pub last_active_chat: Option<uuid::Uuid>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub api_keys: Vec<crate::shared::secrets::ApiKeyEntry>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
default_sampling: SamplingConfig {
max_tokens: Some(16384),
thinking: Some(true),
..Default::default()
},
impersonation_sampling: SamplingConfig {
max_tokens: Some(1024),
thinking: Some(false),
..Default::default()
},
engine: EngineSettings::default(),
impersonation_engine: ImpersonationEngineSettings::default(),
impersonation_profiles: Vec::new(),
embed: EmbedSettings::default(),
openrouter: OpenRouterSettings::default(),
max_tool_rounds: 8,
tools: ToolSettings::default(),
workspace: WorkspaceSettings::default(),
rag: RagSettings::default(),
attachments: AttachmentSettings::default(),
images: ImageSettings::default(),
compaction: CompactionSettings::default(),
self_model: SelfModelSettings::default(),
notes: NotesSettings::default(),
interface: InterfaceSettings::default(),
copy: CopySettings::default(),
mcp: McpSettings::default(),
tts: TtsSettings::default(),
video: VideoSettings::default(),
last_active_chat: None,
api_keys: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn continuation_capability_is_per_mode_and_anthropic_generation() {
for mode in [
ServerMode::Managed,
ServerMode::External,
ServerMode::Gemini,
] {
assert!(mode.supports_continuation(None, false), "{mode:?}");
}
for mode in [ServerMode::OpenAi, ServerMode::Grok] {
assert!(
!mode.supports_continuation(Some("anything"), false),
"{mode:?}"
);
}
for ok in [
"claude-haiku-4-5",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5@20251101",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
] {
assert!(
ServerMode::Claude.supports_continuation(Some(ok), false),
"{ok} must continue"
);
}
for no in [
"claude-opus-4-6",
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
"unknown-model",
] {
assert!(
!ServerMode::Claude.supports_continuation(Some(no), false),
"{no} must refuse"
);
}
assert!(
!ServerMode::Claude.supports_continuation(None, false),
"no model name — nothing to allow by"
);
}
#[test]
fn through_a_gateway_only_the_measured_families_continue() {
let gateway = |slug: &str| ServerMode::External.supports_continuation(Some(slug), true);
for ok in [
"anthropic/claude-haiku-4.5",
"anthropic/claude-3.7-sonnet",
"anthropic/claude-3.7-sonnet:thinking",
"anthropic/claude-haiku-4.5:batch",
"google/gemini-2.5-flash",
"Google/Gemini-3-Flash-Preview",
] {
assert!(gateway(ok), "{ok} continues through a gateway");
}
for no in [
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-4.6:batch",
"anthropic/claude-opus-5",
"google/gemma-4-31b-it",
"openai/gpt-4.1-mini",
"qwen/qwen3.6-27b",
"x-ai/grok-4.5",
"mistralai/mistral-small-3.2-24b-instruct",
"~anthropic/claude-haiku-latest",
"openrouter/auto",
"claude-haiku-4-5",
] {
assert!(!gateway(no), "{no} refuses through a gateway");
}
assert!(
!ServerMode::External.supports_continuation(None, true),
"a gateway with no model named has nothing to allow by"
);
for slug in ["google/gemma-4-31b-it", "openai/gpt-4.1-mini"] {
assert!(
ServerMode::External.supports_continuation(Some(slug), false),
"without a catalogue {slug} answers as external always did"
);
}
assert!(ServerMode::Managed.supports_continuation(None, true));
assert!(!ServerMode::OpenAi.supports_continuation(Some("x"), true));
}
#[test]
fn default_has_current_schema_version() {
assert_eq!(AppConfig::default().schema_version, SCHEMA_VERSION);
assert_eq!(AppConfig::default().max_tool_rounds, 8);
}
#[test]
fn auto_title_defaults_to_after_assistant_reply() {
assert_eq!(
AppConfig::default().interface.auto_title,
AutoTitleMode::AfterAssistantReply
);
let old: InterfaceSettings = serde_json::from_str("{}").unwrap();
assert_eq!(old.auto_title, AutoTitleMode::AfterAssistantReply);
}
#[test]
fn secret_key_follows_the_mode_of_each_slot() {
let openai = Some(SecretKey::Provider(CloudProvider::OpenAi));
let mut engine = EngineSettings::default();
for (mode, want) in [
(ServerMode::Managed, None),
(
ServerMode::External,
Some(SecretKey::External(ExternalSlot::Chat)),
),
(ServerMode::OpenAi, openai.clone()),
(
ServerMode::Claude,
Some(SecretKey::Provider(CloudProvider::Claude)),
),
] {
engine.mode = mode;
assert_eq!(engine.secret_key(), want, "chat mode {mode:?}");
}
let mut imp = ImpersonationEngineSettings::default();
for (mode, want) in [
(ImpersonationMode::Shared, None),
(ImpersonationMode::Managed, None),
(
ImpersonationMode::External,
Some(SecretKey::External(ExternalSlot::Impersonation)),
),
(ImpersonationMode::OpenAi, openai.clone()),
] {
imp.mode = mode;
assert_eq!(imp.secret_key(), want, "impersonation mode {mode:?}");
}
let mut embed = EmbedSettings {
mode: ServerMode::External,
..Default::default()
};
assert_eq!(
embed.secret_key(),
Some(SecretKey::External(ExternalSlot::Embed))
);
embed.mode = ServerMode::OpenAi;
assert_eq!(embed.secret_key(), openai.clone());
let mut tts = TtsSettings {
mode: TtsMode::External,
..Default::default()
};
assert_eq!(
tts.secret_key(),
Some(SecretKey::External(ExternalSlot::Tts))
);
tts.mode = TtsMode::OpenAi;
assert_eq!(tts.secret_key(), openai);
let names: std::collections::BTreeSet<String> = ExternalSlot::ALL
.into_iter()
.map(|s| SecretKey::External(s).storage_name())
.collect();
assert_eq!(names.len(), ExternalSlot::ALL.len());
}
#[test]
fn tts_active_voices_reads_mode_and_treats_blank_as_unset() {
let mut tts = TtsSettings {
mode: TtsMode::OpenAi,
openai: TtsCloudSettings {
voice: Some("onyx".into()),
user_voice: Some("nova".into()),
..Default::default()
},
..Default::default()
};
assert_eq!(tts.active_voices(), (Some("onyx"), Some("nova")));
tts.openai.user_voice = Some(" ".into());
assert_eq!(tts.active_voices(), (Some("onyx"), None));
tts.mode = TtsMode::External;
tts.external.voice = Some("bella".into());
assert_eq!(tts.active_voices(), (Some("bella"), None));
}
#[test]
fn serde_roundtrip() {
let c = AppConfig::default();
let json = serde_json::to_string_pretty(&c).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn docker_settings_seed_still_configures_what_it_claims() {
const SEED: &str = include_str!("../../docker/lab/settings.seed.json");
let c: AppConfig = serde_json::from_str(SEED).expect("the seed is valid AppConfig JSON");
assert_eq!(c.engine.mode, ServerMode::External);
assert_eq!(c.engine.external.url.as_deref(), Some("__CHAT_URL__"));
assert_eq!(c.embed.mode, ServerMode::External);
assert_eq!(c.embed.external.url.as_deref(), Some("__EMBED_URL__"));
assert_eq!(
c.engine.claude.api_key_env.as_deref(),
Some("ANTHROPIC_API_KEY")
);
assert_eq!(
c.engine.openai.api_key_env.as_deref(),
Some("OPENAI_API_KEY")
);
for section in [&c.engine.openrouter, &c.embed.openrouter] {
assert_eq!(section.api_key_env.as_deref(), Some("OPENROUTER_API_KEY"));
assert!(section.model_name.is_some());
}
assert!(c.tools.python_enabled);
assert_eq!(c.tools.python_mode, PythonMode::Local);
assert!(
!c.mcp.enabled,
"the MCP master switch must not be seeded on"
);
let fs = c.mcp.servers.first().expect("the example server");
assert_eq!(fs.id, "fs");
assert_eq!(fs.args, vec!["/home/jovyan/work".to_string()]);
assert_eq!(c.interface.theme, Theme::Auto);
assert_eq!(c.max_tool_rounds, AppConfig::default().max_tool_rounds);
}
#[test]
fn active_sessions_follows_the_mode_and_never_drops_below_one() {
let mut e = EngineSettings::default();
assert_eq!(e.active_sessions(), 1);
e.managed.sessions = 3;
e.external.sessions = 0;
e.grok.sessions = 5;
e.mode = ServerMode::Managed;
assert_eq!(e.active_sessions(), 3);
e.mode = ServerMode::External;
assert_eq!(e.active_sessions(), 1, "zero reads as one");
e.mode = ServerMode::Grok;
assert_eq!(e.active_sessions(), 5);
e.mode = ServerMode::OpenAi;
assert_eq!(e.active_sessions(), 1, "a provider left at its default");
let mut old: EngineSettings = serde_json::from_str(
r#"{"external":{"url":"http://h/v1"},"openai":{"model_name":"m"}}"#,
)
.unwrap();
old.mode = ServerMode::External;
assert_eq!(old.external.sessions, 1);
assert_eq!(old.openai.sessions, 1);
assert_eq!(old.active_sessions(), 1);
}
#[test]
fn active_concurrent_calls_follows_the_mode_with_a_default_per_kind() {
let mut e = EngineSettings::default();
assert_eq!(e.active_concurrent_calls(), 1, "managed is local");
e.mode = ServerMode::External;
assert_eq!(e.active_concurrent_calls(), 1, "external is local");
e.mode = ServerMode::OpenAi;
assert_eq!(
e.active_concurrent_calls(),
4,
"a cloud overlaps by default"
);
e.mode = ServerMode::Claude;
assert_eq!(e.active_concurrent_calls(), 4);
e.grok.concurrent_calls = 0;
e.mode = ServerMode::Grok;
assert_eq!(e.active_concurrent_calls(), 1, "zero reads as one");
e.managed.concurrent_calls = 3;
e.mode = ServerMode::Managed;
assert_eq!(e.active_concurrent_calls(), 3);
let old: EngineSettings = serde_json::from_str(
r#"{"external":{"url":"http://h/v1"},"gemini":{"model_name":"m"}}"#,
)
.unwrap();
assert_eq!(
old.external.concurrent_calls,
DEFAULT_CONCURRENT_CALLS_LOCAL
);
assert_eq!(old.managed.concurrent_calls, DEFAULT_CONCURRENT_CALLS_LOCAL);
assert_eq!(old.gemini.concurrent_calls, DEFAULT_CONCURRENT_CALLS_CLOUD);
assert_eq!(old.openai.concurrent_calls, DEFAULT_CONCURRENT_CALLS_CLOUD);
}
#[test]
fn active_model_name_by_mode() {
let mut e = EngineSettings {
mode: ServerMode::Managed,
managed: ManagedSettings {
model_path: Some("/models/gemma-4-it.gguf".into()),
..Default::default()
},
..Default::default()
};
assert_eq!(e.active_model_name().as_deref(), Some("gemma-4-it"));
e.managed.model_path = None;
assert_eq!(e.active_model_name(), None);
e.mode = ServerMode::External;
e.external.model_name = Some("qwen-3.6".into());
assert_eq!(e.active_model_name().as_deref(), Some("qwen-3.6"));
e.mode = ServerMode::OpenAi;
e.openai.model_name = Some("gpt-4o".into());
assert_eq!(e.active_model_name().as_deref(), Some("gpt-4o"));
e.openai.model_name = Some(String::new());
assert_eq!(e.active_model_name(), None);
}
#[test]
fn server_mode_key_matches_serde() {
for mode in ServerMode::ALL {
let serde_name = serde_json::to_value(mode).unwrap();
assert_eq!(serde_name.as_str(), Some(mode.key()));
assert_eq!(ServerMode::from_key(mode.key()), Some(mode));
}
assert_eq!(ServerMode::from_key("no-such-mode"), None);
}
#[test]
fn mcp_server_config_roundtrip_and_partial_defaults() {
let c: AppConfig = serde_json::from_str(
r#"{"mcp":{"enabled":true,"servers":[{
"id":"fs","command":"cmd","args":["/c","npx","-y","srv"],
"env":{"TOKEN":"MINDFORK_FS_TOKEN"}}]}}"#,
)
.unwrap();
assert!(c.mcp.enabled);
let s = &c.mcp.servers[0];
assert_eq!(s.id, "fs");
assert_eq!(s.command, "cmd");
assert_eq!(s.args, vec!["/c", "npx", "-y", "srv"]);
assert_eq!(
s.env.get("TOKEN").map(String::as_str),
Some("MINDFORK_FS_TOKEN")
);
assert!(s.enabled);
assert_eq!(s.tool_timeout_secs, DEFAULT_MCP_TOOL_TIMEOUT_SECS);
assert_eq!(s.max_result_chars, DEFAULT_MCP_MAX_RESULT_CHARS);
let json = serde_json::to_string(&c.mcp).unwrap();
let back: McpSettings = serde_json::from_str(&json).unwrap();
assert_eq!(back, c.mcp);
}
#[test]
fn partial_json_fills_defaults() {
let c: AppConfig = serde_json::from_str(r#"{"max_tool_rounds":4}"#).unwrap();
assert_eq!(c.max_tool_rounds, 4);
assert_eq!(c.schema_version, SCHEMA_VERSION);
assert_eq!(c.engine.managed.port, 8000);
assert_eq!(c.engine.managed.gpu_layers, DEFAULT_GPU_LAYERS);
assert!(c.engine.managed.jinja);
assert_eq!(c.embed.convention, EmbedConvention::None);
assert_eq!(c.embed.managed.port, DEFAULT_EMBED_PORT);
assert_eq!(c.tools.subagent_max_tokens, DEFAULT_SUBAGENT_MAX_TOKENS);
assert_eq!(
c.tools.subagent_run_timeout_secs,
DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS
);
assert_eq!(
c.tools.dialogue_run_timeout_secs,
DEFAULT_DIALOGUE_RUN_TIMEOUT_SECS
);
assert_eq!(c.engine.managed.sessions, DEFAULT_SESSIONS);
assert_eq!(c.engine.external.sessions, DEFAULT_SESSIONS);
assert_eq!(c.engine.claude.sessions, DEFAULT_SESSIONS);
assert_eq!(c.tools.subagent_parallel, DEFAULT_SUBAGENT_PARALLEL);
assert!(!c.tools.confirm_dangerous);
assert!(c.compaction.enabled);
assert_eq!(c.compaction.threshold_pct, DEFAULT_COMPACTION_THRESHOLD_PCT);
assert_eq!(c.compaction.context_tokens, None);
assert!(!c.tools.fs_enabled);
assert_eq!(c.tools.fs_root, None);
assert!(!c.tools.python_enabled);
assert_eq!(c.tools.python_mode, PythonMode::Wasmer);
assert!(c.tools.python_net_enabled);
assert_eq!(
c.tools.python_wasm_timeout_secs,
DEFAULT_PYTHON_WASM_TIMEOUT_SECS
);
assert_eq!(c.tools.python_wasm_memory_mb, None);
assert_eq!(c.tools.python_local_memory_mb, None);
let old: AppConfig =
serde_json::from_str(r#"{"tools": {"python_wasm_memory_mb": 2048}}"#).unwrap();
assert_eq!(
(
old.tools.python_wasm_memory_mb,
old.tools.python_local_memory_mb
),
(Some(2048), None)
);
assert_eq!(c.rag.chunk_target_chars, DEFAULT_CHUNK_TARGET_CHARS);
assert_eq!(c.self_model.max_narrative, DEFAULT_SELF_MODEL_MAX_NARRATIVE);
assert_eq!(
c.self_model.narrative_in_prompt,
DEFAULT_SELF_MODEL_NARRATIVE_IN_PROMPT
);
assert_eq!(c.self_model.prompt_cap, DEFAULT_SELF_MODEL_PROMPT_CAP);
assert_eq!(
c.self_model.max_closed_goals,
DEFAULT_SELF_MODEL_MAX_CLOSED_GOALS
);
assert_eq!(
c.self_model.summary_target_chars,
DEFAULT_SELF_MODEL_SUMMARY_TARGET
);
assert!(c.self_model.maintenance_protocol);
assert_eq!(c.self_model.auto_reflect_every, 0);
assert_eq!(c.self_model.auto_consolidate_every, 0);
assert_eq!(c.notes.auto_consolidate_every, 0);
assert!(!c.notes.recall_includes_self);
assert_eq!(c.rag.chunk_overlap_chars, DEFAULT_CHUNK_OVERLAP_CHARS);
assert_eq!(c.rag.chunk_max_chars, DEFAULT_CHUNK_MAX_CHARS);
assert!(c.interface.spellcheck_enabled);
assert_eq!(c.interface.theme, Theme::Auto);
assert_eq!(c.interface.self_model_note_order, NoteOrder::NewestFirst);
let old: AppConfig = serde_json::from_str(r#"{"interface":{"theme":"dark"}}"#).unwrap();
assert_eq!(old.interface.theme, Theme::Dark);
assert_eq!(old.interface.self_model_note_order, NoteOrder::NewestFirst);
assert_eq!(c.interface.input_max_rows, DEFAULT_INPUT_MAX_ROWS);
assert_eq!(DEFAULT_INPUT_MAX_ROWS, 6);
assert_eq!(old.interface.input_max_rows, DEFAULT_INPUT_MAX_ROWS);
assert!(!c.interface.terminal_compat);
assert!(!c.interface.table_row_separators);
assert!(c.interface.render_mermaid);
assert_eq!(c.interface.language, crate::shared::i18n::Lang::Ru);
assert!(!c.copy.copy_thoughts);
assert!(!c.copy.copy_tool_calls);
assert!(!c.copy.copy_tool_results);
assert!(!c.mcp.enabled);
assert!(c.mcp.servers.is_empty());
assert_eq!(c.tts.mode, TtsMode::OpenAi);
assert_eq!(
c.tts.openai.model_name.as_deref(),
Some(DEFAULT_TTS_OPENAI_MODEL)
);
assert_eq!(
c.tts.openai.voice.as_deref(),
Some(DEFAULT_TTS_OPENAI_VOICE)
);
assert_eq!(
c.tts.gemini.model_name.as_deref(),
Some(DEFAULT_TTS_GEMINI_MODEL)
);
assert_eq!(c.tts.speed, 1.0);
assert!(!c.tts.speak_roles);
assert!(c.tts.stop_on_chat_switch);
assert!(!c.tts.stop_on_generation_start);
assert_eq!(c.impersonation_engine.mode, ImpersonationMode::Shared);
assert_eq!(
c.impersonation_engine.managed.port,
DEFAULT_IMPERSONATION_PORT
);
assert_eq!(c.impersonation_sampling.thinking, Some(false));
assert_eq!(c.last_active_chat, None);
}
#[test]
fn impersonation_sections_roundtrip() {
let c = AppConfig {
impersonation_engine: ImpersonationEngineSettings {
mode: ImpersonationMode::Managed,
managed: ManagedSettings {
binary: Some("llama-server".into()),
model_path: Some("persona.gguf".into()),
port: 8002,
..Default::default()
},
..Default::default()
},
impersonation_sampling: SamplingConfig {
temperature: Some(0.8),
max_tokens: Some(256),
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string_pretty(&c).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn impersonation_profiles_default_empty_and_roundtrip() {
let old: AppConfig = serde_json::from_str(r#"{"max_tool_rounds":4}"#).unwrap();
assert!(old.impersonation_profiles.is_empty());
assert!(
!serde_json::to_string(&old)
.unwrap()
.contains("impersonation_profiles")
);
let c = AppConfig {
impersonation_profiles: vec![ImpersonationProfile::new("Юзер", "Ты — Владимир.")],
..Default::default()
};
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(c, back);
}
#[test]
fn impersonation_managed_default_port_is_8002() {
let s = ImpersonationEngineSettings::default();
assert_eq!(s.managed.port, DEFAULT_IMPERSONATION_PORT);
}
#[test]
fn flash_attn_arg_and_cycle() {
assert_eq!(FlashAttn::default(), FlashAttn::Auto);
assert_eq!(FlashAttn::Auto.as_arg(), None);
assert_eq!(FlashAttn::On.as_arg(), Some("on"));
assert_eq!(FlashAttn::Off.as_arg(), Some("off"));
assert_eq!(FlashAttn::Auto.cycle(1), FlashAttn::On);
assert_eq!(FlashAttn::Auto.cycle(-1), FlashAttn::Off);
}
#[test]
fn python_mode_default_cycle_and_serde() {
assert_eq!(PythonMode::default(), PythonMode::Wasmer);
assert_eq!(PythonMode::Wasmer.cycle(1), PythonMode::Local);
assert_eq!(PythonMode::Local.cycle(1), PythonMode::Wasmer);
assert_eq!(PythonMode::Wasmer.cycle(-1), PythonMode::Local);
assert_eq!(
serde_json::to_string(&PythonMode::Local).unwrap(),
"\"local\""
);
let m: PythonMode = serde_json::from_str("\"wasmer\"").unwrap();
assert_eq!(m, PythonMode::Wasmer);
assert!(PythonMode::ALL.contains(&PythonMode::Local));
}
#[test]
fn spec_type_arg_serde_and_draft_need() {
assert_eq!(SpecType::default(), SpecType::None);
assert_eq!(SpecType::None.as_arg(), None);
assert_eq!(SpecType::DraftMtp.as_arg(), Some("draft-mtp"));
assert_eq!(SpecType::NgramMapK4v.as_arg(), Some("ngram-map-k4v"));
assert_eq!(
serde_json::to_string(&SpecType::DraftMtp).unwrap(),
"\"draft-mtp\""
);
assert_eq!(
serde_json::to_string(&SpecType::NgramMapK4v).unwrap(),
"\"ngram-map-k4v\""
);
assert!(SpecType::DraftMtp.needs_draft_model());
assert!(!SpecType::NgramSimple.needs_draft_model());
assert!(!SpecType::None.needs_draft_model());
}
#[test]
fn managed_spec_fields_roundtrip() {
let c = AppConfig {
engine: EngineSettings {
mode: ServerMode::Managed,
managed: ManagedSettings {
binary: Some("llama-server".into()),
model_path: Some("mtp-gemma-4-12B-it.gguf".into()),
flash_attn: FlashAttn::On,
spec_type: SpecType::DraftMtp,
draft_model: Some("mtp-gemma-4-12B-it.gguf".into()),
draft_gpu_layers: Some(99),
draft_n_max: Some(5),
draft_n_min: Some(1),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string_pretty(&c).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn managed_projector_roundtrips_and_a_change_is_visible_to_the_restart_check() {
let c = AppConfig {
engine: EngineSettings {
mode: ServerMode::Managed,
managed: ManagedSettings {
binary: Some("llama-server".into()),
model_path: Some("gemma-4-31B-it.gguf".into()),
mmproj: Some("mmproj-gemma-4-31B-it.gguf".into()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(c, back);
assert_eq!(
back.engine.managed.mmproj.as_deref(),
Some("mmproj-gemma-4-31B-it.gguf")
);
let mut changed = c.clone();
changed.engine.managed.mmproj = Some("mmproj-other.gguf".into());
assert_ne!(changed.engine, c.engine, "a changed projector must restart");
let mut cleared = c.clone();
cleared.engine.managed.mmproj = None;
assert_ne!(cleared.engine, c.engine, "clearing it must restart too");
}
#[test]
fn a_config_without_the_projector_still_loads() {
let json = r#"{"engine":{"mode":"managed","managed":{"model_path":"m.gguf"}}}"#;
let c: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(c.engine.managed.mmproj, None);
assert_eq!(c.engine.managed.model_path.as_deref(), Some("m.gguf"));
}
#[test]
fn per_provider_cloud_settings_are_independent() {
let mut e = EngineSettings {
mode: ServerMode::OpenAi,
openai: CloudSettings {
model_name: Some("gpt-4o".into()),
api_key_env: Some("OPENAI_API_KEY".into()),
..Default::default()
},
gemini: CloudSettings {
model_name: Some("gemini-2.5-pro".into()),
api_key_env: Some("GEMINI_API_KEY".into()),
..Default::default()
},
..Default::default()
};
assert_eq!(e.cloud().unwrap().model_name.as_deref(), Some("gpt-4o"));
e.mode = ServerMode::Gemini;
assert_eq!(
e.cloud().unwrap().model_name.as_deref(),
Some("gemini-2.5-pro")
);
assert_eq!(e.openai.model_name.as_deref(), Some("gpt-4o"));
e.mode = ServerMode::Managed;
assert!(e.cloud().is_none());
}
#[test]
fn impersonation_mode_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&ImpersonationMode::External).unwrap(),
"\"external\""
);
assert_eq!(
serde_json::to_string(&ImpersonationMode::Shared).unwrap(),
"\"shared\""
);
}
#[test]
fn extended_sections_roundtrip() {
let c = AppConfig {
engine: EngineSettings {
mode: ServerMode::Managed,
managed: ManagedSettings {
binary: Some("llama-server".into()),
model_path: Some("gemma.gguf".into()),
gpu_layers: 50,
context_size: 4096,
jinja: true,
reasoning_format: Some("auto".into()),
..Default::default()
},
..Default::default()
},
embed: EmbedSettings {
mode: ServerMode::External,
external: ExternalSettings {
url: Some("http://127.0.0.1:8001/v1".into()),
..Default::default()
},
..Default::default()
},
tools: ToolSettings {
python_enabled: true,
subagent_max_tokens: 512,
subagent_run_timeout_secs: 30,
confirm_dangerous: true,
..Default::default()
},
interface: InterfaceSettings {
theme: Theme::Dark,
theme_mode: Some(ThemeMode::Full),
full_theme: "light".into(),
spellcheck_enabled: false,
selected_dictionaries: vec!["en_US".into(), "ru_RU".into()],
confirm_destructive_keys: true,
terminal_compat: true,
table_row_separators: true,
render_mermaid: false,
code_block_background: CodeBlockBackground::Normal,
show_model_name: true,
language: crate::shared::i18n::Lang::Ru,
clipboard_osc52: crate::shared::osc52::Osc52Mode::Always,
auto_title: AutoTitleMode::AfterUserMessage,
self_model_note_order: NoteOrder::OldestFirst,
input_max_rows: 12,
},
..Default::default()
};
let json = serde_json::to_string_pretty(&c).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn input_rows_ceiling_bounds_a_hand_edited_value() {
let with = |rows: u16| InterfaceSettings {
input_max_rows: rows,
..Default::default()
};
assert_eq!(with(0).input_rows_ceiling(), 1);
assert_eq!(with(1).input_rows_ceiling(), 1);
assert_eq!(with(17).input_rows_ceiling(), 17);
assert_eq!(
with(INPUT_MAX_ROWS_LIMIT).input_rows_ceiling(),
INPUT_MAX_ROWS_LIMIT
);
assert_eq!(with(u16::MAX).input_rows_ceiling(), INPUT_MAX_ROWS_LIMIT);
let hand: AppConfig =
serde_json::from_str(r#"{"interface":{"input_max_rows":0}}"#).unwrap();
assert_eq!(hand.interface.input_rows_ceiling(), 1);
}
#[test]
fn a_settings_file_without_a_colour_mode_reads_as_system() {
let old: AppConfig = serde_json::from_str(r#"{"interface":{"theme":"light"}}"#).unwrap();
assert_eq!(old.interface.theme_mode, None);
assert_eq!(old.interface.mode(), ThemeMode::System);
assert_eq!(old.interface.theme, Theme::Light);
assert_eq!(old.interface.full_theme, DEFAULT_FULL_THEME);
}
#[test]
fn a_colour_mode_is_written_only_when_chosen() {
let untouched = serde_json::to_value(InterfaceSettings::default()).unwrap();
assert!(
untouched.get("theme_mode").is_none(),
"the default mode must stay out of the file: {untouched}"
);
assert_eq!(untouched["full_theme"], "dark");
let mut chosen = InterfaceSettings::default();
chosen.set_mode(ThemeMode::Full);
let written = serde_json::to_value(&chosen).unwrap();
assert_eq!(written["theme_mode"], "full");
let back: InterfaceSettings = serde_json::from_value(written).unwrap();
assert_eq!(back.mode(), ThemeMode::Full);
chosen.set_mode(ThemeMode::System);
assert_eq!(chosen.theme_mode, None);
}
#[test]
fn a_colour_mode_from_a_newer_version_does_not_cost_the_config() {
for foreign in [r#""sepia""#, r#""FULL""#, "7", "true", r#"{"a":1}"#] {
let json = format!(
r#"{{"max_tool_rounds":3,"interface":{{"theme":"dark","theme_mode":{foreign}}}}}"#
);
let c: AppConfig = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("theme_mode={foreign} failed the parse: {e}"));
assert_eq!(c.interface.mode(), ThemeMode::System, "{foreign}");
assert_eq!(c.interface.theme, Theme::Dark, "{foreign}");
assert_eq!(c.max_tool_rounds, 3, "{foreign}");
}
let c: AppConfig =
serde_json::from_str(r#"{"interface":{"full_theme":"gruvbox"}}"#).unwrap();
assert_eq!(c.interface.full_theme, "gruvbox");
assert!(serde_json::from_str::<AppConfig>(r#"{"interface":{"theme":"mono"}}"#).is_err());
}
#[test]
fn colour_modes_have_stable_names_and_cycle_both_ways() {
assert_eq!(ThemeMode::System.name(), "system");
assert_eq!(ThemeMode::Full.name(), "full");
assert_eq!(ThemeMode::Mono.name(), "mono");
for mode in ThemeMode::ALL {
assert_eq!(ThemeMode::from_name(mode.name()), Some(mode));
assert_eq!(
serde_json::to_value(mode).unwrap(),
mode.name(),
"what is written is what is read back"
);
assert_eq!(mode.cycle(1).cycle(-1), mode);
}
assert_eq!(ThemeMode::System.cycle(1), ThemeMode::Full);
assert_eq!(ThemeMode::Full.cycle(1), ThemeMode::Mono);
assert_eq!(ThemeMode::System.cycle(-1), ThemeMode::Mono, "wraps");
assert_eq!(ThemeMode::from_name("sepia"), None);
}
#[test]
fn the_code_block_background_is_inverted_unless_chosen() {
assert_eq!(
InterfaceSettings::default().code_block_background,
CodeBlockBackground::Inverted
);
let old: AppConfig = serde_json::from_str(r#"{"interface":{"theme":"dark"}}"#).unwrap();
assert_eq!(
old.interface.code_block_background,
CodeBlockBackground::Inverted
);
let mut chosen = InterfaceSettings {
code_block_background: CodeBlockBackground::Normal,
..InterfaceSettings::default()
};
let written = serde_json::to_value(&chosen).unwrap();
assert_eq!(written["code_block_background"], "normal");
let back: InterfaceSettings = serde_json::from_value(written).unwrap();
assert_eq!(back.code_block_background, CodeBlockBackground::Normal);
chosen.code_block_background = CodeBlockBackground::Inverted;
assert_eq!(
serde_json::to_value(&chosen).unwrap()["code_block_background"],
"inverted"
);
}
#[test]
fn a_code_block_background_from_a_newer_version_does_not_cost_the_config() {
for foreign in [
r#""tinted""#,
r#""NORMAL""#,
"7",
"true",
"null",
r#"{"a":1}"#,
] {
let json = format!(
r#"{{"max_tool_rounds":3,"interface":{{"theme":"light","code_block_background":{foreign}}}}}"#
);
let c: AppConfig = serde_json::from_str(&json).unwrap_or_else(|e| {
panic!("code_block_background={foreign} failed the parse: {e}")
});
assert_eq!(
c.interface.code_block_background,
CodeBlockBackground::Inverted,
"{foreign}"
);
assert_eq!(c.interface.theme, Theme::Light, "{foreign}");
assert_eq!(c.max_tool_rounds, 3, "{foreign}");
}
}
#[test]
fn code_block_backgrounds_have_stable_names_and_cycle_both_ways() {
assert_eq!(CodeBlockBackground::Inverted.name(), "inverted");
assert_eq!(CodeBlockBackground::Normal.name(), "normal");
for b in CodeBlockBackground::ALL {
assert_eq!(CodeBlockBackground::from_name(b.name()), Some(b));
assert_eq!(serde_json::to_value(b).unwrap(), b.name());
assert_eq!(b.cycle(1).cycle(-1), b);
assert_ne!(b.cycle(1), b);
assert_eq!(b.cycle(1), b.cycle(-1));
}
assert_eq!(
CodeBlockBackground::Inverted.cycle(1),
CodeBlockBackground::Normal
);
assert_eq!(CodeBlockBackground::from_name("tinted"), None);
}
#[test]
fn no_color_asks_for_monochrome_unless_it_is_empty() {
use std::ffi::OsStr;
assert_eq!(ThemeMode::for_environment(None), ThemeMode::System);
assert_eq!(
ThemeMode::for_environment(Some(OsStr::new(""))),
ThemeMode::System,
"an empty NO_COLOR is an unset one"
);
for value in ["1", "0", "false", "yes", " "] {
assert_eq!(
ThemeMode::for_environment(Some(OsStr::new(value))),
ThemeMode::Mono,
"{value:?}"
);
}
assert_eq!(ThemeMode::environment_default(), ThemeMode::System);
}
#[test]
fn the_environment_decides_only_the_mode_nobody_chose() {
let mono = ThemeMode::Mono;
let mut i = InterfaceSettings::default();
assert_eq!(i.mode_under(mono), ThemeMode::Mono, "nothing chosen");
i.set_mode_under(ThemeMode::System, mono);
assert_eq!(i.theme_mode, Some(ThemeMode::System));
assert_eq!(i.mode_under(mono), ThemeMode::System);
assert_eq!(serde_json::to_value(&i).unwrap()["theme_mode"], "system");
i.set_mode_under(ThemeMode::Mono, mono);
assert_eq!(i.theme_mode, None);
assert_eq!(i.mode_under(ThemeMode::System), ThemeMode::System);
i.set_mode_under(ThemeMode::Full, ThemeMode::System);
assert_eq!(i.mode_under(mono), ThemeMode::Full);
i.set_mode_under(ThemeMode::Mono, ThemeMode::System);
assert_eq!(i.theme_mode, Some(ThemeMode::Mono), "chosen, so written");
assert_eq!(i.mode_under(ThemeMode::System), ThemeMode::Mono);
}
#[test]
fn provider_index_matches_its_slot_in_all() {
for (i, p) in CloudProvider::ALL.into_iter().enumerate() {
assert_eq!(p.index(), i, "{p:?} is not at its own index");
}
let mut e = EngineSettings {
grok: CloudSettings {
model_name: Some("grok-4.5".into()),
..Default::default()
},
claude: CloudSettings {
model_name: Some("claude-opus-4-8".into()),
..Default::default()
},
..Default::default()
};
e.mode = ServerMode::Grok;
assert_eq!(e.cloud().unwrap().model_name.as_deref(), Some("grok-4.5"));
assert_eq!(e.active_model_name().as_deref(), Some("grok-4.5"));
e.cloud_mut().unwrap().model_name = Some("grok-4.3".into());
assert_eq!(e.grok.model_name.as_deref(), Some("grok-4.3"));
assert_eq!(
e.claude.model_name.as_deref(),
Some("claude-opus-4-8"),
"writing through cloud_mut hit the wrong provider"
);
}
#[test]
fn config_without_grok_section_still_loads() {
let old = r#"{"engine":{"mode":"openai","openai":{"model_name":"gpt-4o"}}}"#;
let c: AppConfig = serde_json::from_str(old).unwrap();
assert_eq!(c.engine.openai.model_name.as_deref(), Some("gpt-4o"));
assert_eq!(c.engine.grok, CloudSettings::default());
assert_eq!(c.impersonation_engine.grok, CloudSettings::default());
assert_eq!(c.embed.grok, CloudSettings::default());
}
#[test]
fn theme_serializes_lowercase() {
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
}
#[test]
fn cloud_modes_serialize_and_map_to_provider() {
assert_eq!(
serde_json::to_string(&ServerMode::OpenAi).unwrap(),
"\"openai\""
);
assert_eq!(
serde_json::to_string(&ServerMode::Gemini).unwrap(),
"\"gemini\""
);
assert_eq!(
ServerMode::OpenAi.cloud_provider(),
Some(CloudProvider::OpenAi)
);
assert_eq!(
ServerMode::Gemini.cloud_provider(),
Some(CloudProvider::Gemini)
);
assert_eq!(ServerMode::Managed.cloud_provider(), None);
assert_eq!(ServerMode::External.cloud_provider(), None);
assert_eq!(
serde_json::to_string(&ServerMode::Claude).unwrap(),
"\"claude\""
);
assert_eq!(
ServerMode::Claude.cloud_provider(),
Some(CloudProvider::Claude)
);
assert!(CloudProvider::Claude.base_url().contains("anthropic"));
assert_eq!(
serde_json::to_string(&ServerMode::Grok).unwrap(),
"\"grok\""
);
assert_eq!(ServerMode::Grok.cloud_provider(), Some(CloudProvider::Grok));
assert_eq!(CloudProvider::Grok.base_url(), "https://api.x.ai/v1");
assert_eq!(
CloudProvider::Grok.chat_base_url(),
CloudProvider::Grok.base_url()
);
assert_eq!(
ImpersonationMode::OpenAi.cloud_provider(),
Some(CloudProvider::OpenAi)
);
assert_eq!(ImpersonationMode::Shared.cloud_provider(), None);
assert!(
CloudProvider::OpenAi
.base_url()
.starts_with("https://api.openai.com")
);
assert!(
CloudProvider::Gemini
.base_url()
.contains("generativelanguage")
);
}
#[test]
fn cloud_engine_settings_roundtrip() {
let c = AppConfig {
engine: EngineSettings {
mode: ServerMode::OpenAi,
openai: CloudSettings {
model_name: Some("gpt-4o".into()),
api_key_env: Some("OPENAI_API_KEY".into()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string_pretty(&c).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
let old: AppConfig = serde_json::from_str(r#"{"engine":{"mode":"managed"}}"#).unwrap();
assert_eq!(old.engine.openai.model_name, None);
assert_eq!(old.engine.openai.api_key_env, None);
assert_eq!(old.engine.managed.binary, None);
}
#[test]
fn the_gateway_mode_is_one_word_in_both_enums_that_carry_it() {
let written = |mode: serde_json::Value| mode.as_str().map(str::to_string);
assert_eq!(
written(serde_json::to_value(ServerMode::OpenRouter).unwrap()).as_deref(),
Some("openrouter")
);
assert_eq!(
written(serde_json::to_value(ImpersonationMode::OpenRouter).unwrap()).as_deref(),
Some("openrouter")
);
assert_eq!(
serde_json::from_str::<ServerMode>(r#""openrouter""#).ok(),
Some(ServerMode::OpenRouter)
);
assert_eq!(
serde_json::from_str::<ImpersonationMode>(r#""openrouter""#).ok(),
Some(ImpersonationMode::OpenRouter)
);
assert_eq!(
ServerMode::from_key("openrouter"),
Some(ServerMode::OpenRouter)
);
for other in ["open_router", "open-router", "OpenRouter", "openrouter.ai"] {
let json = format!("\"{other}\"");
assert!(
serde_json::from_str::<ServerMode>(&json).is_err(),
"{other}"
);
assert!(
serde_json::from_str::<ImpersonationMode>(&json).is_err(),
"{other}"
);
assert_eq!(ServerMode::from_key(other), None, "{other}");
}
}
#[test]
fn each_slot_hands_the_gateway_mode_its_own_section() {
const SLUG: &str = "anthropic/claude-haiku-4.5";
const PICKED: &str = "google/gemini-3.5-flash";
assert_eq!(
CloudProvider::ALL.get(CloudProvider::OpenRouter.index()),
Some(&CloudProvider::OpenRouter)
);
macro_rules! own_section {
($slot:literal, $settings:expr) => {{
let mut s = $settings;
s.grok.model_name = Some("grok-4.5".into());
s.openrouter.model_name = Some(SLUG.into());
let read = s.cloud().and_then(|c| c.model_name.clone());
assert_eq!(read.as_deref(), Some(SLUG), "{}: read", $slot);
s.cloud_mut().expect("a cloud mode").model_name = Some(PICKED.into());
assert_eq!(
(
s.openrouter.model_name.as_deref(),
s.grok.model_name.as_deref()
),
(Some(PICKED), Some("grok-4.5")),
"{}: written to the gateway's section and to no neighbour",
$slot
);
s
}};
}
let engine = own_section!(
"engine",
EngineSettings {
mode: ServerMode::OpenRouter,
..Default::default()
}
);
assert_eq!(engine.active_model_name().as_deref(), Some(PICKED));
assert_eq!(
engine.secret_key(),
Some(SecretKey::Provider(CloudProvider::OpenRouter))
);
let imp = own_section!(
"impersonation",
ImpersonationEngineSettings {
mode: ImpersonationMode::OpenRouter,
..Default::default()
}
);
assert_eq!(imp.secret_key(), engine.secret_key(), "one key, every slot");
let embed = own_section!(
"embed",
EmbedSettings {
mode: ServerMode::OpenRouter,
..Default::default()
}
);
assert_eq!(embed.active_model_name().as_deref(), Some(PICKED));
assert_eq!(embed.secret_key(), engine.secret_key());
}
#[test]
fn config_without_openrouter_sections_still_loads() {
let old = r#"{"engine":{"mode":"grok","grok":{"model_name":"grok-4.5"}}}"#;
let c: AppConfig = serde_json::from_str(old).unwrap();
assert_eq!(c.engine.mode, ServerMode::Grok);
assert_eq!(c.engine.grok.model_name.as_deref(), Some("grok-4.5"));
assert_eq!(c.engine.openrouter, CloudSettings::default());
assert_eq!(c.impersonation_engine.openrouter, CloudSettings::default());
assert_eq!(c.embed.openrouter, CloudSettings::default());
assert_eq!(c.openrouter, OpenRouterSettings::default());
}
#[test]
fn attribution_is_on_until_somebody_turns_it_off() {
assert!(AppConfig::default().openrouter.attribution);
for silent in ["{}", r#"{"openrouter":{}}"#] {
let c: AppConfig =
serde_json::from_str(silent).unwrap_or_else(|e| panic!("{silent} must load: {e}"));
assert!(c.openrouter.attribution, "{silent}");
}
let off: AppConfig =
serde_json::from_str(r#"{"openrouter":{"attribution":false}}"#).unwrap();
assert!(!off.openrouter.attribution);
let saved = serde_json::to_value(&off).unwrap();
assert_eq!(saved["openrouter"]["attribution"], false, "{saved}");
let back: AppConfig = serde_json::from_value(saved).unwrap();
assert!(!back.openrouter.attribution, "off survives the round trip");
}
#[test]
fn a_config_on_the_gateway_round_trips_in_the_files_own_words() {
let section = |model: &str, env: &str| CloudSettings {
model_name: Some(model.into()),
api_key_env: Some(env.into()),
..Default::default()
};
let mut c = AppConfig::default();
c.engine.mode = ServerMode::OpenRouter;
c.engine.openrouter = section("anthropic/claude-haiku-4.5", "CHAT_KEY");
c.impersonation_engine.mode = ImpersonationMode::OpenRouter;
c.impersonation_engine.openrouter = section("qwen/qwen3.6-27b", "IMP_KEY");
c.embed.mode = ServerMode::OpenRouter;
c.embed.openrouter = section("baai/bge-m3", "EMBED_KEY");
c.openrouter.attribution = false;
let json = serde_json::to_value(&c).unwrap();
let said: Vec<serde_json::Value> = "/engine/mode /engine/openrouter/model_name \
/impersonation_engine/mode /impersonation_engine/openrouter/api_key_env \
/embed/mode /embed/openrouter/model_name /openrouter/attribution"
.split_whitespace()
.map(|path| json.pointer(path).cloned().unwrap_or_default())
.collect();
let written: serde_json::Value = serde_json::from_str(
r#"["openrouter", "anthropic/claude-haiku-4.5", "openrouter", "IMP_KEY",
"openrouter", "baai/bge-m3", false]"#,
)
.unwrap();
assert_eq!(serde_json::Value::Array(said), written, "{json}");
let back: AppConfig = serde_json::from_value(json).unwrap();
assert_eq!(back, c);
}
#[test]
fn a_variable_named_for_the_gateways_key_is_kept_from_children() {
let mut c = AppConfig::default();
c.engine.openrouter.api_key_env = Some("MY_ROUTER_CHAT".into());
c.impersonation_engine.openrouter.api_key_env = Some(" MY_ROUTER_IMP ".into());
c.embed.openrouter.api_key_env = Some("MY_ROUTER_EMBED".into());
let named = named_key_env_vars(&c);
let missing: Vec<&str> = ["MY_ROUTER_CHAT", "MY_ROUTER_IMP", "MY_ROUTER_EMBED"]
.into_iter()
.filter(|name| !named.iter().any(|n| n == name))
.collect();
assert!(missing.is_empty(), "not named: {missing:?} in {named:?}");
}
const GATEWAY_CONTINUES: &str = "
anthropic/claude-haiku-4.5 yes | Anthropic up to 4.5 continues
anthropic/claude-sonnet-4.6 no | ...and refuses prefill from 4.6 on
google/gemini-3.5-flash yes | a Gemini continues
openai/gpt-5.5 no | OpenAI restarts
qwen/qwen3.6-27b no | every open-weight route restarts
anthropic/claude-haiku-4.5:nitro yes | a variant answers as its base
anthropic/claude-sonnet-4.6:nitro no | ...and must not hide the minor version
";
#[test]
fn the_gateway_mode_asks_the_slug_whether_or_not_the_catalogue_answered() {
let mut wrong = Vec::new();
for row in GATEWAY_CONTINUES.lines().filter(|l| !l.trim().is_empty()) {
let (columns, why) = row.split_once('|').unwrap();
let c: Vec<&str> = columns.split_whitespace().collect();
for catalogued in [false, true] {
let got = ServerMode::OpenRouter.supports_continuation(Some(c[0]), catalogued);
if got != (c[1] == "yes") {
wrong.push(format!(
"{} (catalogued: {catalogued}) — {}",
c[0],
why.trim()
));
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
for catalogued in [false, true] {
assert!(
!ServerMode::OpenRouter.supports_continuation(None, catalogued),
"no model named — nothing to allow by"
);
}
assert!(ServerMode::External.supports_continuation(Some("openai/gpt-5.5"), false));
assert!(!ServerMode::External.supports_continuation(Some("openai/gpt-5.5"), true));
}
#[test]
fn the_speech_slot_has_a_gateway_mode_on_the_providers_key() {
let tts: TtsSettings = serde_json::from_str(
r#"{"mode":"openrouter",
"openrouter":{"model_name":"x-ai/grok-voice-tts-1.0","voice":"eve"}}"#,
)
.expect("the mode is read");
assert_eq!(tts.mode, TtsMode::OpenRouter);
assert_eq!(
serde_json::to_value(&tts).unwrap()["mode"],
"openrouter",
"and written back the same"
);
assert_eq!(TtsMode::OpenRouter.label(), "openrouter");
assert_eq!(
tts.secret_key(),
Some(SecretKey::Provider(CloudProvider::OpenRouter)),
"the key the chat slot stored serves speech too"
);
assert_eq!(tts.active_voices(), (Some("eve"), None));
assert_eq!(
tts.cloud().and_then(|c| c.model_name.as_deref()),
Some("x-ai/grok-voice-tts-1.0")
);
assert_eq!(
tts.openai.model_name.as_deref(),
Some(DEFAULT_TTS_OPENAI_MODEL)
);
for other in ["open_router", "OpenRouter", "openrouter.ai"] {
let asked = format!(r#"{{"mode":"{other}"}}"#);
assert!(
serde_json::from_str::<TtsSettings>(&asked).is_err(),
"{other} is not a mode"
);
}
}
#[test]
fn the_gateways_speech_section_starts_empty() {
let tts = TtsSettings::default();
assert_eq!(tts.openrouter, TtsCloudSettings::default());
let written = serde_json::to_value(&tts).unwrap();
assert!(written["openrouter"]["model_name"].is_null(), "{written}");
let old: TtsSettings =
serde_json::from_str(r#"{"mode":"gemini","gemini":{"voice":"Puck"}}"#).unwrap();
assert_eq!(old.openrouter, TtsCloudSettings::default());
assert_eq!(old.gemini.voice.as_deref(), Some("Puck"));
}
#[test]
fn every_speech_mode_is_reached_by_cycling_and_edits_its_own_section() {
for dir in [1, -1] {
let mut seen = vec![TtsMode::default()];
for _ in 1..TtsMode::ALL.len() {
seen.push(seen.last().unwrap().cycle(dir));
}
for mode in TtsMode::ALL {
assert!(seen.contains(&mode), "{mode:?} not reached with {dir}");
}
assert_eq!(seen.last().unwrap().cycle(dir), TtsMode::default());
}
for mode in TtsMode::ALL {
let mut tts = TtsSettings {
mode,
..Default::default()
};
let Some(section) = tts.cloud_mut() else {
assert_eq!(mode, TtsMode::External, "{mode:?} has a section");
continue;
};
section.voice = Some(format!("voice-of-{}", mode.label()));
let named = format!("voice-of-{}", mode.label());
assert_eq!(tts.active_voices().0, Some(named.as_str()));
let sections = [&tts.openai, &tts.gemini, &tts.openrouter];
let holding = sections
.iter()
.filter(|s| s.voice.as_deref() == Some(named.as_str()))
.count();
assert_eq!(holding, 1, "{mode:?} wrote to one section");
}
}
#[test]
fn the_video_slot_chooses_its_provider_and_keeps_both_models() {
let old: VideoSettings = serde_json::from_str(
r#"{"model_name":"gemini-2.5-pro","api_key_env":"MY_GEMINI","max_minutes":45}"#,
)
.expect("a file of before the selector");
assert_eq!(old.provider, VideoProvider::Gemini);
assert_eq!(old.openrouter, VideoGatewaySettings::default());
assert_eq!(old.model_name.as_deref(), Some("gemini-2.5-pro"));
assert_eq!(
old.secret_key(),
SecretKey::Provider(CloudProvider::Gemini),
"Gemini's key, as before"
);
let new: VideoSettings = serde_json::from_str(
r#"{"provider":"openrouter","model_name":"gemini-2.5-pro",
"openrouter":{"model_name":"google/gemini-3.5-flash","api_key_env":"MY_ROUTER"}}"#,
)
.expect("the selector is read");
assert_eq!(new.provider, VideoProvider::OpenRouter);
assert_eq!(
new.secret_key(),
SecretKey::Provider(CloudProvider::OpenRouter)
);
assert_eq!(
new.openrouter.model_name.as_deref(),
Some("google/gemini-3.5-flash")
);
assert_eq!(new.model_name.as_deref(), Some("gemini-2.5-pro"));
let written = serde_json::to_value(&new).unwrap();
assert_eq!(written["provider"], "openrouter");
assert_eq!(written["openrouter"]["api_key_env"], "MY_ROUTER");
for other in ["open_router", "OpenRouter", "google"] {
let asked = format!(r#"{{"provider":"{other}"}}"#);
assert!(
serde_json::from_str::<VideoSettings>(&asked).is_err(),
"{other} is not a provider"
);
}
}
#[test]
fn the_gateways_video_section_starts_empty_and_the_selector_cycles() {
let video = VideoSettings::default();
assert_eq!(video.provider, VideoProvider::Gemini);
assert_eq!(video.openrouter, VideoGatewaySettings::default());
assert_eq!(video.model_name.as_deref(), Some(DEFAULT_VIDEO_MODEL));
for dir in [1, -1] {
assert_eq!(
VideoProvider::Gemini.cycle(dir),
VideoProvider::OpenRouter,
"{dir}"
);
assert_eq!(
VideoProvider::OpenRouter.cycle(dir),
VideoProvider::Gemini,
"{dir}"
);
}
let labels: Vec<&str> = VideoProvider::ALL.into_iter().map(|p| p.label()).collect();
assert_eq!(labels, ["gemini", "openrouter"]);
for provider in VideoProvider::ALL {
let written = serde_json::to_value(provider).unwrap();
assert_eq!(
written,
provider.label(),
"the label is the file's spelling"
);
}
}
#[test]
fn the_gateways_video_key_variable_is_named_among_the_secrets() {
let mut c = AppConfig::default();
c.video.api_key_env = Some("MY_GEMINI_VIDEO".into());
c.video.openrouter.api_key_env = Some(" MY_ROUTER_VIDEO ".into());
let named = named_key_env_vars(&c);
for name in ["MY_GEMINI_VIDEO", "MY_ROUTER_VIDEO"] {
assert!(named.contains(&name.to_string()), "{name}: {named:?}");
}
}
#[test]
fn the_gateways_speech_key_variable_is_named_among_the_secrets() {
let mut c = AppConfig::default();
c.tts.openrouter.api_key_env = Some(" MY_ROUTER_VOICE ".into());
assert!(
named_key_env_vars(&c).contains(&"MY_ROUTER_VOICE".to_string()),
"{:?}",
named_key_env_vars(&c)
);
}
}