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 = 3;
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudProvider {
OpenAi,
Gemini,
Claude,
Grok,
}
impl CloudProvider {
pub const ALL: [CloudProvider; 4] = [
CloudProvider::OpenAi,
CloudProvider::Gemini,
CloudProvider::Claude,
CloudProvider::Grok,
];
fn index(self) -> usize {
match self {
CloudProvider::OpenAi => 0,
CloudProvider::Gemini => 1,
CloudProvider::Claude => 2,
CloudProvider::Grok => 3,
}
}
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",
}
}
pub fn chat_base_url(self) -> &'static str {
match self {
CloudProvider::Gemini => "https://generativelanguage.googleapis.com/v1beta",
CloudProvider::OpenAi | CloudProvider::Claude | CloudProvider::Grok => self.base_url(),
}
}
pub fn key(self) -> &'static str {
match self {
CloudProvider::OpenAi => "openai",
CloudProvider::Gemini => "gemini",
CloudProvider::Claude => "claude",
CloudProvider::Grok => "grok",
}
}
pub fn display_name(self) -> &'static str {
match self {
CloudProvider::OpenAi => "OpenAI",
CloudProvider::Gemini => "Gemini",
CloudProvider::Claude => "Claude",
CloudProvider::Grok => "Grok",
}
}
}
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::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",
}
}
pub fn from_key(key: &str) -> Option<Self> {
ServerMode::ALL.into_iter().find(|m| m.key() == key)
}
pub const ALL: [ServerMode; 6] = [
ServerMode::Managed,
ServerMode::External,
ServerMode::OpenAi,
ServerMode::Gemini,
ServerMode::Claude,
ServerMode::Grok,
];
pub fn supports_continuation(self, model: Option<&str>, catalogued: bool) -> bool {
match self {
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,
}
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,
}
}
}
#[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(),
]
};
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(),
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(),
cfg.tts.openai.api_key_env.clone(),
cfg.tts.gemini.api_key_env.clone(),
cfg.tts.external.api_key_env.clone(),
cfg.tools.web_tavily_key_env.clone(),
cfg.video.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,
}
impl EngineSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[&self.openai, &self.gemini, &self.claude, &self.grok],
)
}
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,
],
)
}
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 => {
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 => 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 => 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,
}
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::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,
}
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(),
}
}
}
impl ImpersonationEngineSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[&self.openai, &self.gemini, &self.claude, &self.grok],
)
}
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,
],
)
}
}
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,
}
impl Default for ManagedEmbedSettings {
fn default() -> Self {
Self {
binary: None,
model_path: None,
gpu_layers: DEFAULT_GPU_LAYERS,
port: DEFAULT_EMBED_PORT,
}
}
}
#[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 convention: EmbedConvention,
}
impl EmbedSettings {
pub fn cloud(&self) -> Option<&CloudSettings> {
cloud_ref(
self.mode.cloud_provider(),
[&self.openai, &self.gemini, &self.claude, &self.grok],
)
}
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,
],
)
}
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 => 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, 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, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct InterfaceSettings {
pub theme: Theme,
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,
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,
}
impl Default for InterfaceSettings {
fn default() -> Self {
Self {
theme: Theme::default(),
spellcheck_enabled: true,
selected_dictionaries: Vec::new(),
confirm_destructive_keys: false,
terminal_compat: false,
table_row_separators: false,
render_mermaid: true,
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(),
}
}
}
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, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoSettings {
pub model_name: Option<String>,
pub url: Option<String>,
pub api_key_env: Option<String>,
pub media_resolution: MediaResolution,
pub max_minutes: u32,
}
impl Default for VideoSettings {
fn default() -> Self {
Self {
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,
External,
}
impl TtsMode {
pub const ALL: [TtsMode; 3] = [TtsMode::OpenAi, TtsMode::Gemini, TtsMode::External];
pub fn label(self) -> &'static str {
match self {
TtsMode::OpenAi => "openai",
TtsMode::Gemini => "gemini",
TtsMode::External => "external",
}
}
pub fn cloud_provider(self) -> Option<CloudProvider> {
match self {
TtsMode::OpenAi => Some(CloudProvider::OpenAi),
TtsMode::Gemini => Some(CloudProvider::Gemini),
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 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()
},
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::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::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 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 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(),
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")
);
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!(!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,
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,
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,
},
..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 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);
}
}