vibe/vibe/core/config/_settings.py
Clément Drouin ad0d5c9520
v2.13.0 (#733)
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Peter Evers <peter.evers@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-29 16:17:54 +02:00

1087 lines
36 KiB
Python

from __future__ import annotations
from collections.abc import MutableMapping
from enum import StrEnum, auto
import os
from pathlib import Path
import re
import shlex
import tomllib
from typing import Annotated, Any, Literal, get_args
from urllib.parse import urljoin
from dotenv import dotenv_values
from mistralai.client.models import SpeechOutputFormat
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH,
)
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.fields import FieldInfo
from pydantic_core import to_jsonable_python
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from textual.theme import BUILTIN_THEMES
import tomli_w
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.types import Backend
from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
def _strip_bash_pattern_wildcard(pattern: str) -> str:
if pattern.endswith(" *"):
return pattern[:-2]
return pattern
def deep_update(
mapping: dict[str, Any], updating_mapping: dict[str, Any]
) -> dict[str, Any]:
merged = dict(mapping)
for key, value in updating_mapping.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = deep_update(merged[key], value)
else:
merged[key] = value
return merged
def load_dotenv_values(
env_path: Path = GLOBAL_ENV_FILE.path,
environ: MutableMapping[str, str] = os.environ,
) -> None:
# We allow FIFO path to support some environment management solutions (e.g. https://developer.1password.com/docs/environments/local-env-file/)
if not env_path.is_file() and not env_path.is_fifo():
return
env_vars = dotenv_values(env_path)
for key, value in env_vars.items():
if not value:
continue
environ.update({key: value})
class MissingAPIKeyError(RuntimeError):
def __init__(self, env_key: str, provider_name: str) -> None:
super().__init__(
f"Missing {env_key} environment variable for {provider_name} provider"
)
self.env_key = env_key
self.provider_name = provider_name
class TomlFileSettingsSource(PydanticBaseSettingsSource):
def __init__(self, settings_cls: type[BaseSettings]) -> None:
super().__init__(settings_cls)
self.toml_data = self._load_toml()
def _load_toml(self) -> dict[str, Any]:
file = get_harness_files_manager().config_file
if file is None:
return {}
try:
with file.open("rb") as f:
return tomllib.load(f)
except FileNotFoundError:
return {}
except tomllib.TOMLDecodeError as e:
raise RuntimeError(f"Invalid TOML in {file}: {e}") from e
except OSError as e:
raise RuntimeError(f"Cannot read {file}: {e}") from e
def get_field_value(
self, field: FieldInfo, field_name: str
) -> tuple[Any, str, bool]:
return self.toml_data.get(field_name), field_name, False
def __call__(self) -> dict[str, Any]:
return self.toml_data
def _remove_none_values(value: Any) -> Any:
if isinstance(value, dict):
return {
key: cleaned_value
for key, item in value.items()
if (cleaned_value := _remove_none_values(item)) is not None
}
if isinstance(value, list):
return [
cleaned_item
for item in value
if (cleaned_item := _remove_none_values(item)) is not None
]
return value
def _to_toml_document(value: Any) -> dict[str, Any]:
jsonable = to_jsonable_python(value, fallback=str)
if not isinstance(jsonable, dict):
return {}
return _remove_none_values(jsonable)
class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
default_commit_count: int = 5
timeout_seconds: float = 2.0
class ExperimentsConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
enable: bool = True
api_host: str = "https://experiments.mistral.services/"
client_key: str = "sdk-OE8yJgTXZY6tj"
class SessionLoggingConfig(BaseSettings):
save_dir: str = ""
session_prefix: str = "session"
enabled: bool = True
@field_validator("save_dir", mode="before")
@classmethod
def set_default_save_dir(cls, v: str) -> str:
if not v:
return str(SESSION_LOG_DIR.path)
return v
@field_validator("save_dir", mode="after")
@classmethod
def expand_save_dir(cls, v: str) -> str:
return str(Path(v).expanduser().resolve())
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai"
class ProviderConfig(BaseModel):
name: str
api_base: str
api_key_env_var: str = ""
browser_auth_base_url: str | None = None
browser_auth_api_base_url: str | None = None
api_style: str = "openai"
backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content"
project_id: str = ""
region: str = ""
extra_headers: dict[str, str] = Field(default_factory=dict)
def _is_legacy_mistral_provider_without_backend(self) -> bool:
return (
self.name == "mistral"
and self.backend == Backend.GENERIC
and "backend" not in self.model_fields_set
)
def _uses_mistral_browser_sign_in_defaults(self) -> bool:
return self.name == "mistral" and (
self.backend == Backend.MISTRAL
or self._is_legacy_mistral_provider_without_backend()
)
@model_validator(mode="after")
def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig:
if not self._uses_mistral_browser_sign_in_defaults():
return self
if self.browser_auth_base_url is None:
self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
if self.browser_auth_api_base_url is None:
self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
return self
@property
def supports_browser_sign_in(self) -> bool:
return (
(self.backend == Backend.MISTRAL or self.name == "mistral")
and bool(self.browser_auth_base_url)
and bool(self.browser_auth_api_base_url)
)
class TranscribeClient(StrEnum):
MISTRAL = auto()
class TranscribeProviderConfig(BaseModel):
name: str
api_base: str = "wss://api.mistral.ai"
api_key_env_var: str = ""
client: TranscribeClient = TranscribeClient.MISTRAL
class _MCPBase(BaseModel):
name: str = Field(description="Short alias used to prefix tool names")
prompt: str | None = Field(
default=None, description="Optional usage hint appended to tool descriptions"
)
startup_timeout_sec: float = Field(
default=10.0,
gt=0,
description="Timeout in seconds for the server to start and initialize.",
)
tool_timeout_sec: float = Field(
default=60.0, gt=0, description="Timeout in seconds for tool execution."
)
sampling_enabled: bool = Field(
default=True,
description="Allow this MCP server to request LLM completions via sampling/createMessage.",
)
disabled: bool = Field(
default=False,
description="Disable all tools from this MCP server. Tools are still discovered but hidden.",
)
disabled_tools: list[str] = Field(
default_factory=list,
description=(
"Tool names (without the server prefix) to disable from this server. "
"E.g. ['search', 'read'] to hide '{alias}_search' and '{alias}_read'."
),
)
@field_validator("name", mode="after")
@classmethod
def normalize_name(cls, v: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", v)
normalized = normalized.strip("_-")
return normalized[:256]
class _MCPHttpFields(BaseModel):
url: str = Field(description="Base URL of the MCP HTTP server")
headers: dict[str, str] = Field(
default_factory=dict,
description=(
"Additional HTTP headers when using 'http' transport (e.g., Authorization or X-API-Key)."
),
)
api_key_env: str = Field(
default="",
description=(
"Environment variable name containing an API token to send for HTTP transport."
),
)
api_key_header: str = Field(
default="Authorization",
description=(
"HTTP header name to carry the token when 'api_key_env' is set (e.g., 'Authorization' or 'X-API-Key')."
),
)
api_key_format: str = Field(
default="Bearer {token}",
description=(
"Format string for the header value when 'api_key_env' is set. Use '{token}' placeholder."
),
)
def http_headers(self) -> dict[str, str]:
hdrs = dict(self.headers or {})
env_var = (self.api_key_env or "").strip()
if env_var and (token := os.getenv(env_var)):
target = (self.api_key_header or "").strip() or "Authorization"
if not any(h.lower() == target.lower() for h in hdrs):
try:
value = (self.api_key_format or "{token}").format(token=token)
except Exception:
value = token
hdrs[target] = value
return hdrs
class MCPHttp(_MCPBase, _MCPHttpFields):
transport: Literal["http"]
class MCPStreamableHttp(_MCPBase, _MCPHttpFields):
transport: Literal["streamable-http"]
class MCPStdio(_MCPBase):
transport: Literal["stdio"]
command: str | list[str]
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(
default_factory=dict,
description="Environment variables to set for the MCP server process.",
)
cwd: str | None = Field(
default=None, description="Working directory for the MCP server process."
)
def argv(self) -> list[str]:
base = (
shlex.split(self.command)
if isinstance(self.command, str)
else list(self.command or [])
)
return [*base, *self.args] if self.args else base
MCPServer = Annotated[
MCPHttp | MCPStreamableHttp | MCPStdio, Field(discriminator="transport")
]
class ConnectorConfig(BaseModel):
name: str = Field(description="Normalized connector alias to match against.")
disabled: bool = Field(
default=False,
description="Disable all tools from this connector. Tools are still discovered but hidden.",
)
disabled_tools: list[str] = Field(
default_factory=list,
description=(
"Tool names (without the connector prefix) to disable. "
"E.g. ['search'] to hide 'connector_{name}_search'."
),
)
def _default_alias_to_name(data: Any) -> Any:
if isinstance(data, dict):
if "alias" not in data or data["alias"] is None:
data["alias"] = data.get("name")
return data
ThinkingLevel = Literal["off", "low", "medium", "high", "max"]
THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel))
class ModelConfig(BaseModel):
name: str
provider: str
alias: str
temperature: float = 0.2
input_price: float = 0.0 # Price per million input tokens
output_price: float = 0.0 # Price per million output tokens
thinking: ThinkingLevel = "off"
auto_compact_threshold: int = 200_000
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class TranscribeModelConfig(BaseModel):
name: str
provider: str
alias: str
sample_rate: int = 16000
encoding: Literal["pcm_s16le"] = "pcm_s16le"
language: str = "en"
target_streaming_delay_ms: int = 500
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class TTSClient(StrEnum):
MISTRAL = auto()
class TTSProviderConfig(BaseModel):
name: str
api_base: str = "https://api.mistral.ai"
api_key_env_var: str = ""
client: TTSClient = TTSClient.MISTRAL
class TTSModelConfig(BaseModel):
name: str
provider: str
alias: str
voice: str = "gb_jane_neutral"
response_format: SpeechOutputFormat = "wav"
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class OtelSpanExporterConfig(BaseModel):
model_config = ConfigDict(frozen=True)
endpoint: str
headers: dict[str, str] | None = None
MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_PROVIDERS = [
ProviderConfig(
name="mistral",
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
backend=Backend.MISTRAL,
),
ProviderConfig(
name="llamacpp",
api_base="http://127.0.0.1:8080/v1",
api_key_env_var="", # NOTE: if you wish to use --api-key in llama-server, change this value
),
]
DEFAULT_MODELS = [
ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
alias="mistral-medium-3.5",
temperature=1.0,
input_price=1.5,
output_price=7.5,
thinking="high",
),
ModelConfig(
name="devstral-small-latest",
provider="mistral",
alias="devstral-small",
input_price=0.1,
output_price=0.3,
),
ModelConfig(
name="devstral",
provider="llamacpp",
alias="local",
input_price=0.0,
output_price=0.0,
),
]
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig(
name="mistral",
api_base="wss://api.mistral.ai",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
)
]
DEFAULT_TRANSCRIBE_MODELS = [
TranscribeModelConfig(
name="voxtral-mini-transcribe-realtime-2602",
provider="mistral",
alias="voxtral-realtime",
)
]
DEFAULT_TTS_PROVIDERS = [
TTSProviderConfig(
name="mistral",
api_base="https://api.mistral.ai",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
)
]
DEFAULT_TTS_MODELS = [
TTSModelConfig(
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
)
]
DEFAULT_THEME = "ansi-dark"
class VibeConfig(BaseSettings):
active_model: str = DEFAULT_ACTIVE_MODEL
vim_keybindings: bool = False
theme: str = DEFAULT_THEME
disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True
file_watcher_for_autocomplete: bool = False
displayed_workdir: str = ""
context_warnings: bool = False
voice_mode_enabled: bool = False
narrator_enabled: bool = False
active_transcribe_model: str = "voxtral-realtime"
active_tts_model: str = "voxtral-tts"
bypass_tool_permissions: bool = False
enable_telemetry: bool = True
experiment_overrides: dict[str, str] = Field(default_factory=dict)
system_prompt_id: str = "cli"
compaction_prompt_id: str = "compact"
include_commit_signature: bool = True
include_model_info: bool = True
include_project_context: bool = True
include_prompt_detail: bool = True
enable_update_checks: bool = True
enable_auto_update: bool = True
enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = 720.0
auto_compact_threshold: int = 200_000
vibe_code_enabled: bool = Field(default=True, exclude=True)
vibe_code_base_url: str = Field(default="https://api.mistral.ai", exclude=True)
vibe_code_sessions_base_url: str = Field(
default="https://chat.mistral.ai", exclude=True
)
vibe_code_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
vibe_code_project_name: str | None = Field(default=None, exclude=True)
# TODO(otel): remove exclude=True once the feature is publicly available
enable_otel: bool = Field(default=False, exclude=True)
otel_endpoint: str = Field(default="", exclude=True)
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
vibe_base_url: str = Field(default=DEFAULT_VIBE_BASE_URL, exclude=True)
enable_experimental_hooks: bool = Field(default=False, exclude=True)
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
models: list[ModelConfig] = Field(default_factory=lambda: list(DEFAULT_MODELS))
compaction_model: ModelConfig | None = None
transcribe_providers: list[TranscribeProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)
)
transcribe_models: list[TranscribeModelConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
)
tts_providers: list[TTSProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_TTS_PROVIDERS)
)
tts_models: list[TTSModelConfig] = Field(
default_factory=lambda: list(DEFAULT_TTS_MODELS)
)
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
experiments: ExperimentsConfig = Field(default_factory=ExperimentsConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
tools: dict[str, dict[str, Any]] = Field(default_factory=dict)
tool_paths: list[Path] = Field(
default_factory=list,
description=(
"Additional directories or files to explore for custom tools. "
"Paths may be absolute or relative to the current working directory. "
"Directories are shallow-searched for tool definition files, "
"while files are loaded directly if valid."
),
)
mcp_servers: list[MCPServer] = Field(
default_factory=list, description="Preferred MCP server configuration entries."
)
enable_connectors: bool = Field(
default=True,
description=(
"Master switch for Mistral connectors. When False, no connector "
"tools are discovered or registered, regardless of provider/API key."
),
)
connectors: list[ConnectorConfig] = Field(
default_factory=list,
description="Per-connector settings (disable, disabled_tools).",
)
enabled_tools: list[str] = Field(
default_factory=list,
description=(
"An explicit list of tool names/patterns to enable. If set, only these"
" tools will be active. Supports glob patterns (e.g., 'serena_*') and"
" regex with 're:' prefix (e.g., 're:^serena_.*')."
),
)
disabled_tools: list[str] = Field(
default_factory=list,
description=(
"A list of tool names/patterns to disable. Ignored if 'enabled_tools'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
agent_paths: list[Path] = Field(
default_factory=list,
description=(
"Additional directories to search for custom agent profiles. "
"Each path may be absolute or relative to the current working directory."
),
)
enabled_agents: list[str] = Field(
default_factory=list,
description=(
"An explicit list of agent names/patterns to enable. If set, only these"
" agents will be available. Supports glob patterns (e.g., 'custom-*')"
" and regex with 're:' prefix."
),
)
disabled_agents: list[str] = Field(
default_factory=list,
description=(
"A list of agent names/patterns to disable. Ignored if 'enabled_agents'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
installed_agents: list[str] = Field(
default_factory=list,
description=(
"A list of opt-in builtin agent names that have been explicitly installed."
),
)
default_agent: str = Field(
default=BuiltinAgentName.DEFAULT,
description=(
"Agent profile to use when no --agent flag is passed. "
"Builtin: default, plan, accept-edits, auto-approve. "
"Applies in both interactive and programmatic (-p/--prompt) mode."
),
)
skill_paths: list[Path] = Field(
default_factory=list,
description=(
"Additional directories to search for skills. "
"Each path may be absolute or relative to the current working directory."
),
)
enabled_skills: list[str] = Field(
default_factory=list,
description=(
"An explicit list of skill names/patterns to enable. If set, only these"
" skills will be active. Supports glob patterns (e.g., 'search-*') and"
" regex with 're:' prefix."
),
)
disabled_skills: list[str] = Field(
default_factory=list,
description=(
"A list of skill names/patterns to disable. Ignored if 'enabled_skills'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("exclude_none", True)
return super().model_dump(**kwargs)
@property
def vibe_code_api_key(self) -> str:
return os.getenv(self.vibe_code_api_key_env_var, "")
@property
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
# Otherwise endpoint and API key are derived from the active provider if it's Mistral,
# or the first Mistral provider.
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
if self.otel_endpoint:
return OtelSpanExporterConfig(
endpoint=urljoin(
f"{self.otel_endpoint.rstrip('/')}/", traces_export_path
)
)
provider = self.get_mistral_provider()
if provider is not None:
server_url = get_server_url_from_api_base(provider.api_base)
api_key_env = provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY
else:
server_url = None
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
endpoint = urljoin(
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
traces_export_path,
)
if not (api_key := os.getenv(api_key_env)):
logger.warning(
"OTEL tracing enabled but %s is not set; skipping.", api_key_env
)
return None
return OtelSpanExporterConfig(
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
)
@property
def system_prompt(self) -> str:
return load_system_prompt(self.system_prompt_id)
@property
def compaction_prompt(self) -> str:
return load_prompt(
self.compaction_prompt_id,
setting_name="compaction_prompt_id",
builtins={"compact": UtilityPrompt.COMPACT.path},
)
def get_active_model(self) -> ModelConfig:
for model in self.models:
if model.alias == self.active_model:
return model
raise ValueError(
f"Active model '{self.active_model}' not found in configuration."
)
def get_compaction_model(self) -> ModelConfig:
if self.compaction_model is not None:
return self.compaction_model
return self.get_active_model()
def get_mistral_provider(self) -> ProviderConfig | None:
try:
active_provider = self.get_active_provider()
if active_provider.backend == Backend.MISTRAL:
return active_provider
except ValueError:
pass
return next((p for p in self.providers if p.backend == Backend.MISTRAL), None)
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"Provider '{model.provider}' for model '{model.name}' not found in configuration."
)
def get_active_provider(self) -> ProviderConfig:
return self.get_provider_for_model(self.get_active_model())
def is_active_model_mistral(self) -> bool:
try:
return self.get_active_provider().backend == Backend.MISTRAL
except ValueError:
return False
def get_active_transcribe_model(self) -> TranscribeModelConfig:
for model in self.transcribe_models:
if model.alias == self.active_transcribe_model:
return model
raise ValueError(
f"Active transcribe model '{self.active_transcribe_model}' not found in configuration."
)
def get_transcribe_provider_for_model(
self, model: TranscribeModelConfig
) -> TranscribeProviderConfig:
for provider in self.transcribe_providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
)
def get_active_tts_model(self) -> TTSModelConfig:
for model in self.tts_models:
if model.alias == self.active_tts_model:
return model
raise ValueError(
f"Active TTS model '{self.active_tts_model}' not found in configuration."
)
def get_tts_provider_for_model(self, model: TTSModelConfig) -> TTSProviderConfig:
for provider in self.tts_providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"TTS provider '{model.provider}' for TTS model '{model.name}' not found in configuration."
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Define the priority of settings sources.
Note: dotenv_settings is intentionally excluded. API keys and other
non-config environment variables are stored in .env but loaded manually
into os.environ for use by providers. Only VIBE_* prefixed environment
variables (via env_settings) and TOML config are used for Pydantic settings.
"""
return (
init_settings,
env_settings,
TomlFileSettingsSource(settings_cls),
file_secret_settings,
)
@model_validator(mode="after")
def _apply_global_auto_compact_threshold(self) -> VibeConfig:
self.models = [
model
if "auto_compact_threshold" in model.model_fields_set
else model.model_copy(
update={"auto_compact_threshold": self.auto_compact_threshold}
)
for model in self.models
]
return self
@model_validator(mode="after")
def _check_compaction_model_provider(self) -> VibeConfig:
if self.compaction_model is None:
return self
compaction_provider = self.get_provider_for_model(self.compaction_model)
try:
active_provider = self.get_active_provider()
except ValueError:
return self
if active_provider.name != compaction_provider.name:
raise ValueError(
f"Compaction model '{self.compaction_model.alias}' uses provider "
f"'{compaction_provider.name}' but active model uses provider "
f"'{active_provider.name}'. They must share the same provider."
)
return self
@model_validator(mode="after")
def _check_api_key(self) -> VibeConfig:
try:
provider = self.get_active_provider()
api_key_env = provider.api_key_env_var
if api_key_env and not os.getenv(api_key_env):
raise MissingAPIKeyError(api_key_env, provider.name)
except ValueError:
pass
return self
@field_validator("theme", mode="before")
@classmethod
def _validate_theme(cls, v: Any) -> str:
if not isinstance(v, str) or not v:
return DEFAULT_THEME
if v not in BUILTIN_THEMES:
logger.warning(
"Unknown theme=%s in config; falling back to %s", v, DEFAULT_THEME
)
return DEFAULT_THEME
return v
@field_validator("tool_paths", mode="before")
@classmethod
def _expand_tool_paths(cls, v: Any) -> list[Path]:
if not v:
return []
return [Path(p).expanduser().resolve() for p in v]
@field_validator("skill_paths", mode="before")
@classmethod
def _expand_skill_paths(cls, v: Any) -> list[Path]:
if not v:
return []
return [Path(p).expanduser().resolve() for p in v]
@field_validator("tools", mode="before")
@classmethod
def _normalize_tool_configs(cls, v: Any) -> dict[str, dict[str, Any]]:
if not isinstance(v, dict):
return {}
normalized: dict[str, dict[str, Any]] = {}
for tool_name, tool_config in v.items():
if isinstance(tool_config, dict):
normalized[tool_name] = tool_config
else:
normalized[tool_name] = {}
return normalized
@model_validator(mode="after")
def _validate_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate model alias found: '{model.alias}'. Aliases must be unique."
)
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _validate_transcribe_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.transcribe_models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate transcribe model alias found: '{model.alias}'. Aliases must be unique."
)
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _validate_tts_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.tts_models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate TTS model alias found: '{model.alias}'. Aliases must be unique."
)
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _check_system_prompt(self) -> VibeConfig:
_ = self.system_prompt
return self
@model_validator(mode="after")
def _check_compaction_prompt(self) -> VibeConfig:
_ = self.compaction_prompt
return self
def set_thinking(self, level: ThinkingLevel) -> None:
model = self.get_active_model()
for i, m in enumerate(self.models):
if m.alias == model.alias:
self.models[i] = m.model_copy(update={"thinking": level})
break
current_config = TomlFileSettingsSource(type(self)).toml_data
models = current_config.get("models", [])
for entry in models:
if entry.get("alias", entry.get("name")) == model.alias:
entry["thinking"] = level
break
else:
# Model comes from defaults; materialize the full list so we
# don't lose the other models.
models = [
{
"alias": m.alias,
"name": m.name,
"provider": m.provider,
"thinking": level if m.alias == model.alias else m.thinking,
}
for m in self.models
]
type(self).save_updates({"models": models})
def add_tool_allowlist_patterns(self, tool_name: str, patterns: list[str]) -> None:
if tool_name == "bash":
patterns = [_strip_bash_pattern_wildcard(p) for p in patterns]
current_allowlist: list[str] = list(
self.tools.get(tool_name, {}).get("allowlist", [])
)
new_patterns = [p for p in patterns if p not in current_allowlist]
if not new_patterns:
return
merged = sorted(current_allowlist + new_patterns)
self.save_updates({"tools": {tool_name: {"allowlist": merged}}})
if tool_name not in self.tools:
self.tools[tool_name] = {}
self.tools[tool_name]["allowlist"] = merged
@classmethod
def get_persisted_config(cls) -> dict[str, Any]:
return TomlFileSettingsSource(cls).toml_data
@classmethod
def save_updates(cls, updates: dict[str, Any]) -> None:
if not get_harness_files_manager().persist_allowed:
return
current_config = TomlFileSettingsSource(cls).toml_data
merged_config = deep_update(current_config, updates)
cls.dump_config(merged_config)
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
mgr = get_harness_files_manager()
if not mgr.persist_allowed:
return
target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True)
toml_document = _to_toml_document(config)
cls.model_validate(toml_document)
with target.open("wb") as f:
tomli_w.dump(toml_document, f)
@classmethod
def _migrate(cls) -> None:
mgr = get_harness_files_manager()
if not mgr.persist_allowed:
return
file = mgr.config_file
if file is None:
return
try:
with file.open("rb") as f:
data = tomllib.load(f)
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
return
changed = False
bash_tools = data.get("tools", {}).get("bash", {})
allowlist = bash_tools.get("allowlist")
if allowlist is not None and "find" not in allowlist:
allowlist.append("find")
allowlist.sort()
changed = True
if allowlist is not None and any(p.endswith(" *") for p in allowlist):
stripped = [_strip_bash_pattern_wildcard(p) for p in allowlist]
deduped = sorted(set(stripped))
bash_tools["allowlist"] = deduped
changed = True
for model in data.get("models", []):
if (
model.get("name") == "mistral-vibe-cli-latest"
and model.get("alias") == "devstral-2"
):
model["alias"] = "mistral-medium-3.5"
model["temperature"] = 1.0
model["input_price"] = 1.5
model["output_price"] = 7.5
model["thinking"] = "high"
changed = True
if data.get("active_model") == "devstral-2":
data["active_model"] = "mistral-medium-3.5"
changed = True
if changed:
cls.dump_config(data)
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()
config = cls(**(overrides or {}))
configure_ssl_context(
enable_system_trust_store=config.enable_system_trust_store
)
return config
@classmethod
def create_default(cls) -> dict[str, Any]:
config = cls.model_construct()
config_dict = config.model_dump(mode="json")
from vibe.core.tools.manager import ToolManager
tool_defaults = ToolManager.discover_tool_defaults()
if tool_defaults:
config_dict["tools"] = tool_defaults
return config_dict