mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
b513bf3b2a
Users who configure a process-wide MCP allowlist and also choose “Allow for this project — until changed” are now prompted only once (fixes an issue where this was previously discarded). --- A user can intentionally have two kinds of MCP approval at the same time: - Process-wide server names in `DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS`, which bypass project and server-definition binding. - Project-scoped approvals saved by the startup prompt, which match only the same project root, server name, and server-definition fingerprint. ### Before 1. The user starts `dcode` in a project whose MCP servers are not in the process-wide allowlist. 2. They choose “Allow for this project — until changed.” 3. `dcode` writes valid scoped approvals and reports: Allowing 2 project MCP servers for this session; remembering 2 for this project. 4. On the next launch, the presence of the process-wide env var causes the loader to ignore every saved scoped approval, even when the env var contains unrelated names. 5. The same “Approve project MCP servers” prompt appears again. ### After - Process-wide names and scoped remembered approvals are independent grants, so both remain active. - The next launch recognizes the saved project/server fingerprints and does not prompt again. - An explicitly empty process-wide allowlist contributes no global names but does not clear remembered project approvals. - A changed server command or URL still requires re-approval, and disabled server names still take precedence over every approval source. Regression coverage reproduces the repeated-launch case and verifies both populated and empty process-wide allowlists.
4801 lines
184 KiB
Python
4801 lines
184 KiB
Python
"""Model configuration management.
|
|
|
|
Handles loading and saving model configuration from TOML files, providing a
|
|
structured way to define available models and providers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import tomllib
|
|
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypedDict, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import tomli_w
|
|
|
|
from deepagents_code import _env_vars, auth_store
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
from deepagents_code.json_types import JsonValue
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ENV_PREFIX = "DEEPAGENTS_CODE_"
|
|
|
|
|
|
def resolved_env_var_name(canonical: str) -> str:
|
|
"""Return whichever env var name actually carries the resolved value.
|
|
|
|
Mirrors `resolve_env_var`'s precedence: when the prefixed variant is
|
|
present in `os.environ` (even empty), it wins; otherwise the canonical
|
|
name is returned. Useful for UI labels that need to reflect what the
|
|
app is actually reading rather than the canonical name.
|
|
|
|
Args:
|
|
canonical: The canonical environment variable name.
|
|
|
|
Returns:
|
|
The resolving env var name (prefixed or canonical).
|
|
"""
|
|
if not canonical.startswith(_ENV_PREFIX):
|
|
prefixed = f"{_ENV_PREFIX}{canonical}"
|
|
if prefixed in os.environ:
|
|
return prefixed
|
|
return canonical
|
|
|
|
|
|
def resolve_env_var(name: str) -> str | None:
|
|
"""Look up an env var with `DEEPAGENTS_CODE_` prefix override.
|
|
|
|
Checks `DEEPAGENTS_CODE_{name}` first, then falls back to `{name}`.
|
|
|
|
If the prefixed variable is *present* in the environment (even as an empty
|
|
string), the canonical variable is never consulted. This lets users
|
|
set `DEEPAGENTS_CODE_X=""` to shadow a canonically-set key -- the function
|
|
will return `None` (since empty strings are normalized to `None`),
|
|
effectively suppressing the canonical value.
|
|
|
|
If `name` already carries the prefix, the double-prefixed lookup is skipped
|
|
to avoid nonsensical `DEEPAGENTS_CODE_DEEPAGENTS_CODE_*` reads
|
|
(e.g., when the name comes from a user's `config.toml`).
|
|
|
|
Args:
|
|
name: The canonical environment variable name (e.g.
|
|
`ANTHROPIC_API_KEY`).
|
|
|
|
Returns:
|
|
The resolved value, or `None` when absent or empty.
|
|
"""
|
|
if not name.startswith(_ENV_PREFIX):
|
|
prefixed = f"{_ENV_PREFIX}{name}"
|
|
if prefixed in os.environ:
|
|
val = os.environ[prefixed]
|
|
if not val and os.environ.get(name):
|
|
logger.debug(
|
|
"%s is set but empty, blocking non-empty %s. "
|
|
"Unset %s to use the canonical variable.",
|
|
prefixed,
|
|
name,
|
|
prefixed,
|
|
)
|
|
if val:
|
|
logger.debug("Resolved %s from %s", name, prefixed)
|
|
return val or None
|
|
return os.environ.get(name) or None
|
|
|
|
|
|
PROVIDERS_DOCS_URL = (
|
|
"https://docs.langchain.com/oss/python/deepagents/code/providers#provider-reference"
|
|
)
|
|
"""Public docs page for configuring model providers.
|
|
|
|
Referenced by `UnknownProviderError` and the `/auth` manager so the same
|
|
URL is used everywhere a user is sent to read about provider setup.
|
|
"""
|
|
|
|
|
|
class ModelConfigError(Exception):
|
|
"""Raised when model configuration or creation fails."""
|
|
|
|
|
|
class NoCredentialsConfiguredError(ModelConfigError):
|
|
"""Raised when no credentials are configured for any default-resolvable provider.
|
|
|
|
Distinct from `MissingCredentialsError` (which targets a specific provider
|
|
the user has selected): this fires from `_get_default_model_spec()` when
|
|
auto-detection finds no usable credentials at all. Callers (the deferred-
|
|
start path in the TUI and CLI) `isinstance`-check this type to recover by
|
|
launching the TUI with model creation deferred, rather than string-matching
|
|
the formatted message.
|
|
"""
|
|
|
|
|
|
class UnknownProviderError(ModelConfigError):
|
|
"""Raised when neither the app nor `init_chat_model` can infer a provider.
|
|
|
|
Carries the offending model spec as an attribute and exposes
|
|
`PROVIDERS_DOCS_URL` as a class-level constant so callers can render
|
|
a clickable link without string-scanning the formatted message. This
|
|
mirrors how `MissingCredentialsError` exposes `provider` / `env_var`
|
|
for targeted recovery hints.
|
|
"""
|
|
|
|
docs_url: ClassVar[str] = PROVIDERS_DOCS_URL
|
|
"""Provider-reference docs URL. Class-level so callers don't pass it."""
|
|
|
|
def __init__(self, *, model_spec: str) -> None:
|
|
"""Initialize the error.
|
|
|
|
Args:
|
|
model_spec: The bare model name the user supplied (e.g.
|
|
`'mystery-model'`). When the input had a `provider:model`
|
|
form, parsing succeeds and this exception does not fire.
|
|
|
|
Raises:
|
|
ValueError: If `model_spec` is empty.
|
|
"""
|
|
if not model_spec:
|
|
msg = "model_spec must be non-empty"
|
|
raise ValueError(msg)
|
|
message = (
|
|
f"Unable to infer a model provider for {model_spec!r}. "
|
|
f"Specify one explicitly (e.g. 'anthropic:{model_spec}') "
|
|
f"or see the provider reference at {self.docs_url}."
|
|
)
|
|
super().__init__(message)
|
|
self.model_spec = model_spec
|
|
|
|
|
|
class MissingCredentialsError(ModelConfigError):
|
|
"""Raised when a provider is selected but its API key env var is unset.
|
|
|
|
Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks
|
|
keep working. Carries the `provider` name and the canonical `env_var` so
|
|
callers can render targeted recovery hints (e.g., "set OPENAI_API_KEY" or
|
|
"run `/model <other_provider>:<model>`") without string-matching on the
|
|
formatted exception message and without re-deriving the env-var name.
|
|
"""
|
|
|
|
def __init__(
|
|
self, message: str, *, provider: str, env_var: str | None = None
|
|
) -> None:
|
|
"""Initialize the error.
|
|
|
|
Args:
|
|
message: Human-readable message describing the missing credential.
|
|
provider: The provider whose credentials are missing
|
|
(e.g., `'openai'`).
|
|
env_var: The canonical env var name expected to hold the
|
|
credential (e.g., `'OPENAI_API_KEY'`). `None` when the
|
|
provider has no registered env-var mapping.
|
|
"""
|
|
super().__init__(message)
|
|
self.provider = provider
|
|
self.env_var = env_var
|
|
|
|
|
|
class MissingProviderPackageError(ModelConfigError):
|
|
"""Raised when a provider is selected but its LangChain package is not installed.
|
|
|
|
Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks
|
|
keep working. Carries the `provider` name and the `package` to install so
|
|
callers can render targeted recovery hints (e.g., suggest `/install fireworks`
|
|
or the `/model` slash command) without string-matching on the formatted
|
|
exception message.
|
|
"""
|
|
|
|
def __init__(self, message: str, *, provider: str, package: str) -> None:
|
|
"""Initialize the error.
|
|
|
|
Args:
|
|
message: Human-readable message describing the missing package.
|
|
provider: The provider whose package is missing (e.g., `'fireworks'`).
|
|
package: The pip-installable package name (e.g.,
|
|
`'langchain-fireworks'`).
|
|
"""
|
|
super().__init__(message)
|
|
self.provider = provider
|
|
self.package = package
|
|
|
|
|
|
class ProviderAuthState(StrEnum):
|
|
"""Credential readiness state for a model provider."""
|
|
|
|
CONFIGURED = "configured"
|
|
"""An explicit credential source is configured and non-empty."""
|
|
|
|
MISSING = "missing"
|
|
"""An explicit credential source is required but missing."""
|
|
|
|
NOT_REQUIRED = "not_required"
|
|
"""This provider configuration does not require API-key credentials."""
|
|
|
|
IMPLICIT = "implicit"
|
|
"""The provider supports ambient auth outside CLI env-var checks."""
|
|
|
|
MANAGED = "managed"
|
|
"""A custom provider class is expected to manage auth itself."""
|
|
|
|
UNKNOWN = "unknown"
|
|
"""The app cannot determine whether provider auth is ready."""
|
|
|
|
|
|
class ProviderAuthSource(StrEnum):
|
|
"""Origin of a `CONFIGURED` credential, used to discriminate display."""
|
|
|
|
STORED = "stored"
|
|
"""Persisted in a local credential store under `~/.deepagents/.state`.
|
|
|
|
Usually the `/auth` API-key map (`auth.json`), but also covers the
|
|
file-backed ChatGPT OAuth token used by the codex provider
|
|
(`chatgpt-auth.json`).
|
|
"""
|
|
|
|
ENV = "env"
|
|
"""Resolved from an environment variable."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderAuthStatus:
|
|
"""Credential readiness information for a provider.
|
|
|
|
Args:
|
|
state: Provider auth state.
|
|
provider: Provider name.
|
|
env_var: Env var name associated with the state, when applicable.
|
|
source: For `CONFIGURED` states, where the credential value came
|
|
from. `None` for non-configured states or when the source is
|
|
not meaningful (e.g., implicit/managed auth).
|
|
detail: Short user-facing context for selectors and logs.
|
|
"""
|
|
|
|
state: ProviderAuthState
|
|
provider: str
|
|
env_var: str | None = None
|
|
source: ProviderAuthSource | None = None
|
|
detail: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Enforce the source-vs-state invariant.
|
|
|
|
Raises:
|
|
ValueError: If `source` is set but `state` is not `CONFIGURED`,
|
|
or if `state` is `CONFIGURED` but no `source` is recorded.
|
|
"""
|
|
is_configured = self.state is ProviderAuthState.CONFIGURED
|
|
has_source = self.source is not None
|
|
if is_configured != has_source:
|
|
msg = (
|
|
f"ProviderAuthStatus invariant violated: "
|
|
f"state={self.state!r} requires "
|
|
f"{'a source' if is_configured else 'source=None'}, "
|
|
f"got source={self.source!r}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
@property
|
|
def blocks_start(self) -> bool:
|
|
"""Whether this status should block model creation or switching."""
|
|
return self.state is ProviderAuthState.MISSING
|
|
|
|
def as_legacy_bool(self) -> bool | None:
|
|
"""Return the historic `has_provider_credentials` tri-state value."""
|
|
if self.state is ProviderAuthState.MISSING:
|
|
return False
|
|
if self.state is ProviderAuthState.UNKNOWN:
|
|
return None
|
|
return True
|
|
|
|
def missing_detail(self) -> str:
|
|
"""Return a user-facing reason for a missing-credential status."""
|
|
if self.env_var:
|
|
return f"{self.env_var} is not set or is empty"
|
|
if self.detail:
|
|
return self.detail
|
|
return (
|
|
f"provider '{self.provider}' is not recognized. "
|
|
"Add it to ~/.deepagents/config.toml with an api_key_env field"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelSpec:
|
|
"""A model specification in `provider:model` format.
|
|
|
|
Examples:
|
|
>>> spec = ModelSpec.parse("anthropic:claude-sonnet-4-5")
|
|
>>> spec.provider
|
|
'anthropic'
|
|
>>> spec.model
|
|
'claude-sonnet-4-5'
|
|
>>> str(spec)
|
|
'anthropic:claude-sonnet-4-5'
|
|
"""
|
|
|
|
provider: str
|
|
"""The provider name (e.g., `'anthropic'`, `'openai'`)."""
|
|
|
|
model: str
|
|
"""The model identifier (e.g., `'claude-sonnet-4-5'`, `'gpt-5.5'`)."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Validate the model spec after initialization.
|
|
|
|
Raises:
|
|
ValueError: If provider or model is empty.
|
|
"""
|
|
if not self.provider:
|
|
msg = "Provider cannot be empty"
|
|
raise ValueError(msg)
|
|
if not self.model:
|
|
msg = "Model cannot be empty"
|
|
raise ValueError(msg)
|
|
|
|
@classmethod
|
|
def parse(cls, spec: str) -> ModelSpec:
|
|
"""Parse a model specification string.
|
|
|
|
Args:
|
|
spec: Model specification in `'provider:model'` format.
|
|
|
|
Returns:
|
|
Parsed ModelSpec instance.
|
|
|
|
Raises:
|
|
ValueError: If the spec is not in valid `'provider:model'` format.
|
|
"""
|
|
if ":" not in spec:
|
|
msg = (
|
|
f"Invalid model spec '{spec}': must be in provider:model format "
|
|
"(e.g., 'anthropic:claude-sonnet-4-5')"
|
|
)
|
|
raise ValueError(msg)
|
|
provider, model = spec.split(":", 1)
|
|
return cls(provider=provider, model=model)
|
|
|
|
@classmethod
|
|
def try_parse(cls, spec: str) -> ModelSpec | None:
|
|
"""Non-raising variant of `parse`.
|
|
|
|
Args:
|
|
spec: Model specification in `provider:model` format.
|
|
|
|
Returns:
|
|
Parsed `ModelSpec`, or `None` when *spec* is not valid.
|
|
"""
|
|
try:
|
|
return cls.parse(spec)
|
|
except ValueError:
|
|
return None
|
|
|
|
def __str__(self) -> str:
|
|
"""Return the model spec as a string in `provider:model` format."""
|
|
return f"{self.provider}:{self.model}"
|
|
|
|
|
|
class ModelProfileEntry(TypedDict):
|
|
"""Profile data for a model with override tracking."""
|
|
|
|
profile: dict[str, Any]
|
|
"""Merged profile dict (upstream defaults + config.toml overrides).
|
|
|
|
Keys vary by provider (e.g., `max_input_tokens`, `tool_calling`).
|
|
"""
|
|
|
|
overridden_keys: frozenset[str]
|
|
"""Keys in `profile` whose values came from config.toml rather than the
|
|
upstream provider package."""
|
|
|
|
|
|
class ProviderConfig(TypedDict, total=False):
|
|
"""Configuration for a model provider.
|
|
|
|
The optional `class_path` field allows bypassing `init_chat_model` entirely
|
|
and instantiating an arbitrary `BaseChatModel` subclass via importlib.
|
|
|
|
!!! warning
|
|
|
|
Setting `class_path` executes arbitrary Python code from the user's
|
|
config file. This has the same trust model as `pyproject.toml` build
|
|
scripts — the user controls their own machine.
|
|
"""
|
|
|
|
enabled: bool
|
|
"""Whether this provider appears in the model switcher.
|
|
|
|
Defaults to `True`. Set to `False` to hide a package-discovered provider
|
|
and all its models from the `/model` selector. Useful when a LangChain
|
|
provider package is installed as a transitive dependency but should not
|
|
be user-visible.
|
|
"""
|
|
|
|
models: list[str]
|
|
"""List of model identifiers available from this provider."""
|
|
|
|
api_key_env: str
|
|
"""Name of the environment variable that holds the API key.
|
|
|
|
This is the env var *name* (e.g., `"OPENAI_API_KEY"`), not the secret
|
|
itself. The app resolves it at startup to verify credentials before model
|
|
creation.
|
|
"""
|
|
|
|
display_name: str
|
|
"""Human-readable provider name shown in auth UI.
|
|
|
|
Useful for arbitrary providers whose config key is optimized for machine use
|
|
(e.g., `my_gateway`) but whose UI label should include spaces or brand
|
|
capitalization.
|
|
"""
|
|
|
|
short_name: str
|
|
"""Compact brand label for space-constrained UI (e.g. the `/model` Recent
|
|
tag), where the full `display_name` — which may carry a parenthetical
|
|
qualifier like `"OpenAI Codex (ChatGPT login)"` — is too long. Optional;
|
|
when unset, callers fall back to `display_name`.
|
|
"""
|
|
|
|
api_key_url: str
|
|
"""Provider page where users can create or manage API keys.
|
|
|
|
Used by `/auth` as an acquisition link before the API-key input. The value is
|
|
a URL, not a credential. Must use an `http` or `https` scheme to render as a
|
|
clickable link; values with other schemes are ignored with a warning.
|
|
"""
|
|
|
|
base_url: str
|
|
"""Custom base URL."""
|
|
|
|
base_url_env: str
|
|
"""Name of the environment variable that holds this provider's base URL.
|
|
|
|
Parallel to `api_key_env`: lets a provider that is not one of the built-in
|
|
`PROVIDER_BASE_URL_ENV` entries participate in endpoint resolution and in
|
|
the key/endpoint pairing applied by `apply_stored_credentials` (so a stored
|
|
`/auth` override clears an inherited gateway URL). The static `base_url`
|
|
field still wins over this when both are set.
|
|
"""
|
|
|
|
# Level 2: arbitrary BaseChatModel classes
|
|
|
|
class_path: str
|
|
"""Fully-qualified Python class in `module.path:ClassName` format.
|
|
|
|
When set, `create_model` imports this class and instantiates it directly
|
|
instead of calling `init_chat_model`.
|
|
"""
|
|
|
|
params: dict[str, Any]
|
|
"""Extra keyword arguments forwarded to the model constructor.
|
|
|
|
Flat keys (e.g., `temperature = 0`) are provider-wide defaults applied to
|
|
every model from this provider. Model-keyed sub-tables (e.g.,
|
|
`[params."qwen3:4b"]`) override individual values for that model only;
|
|
the merge is shallow (model wins on conflict).
|
|
|
|
Do not set `api_key` here — the early credential check runs before
|
|
`params` are read, so the app will reject the model before it sees the key.
|
|
Use `api_key_env` to point at an environment variable instead.
|
|
"""
|
|
|
|
profile: dict[str, Any]
|
|
"""Overrides merged into the model's runtime profile dict.
|
|
|
|
Flat keys (e.g., `max_input_tokens = 4096`) are provider-wide defaults.
|
|
Model-keyed sub-tables (e.g., `[profile."claude-sonnet-4-5"]`) override
|
|
individual values for that model only; the merge is shallow.
|
|
"""
|
|
|
|
|
|
DEFAULT_CONFIG_DIR = Path.home() / ".deepagents"
|
|
"""Directory for user-level Deep Agents configuration (`~/.deepagents`)."""
|
|
|
|
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
|
"""Path to the user's model configuration file (`~/.deepagents/config.toml`)."""
|
|
|
|
DEFAULT_STATE_DIR = DEFAULT_CONFIG_DIR / ".state"
|
|
"""Directory for app-managed internal state (`~/.deepagents/.state`).
|
|
|
|
Holds files the app writes for its own bookkeeping — OAuth tokens, the
|
|
sessions database, version-check caches, input history. Kept separate from
|
|
top-level user-facing config and agent directories so listing/iterating
|
|
`~/.deepagents` doesn't conflate state with agents.
|
|
"""
|
|
|
|
RECENT_MODELS_FILENAME = "recent_models.json"
|
|
"""Filename under `DEFAULT_STATE_DIR` for the MRU list shown in `/model`."""
|
|
|
|
RECENT_MODELS_LIMIT = 5
|
|
"""Maximum number of `provider:model` specs retained in the recent list.
|
|
|
|
Sized to fit comfortably above the provider-grouped list in `/model` without
|
|
pushing the rest of the catalog off-screen on a typical terminal.
|
|
"""
|
|
|
|
PROVIDER_API_KEY_ENV: dict[str, str] = {
|
|
"anthropic": "ANTHROPIC_API_KEY",
|
|
"azure_openai": "AZURE_OPENAI_API_KEY",
|
|
"baseten": "BASETEN_API_KEY",
|
|
"cohere": "COHERE_API_KEY",
|
|
"deepseek": "DEEPSEEK_API_KEY",
|
|
"fireworks": "FIREWORKS_API_KEY",
|
|
"google_genai": "GOOGLE_API_KEY",
|
|
"google_vertexai": "GOOGLE_CLOUD_PROJECT",
|
|
"groq": "GROQ_API_KEY",
|
|
"huggingface": "HUGGINGFACEHUB_API_TOKEN",
|
|
"ibm": "WATSONX_APIKEY",
|
|
"litellm": "LITELLM_API_KEY",
|
|
"meta": "MODEL_API_KEY",
|
|
"mistralai": "MISTRAL_API_KEY",
|
|
"nvidia": "NVIDIA_API_KEY",
|
|
"openai": "OPENAI_API_KEY",
|
|
"openrouter": "OPENROUTER_API_KEY",
|
|
"perplexity": "PPLX_API_KEY",
|
|
"together": "TOGETHER_API_KEY",
|
|
"xai": "XAI_API_KEY",
|
|
}
|
|
"""Well-known providers mapped to the env var that holds their API key.
|
|
|
|
Used by `has_provider_credentials` to verify credentials *before* model
|
|
creation, so the UI can show a warning icon and a specific error message
|
|
(e.g., "ANTHROPIC_API_KEY not set") instead of letting the provider fail at call
|
|
time.
|
|
|
|
Providers not listed here fall through to the config-file check or the langchain
|
|
registry fallback.
|
|
"""
|
|
|
|
LANGSMITH_SERVICE = "langsmith"
|
|
"""Service name for LangSmith tracing in `SERVICE_API_KEY_ENV`.
|
|
|
|
Storing a key for this service via `/auth` also enables tracing at startup
|
|
(see `config._apply_stored_langsmith_tracing`) and can carry a custom project
|
|
name, so it gets special handling beyond a plain key copy.
|
|
"""
|
|
|
|
TAVILY_SERVICE = "tavily"
|
|
"""Service name for Tavily web search in `SERVICE_API_KEY_ENV`.
|
|
|
|
Storing a key for this service via `/auth` gates the spawn-time `web_search`
|
|
tool (see `server_graph._build_tools`), so a key added to a running server
|
|
takes effect only after a respawn — the app offers that restart, and this
|
|
constant is the single name its `/auth` handling compares against.
|
|
"""
|
|
|
|
SERVICE_API_KEY_ENV: dict[str, str] = {
|
|
LANGSMITH_SERVICE: "LANGSMITH_API_KEY",
|
|
TAVILY_SERVICE: "TAVILY_API_KEY",
|
|
}
|
|
"""Non-model services configurable via `/auth`, mapped to their API-key env var.
|
|
|
|
These are not LLM providers — they back features such as web search (Tavily) or
|
|
agent tracing (LangSmith) — but their credentials follow the same store-on-disk
|
|
model as model providers, so they appear in the `/auth` manager and can be
|
|
entered directly in the TUI instead of being exported as environment variables
|
|
before launch.
|
|
"""
|
|
|
|
CODEX_PROVIDER = "openai_codex"
|
|
"""Provider name for `_ChatOpenAICodex` models authenticated via ChatGPT OAuth.
|
|
|
|
Distinct from `"openai"` (which uses an `OPENAI_API_KEY`) because the auth
|
|
source, model class, and request endpoint all differ. See
|
|
`deepagents_code.integrations.openai_codex` for the OAuth flow.
|
|
"""
|
|
|
|
CODEX_MODELS: frozenset[str] = frozenset(
|
|
{
|
|
"gpt-5.6-luna",
|
|
"gpt-5.6-sol",
|
|
"gpt-5.6-terra",
|
|
"gpt-5.5",
|
|
"gpt-5.4",
|
|
"gpt-5.4-mini",
|
|
"gpt-5.3-codex",
|
|
"gpt-5.2",
|
|
}
|
|
)
|
|
"""Curated allowlist of models the Codex (ChatGPT OAuth) backend serves.
|
|
|
|
The provider mirrors `openai` profiles, but only models in this set are
|
|
exposed under `openai_codex`. The Codex backend serves a narrower lineup than
|
|
the full `openai` API, so mirroring every openai model would surface specs the
|
|
backend rejects at call time.
|
|
"""
|
|
|
|
|
|
RETRY_PARAM_BY_PROVIDER: dict[str, str] = {
|
|
"anthropic": "max_retries",
|
|
"azure_openai": "max_retries",
|
|
"baseten": "max_retries",
|
|
"bedrock": "max_retries",
|
|
"deepseek": "max_retries",
|
|
"fireworks": "max_retries",
|
|
"google_genai": "max_retries",
|
|
"google_vertexai": "max_retries",
|
|
"groq": "max_retries",
|
|
"litellm": "max_retries",
|
|
"meta": "max_retries",
|
|
"mistralai": "max_retries",
|
|
"openai": "max_retries",
|
|
"openrouter": "max_retries",
|
|
"perplexity": "max_retries",
|
|
"together": "max_retries",
|
|
"xai": "max_retries",
|
|
}
|
|
"""Maps a provider to the constructor kwarg that sets its retry count.
|
|
|
|
The value is the kwarg name to pass to the provider's chat model constructor.
|
|
It is uniformly `max_retries` for every provider listed today, but this is a
|
|
`dict` rather than a `set` of providers because retry-kwarg names diverge across
|
|
the ecosystem -- some integrations expose a differently named kwarg -- and the
|
|
value column lets a future provider register its own name without restructuring
|
|
callers.
|
|
|
|
Membership is verified against each provider's chat model constructor (e.g.
|
|
`ChatGoogleGenerativeAI` exposes `max_retries`, not `retries`), not inferred
|
|
from naming. Providers absent from this map either lack an integer retry-count
|
|
kwarg or are not yet wired as a credential-resolvable provider in this module;
|
|
a `[retries]` config for them is ignored with a warning by `_resolve_retry_kwargs`.
|
|
"""
|
|
|
|
PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = {
|
|
# Each tuple lists every base-URL env var the provider's LangChain
|
|
# integration and underlying SDK may read, canonical name first. Names were
|
|
# verified against the integration and SDK source, not inferred:
|
|
# anthropic langchain_anthropic reads ANTHROPIC_API_URL; the anthropic
|
|
# SDK reads ANTHROPIC_BASE_URL.
|
|
# azure_openai AzureChatOpenAI and the openai SDK both read
|
|
# AZURE_OPENAI_ENDPOINT.
|
|
# baseten ChatBaseten reads BASETEN_BASE_URL, then falls back to
|
|
# BASETEN_API_BASE.
|
|
# cohere langchain_cohere passes base_url=None, so the cohere SDK's
|
|
# CO_API_URL is what takes effect.
|
|
# deepseek ChatDeepSeek reads DEEPSEEK_API_BASE (alias base_url).
|
|
# fireworks ChatFireworks reads FIREWORKS_API_BASE; when unset the
|
|
# fireworks SDK reads FIREWORKS_BASE_URL.
|
|
# google_genai the google-genai SDK reads GOOGLE_GEMINI_BASE_URL (the lone
|
|
# name langchain_google_genai threads through HttpOptions).
|
|
# groq ChatGroq reads GROQ_API_BASE; when unset the groq SDK reads
|
|
# GROQ_BASE_URL.
|
|
# huggingface the integration and huggingface_hub both read
|
|
# HF_INFERENCE_ENDPOINT.
|
|
# ibm ChatWatsonx reads WATSONX_URL.
|
|
# meta ChatMetaModel reads MODEL_API_BASE.
|
|
# mistralai ChatMistralAI reads MISTRAL_BASE_URL.
|
|
# nvidia ChatNVIDIA reads NVIDIA_BASE_URL.
|
|
# openai langchain_openai reads OPENAI_API_BASE; the openai SDK
|
|
# reads OPENAI_BASE_URL.
|
|
# openrouter ChatOpenRouter reads OPENROUTER_API_BASE (alias base_url).
|
|
# perplexity the integration passes no base_url, so the perplexity SDK's
|
|
# PERPLEXITY_BASE_URL is what takes effect.
|
|
# together ChatTogether reads TOGETHER_API_BASE (alias base_url).
|
|
# xai ChatXAI reads XAI_API_BASE (alias base_url).
|
|
#
|
|
# OpenAI-compatible providers (deepseek, openrouter, together, xai, baseten)
|
|
# sit on the openai SDK, whose only base-URL env var is the shared
|
|
# OPENAI_BASE_URL. That name is intentionally NOT listed under those
|
|
# providers: writing or clearing it under another provider's name would
|
|
# clobber the user's real OpenAI endpoint. Each is listed above under its own
|
|
# dedicated name(s) instead. In practice the integration always passes
|
|
# base_url explicitly, so the shared fallback never fires.
|
|
#
|
|
# Omitted (no dedicated, provider-specific endpoint env var): litellm
|
|
# (api_base arg, per-provider env), google_vertexai (endpoint derived from the
|
|
# region). A `/auth` endpoint for these still resolves through the
|
|
# stored-credential step of `get_base_url` and reaches the model as the
|
|
# `base_url` kwarg.
|
|
"anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_URL"),
|
|
"azure_openai": ("AZURE_OPENAI_ENDPOINT",),
|
|
"baseten": ("BASETEN_BASE_URL", "BASETEN_API_BASE"),
|
|
"cohere": ("CO_API_URL",),
|
|
"deepseek": ("DEEPSEEK_API_BASE",),
|
|
"fireworks": ("FIREWORKS_BASE_URL", "FIREWORKS_API_BASE"),
|
|
"google_genai": ("GOOGLE_GEMINI_BASE_URL",),
|
|
"groq": ("GROQ_BASE_URL", "GROQ_API_BASE"),
|
|
"huggingface": ("HF_INFERENCE_ENDPOINT",),
|
|
"ibm": ("WATSONX_URL",),
|
|
"meta": ("MODEL_API_BASE",),
|
|
"mistralai": ("MISTRAL_BASE_URL",),
|
|
"nvidia": ("NVIDIA_BASE_URL",),
|
|
"openai": ("OPENAI_BASE_URL", "OPENAI_API_BASE"),
|
|
"openrouter": ("OPENROUTER_API_BASE",),
|
|
"perplexity": ("PERPLEXITY_BASE_URL",),
|
|
"together": ("TOGETHER_API_BASE",),
|
|
"xai": ("XAI_API_BASE",),
|
|
}
|
|
"""Every base-URL env var a provider's SDK may read.
|
|
|
|
Element `[0]` is the *canonical* name — the one we write a stored endpoint to.
|
|
`get_base_url` reads each name in tuple order through `resolve_env_var`, so every
|
|
base URL gets the same `DEEPAGENTS_CODE_*` > plain-var precedence as API keys.
|
|
The remaining names are alternates the SDK might also honor;
|
|
`apply_stored_credentials` clears them when applying or resetting an endpoint, so
|
|
a stale value (e.g. an inherited gateway URL) can't leak through. Clearing every
|
|
name is what lets the write path treat the canonical as authoritative regardless
|
|
of which name the SDK prefers.
|
|
|
|
The key and its endpoint are a coherent pair: a gateway key only works against
|
|
the gateway URL, a provider-native key only against the provider's own endpoint,
|
|
so both must resolve from the same source.
|
|
"""
|
|
|
|
|
|
def _canonical_base_url_env(provider: str) -> str | None:
|
|
"""Return the canonical (written) base-URL env var name for a provider.
|
|
|
|
The canonical name is element `[0]` of the provider's `PROVIDER_BASE_URL_ENV`
|
|
tuple. Returns `None` for providers outside the built-in set.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
Canonical env var name, or `None` if the provider has no built-in entry.
|
|
"""
|
|
names = PROVIDER_BASE_URL_ENV.get(provider)
|
|
return names[0] if names else None
|
|
|
|
|
|
IMPLICIT_AUTH_PROVIDERS: frozenset[str] = frozenset({"google_vertexai"})
|
|
"""Providers that support ambient auth outside app env-var checks.
|
|
|
|
These providers can authenticate without the env var listed in
|
|
`PROVIDER_API_KEY_ENV`, so a missing env var should not be treated as a hard
|
|
credential failure. Used by `create_model` to skip the early credential check
|
|
and by `get_provider_auth_status` for user-facing auth labels.
|
|
"""
|
|
|
|
NO_AUTH_REQUIRED_PROVIDERS: frozenset[str] = frozenset({"ollama"})
|
|
"""Providers whose default local configuration does not require API keys."""
|
|
|
|
OPTIONAL_AUTH_ENV: dict[str, str] = {"ollama": "OLLAMA_API_KEY"}
|
|
"""Optional env vars that enable authenticated provider modes when present."""
|
|
|
|
PROVIDER_HOST_ENV: dict[str, str] = {"ollama": "OLLAMA_HOST"}
|
|
"""Provider-specific env vars that can point a local provider at a remote host."""
|
|
|
|
PROVIDER_CUSTOM_HEADERS_ENV: dict[str, str] = {"anthropic": "ANTHROPIC_CUSTOM_HEADERS"}
|
|
"""Provider SDK env vars that inject custom request headers (e.g. gateway auth)."""
|
|
|
|
OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434"
|
|
"""Default endpoint assumed when no `base_url` or `OLLAMA_HOST` is configured."""
|
|
|
|
OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 1.0
|
|
"""Socket timeout for Ollama discovery probes.
|
|
|
|
Kept short so a dead daemon does not stall switcher loading. Discovery runs
|
|
off the UI loop in a worker thread and may call `/api/tags` and `/api/show`,
|
|
so this caps the worst-case wait visible to the user.
|
|
"""
|
|
|
|
|
|
# Module-level caches — cleared by `clear_caches()`.
|
|
_available_models_cache: dict[str, list[str]] | None = None
|
|
_builtin_providers_cache: dict[str, Any] | None = None
|
|
_default_config_cache: ModelConfig | None = None
|
|
_provider_profiles_cache: dict[str, dict[str, Any]] = {}
|
|
_provider_profiles_lock = threading.Lock()
|
|
_config_write_lock = threading.RLock()
|
|
"""Process-wide lock serializing read-modify-write transactions on `config.toml`.
|
|
|
|
Any helper that reads the file, mutates a section, and atomically replaces it
|
|
must hold this lock for the whole transaction. The atomic rename alone only
|
|
prevents torn writes; without a lock covering read-through-replace, two
|
|
overlapping writers (e.g. concurrent effort-selection workers) can each read the
|
|
same snapshot and the last `replace()` silently drops the other's change.
|
|
|
|
Because the hazard is on the whole-file replace (not per-section), *every* writer
|
|
of `config.toml` must share this one lock — a second lock guarding the same file
|
|
would not mutually exclude, so a `[effort]` write could still clobber a `[ui]`
|
|
write. All such helpers here hold it, and `app.py`'s theme/UI writers import and
|
|
hold this same object rather than defining their own.
|
|
|
|
It is reentrant so a caller can hold it across several of these helpers without
|
|
self-deadlock. Cross-process races are out of scope (mirrors the existing
|
|
helpers)."""
|
|
_ollama_installed_models_cache: dict[str, list[str]] = {}
|
|
_ollama_unreachable_endpoints: set[str] = set()
|
|
"""Local endpoints (trailing slash stripped) whose daemon refused the TCP
|
|
presence preflight.
|
|
|
|
Lets `_get_ollama_installed_models` negatively-cache the empty result for a
|
|
daemon that is definitively absent (connection refused) so it probes and logs
|
|
"not detected" once per reload. A *reachable* daemon that merely has no models
|
|
pulled yet -- and a daemon whose preflight is only ambiguous (a connect
|
|
timeout, which defers to the HTTP probe) -- is still re-probed (its empty
|
|
result is not cached), so a later `ollama pull` is discovered without
|
|
`/reload`. Cleared by `clear_caches()`."""
|
|
_ollama_model_profiles_cache: dict[tuple[str, str], dict[str, Any]] = {}
|
|
_profiles_cache: Mapping[str, ModelProfileEntry] | None = None
|
|
_profiles_override_cache: tuple[int, Mapping[str, ModelProfileEntry]] | None = None
|
|
|
|
|
|
def clear_caches() -> None:
|
|
"""Reset module-level caches so the next call recomputes from scratch.
|
|
|
|
Intended for tests and for the `/reload` command.
|
|
"""
|
|
global _available_models_cache, _builtin_providers_cache, _default_config_cache, _profiles_cache, _profiles_override_cache # noqa: PLW0603, E501 # Module-level caches require global statement
|
|
_available_models_cache = None
|
|
_builtin_providers_cache = None
|
|
_default_config_cache = None
|
|
_provider_profiles_cache.clear()
|
|
_ollama_installed_models_cache.clear()
|
|
_ollama_unreachable_endpoints.clear()
|
|
_ollama_model_profiles_cache.clear()
|
|
_profiles_cache = None
|
|
_profiles_override_cache = None
|
|
invalidate_thread_config_cache()
|
|
|
|
|
|
def _get_builtin_providers() -> dict[str, Any]:
|
|
"""Return langchain's built-in provider registry.
|
|
|
|
Tries the newer `_BUILTIN_PROVIDERS` name first, then falls back to
|
|
the legacy `_SUPPORTED_PROVIDERS` for older langchain versions.
|
|
|
|
Results are cached after the first call; use `clear_caches()` to reset.
|
|
|
|
Returns:
|
|
The provider registry dict from `langchain.chat_models.base`.
|
|
"""
|
|
global _builtin_providers_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
if _builtin_providers_cache is not None:
|
|
return _builtin_providers_cache
|
|
|
|
# Deferred: langchain.chat_models pulls in heavy provider registry,
|
|
# only needed when resolving provider names for model config.
|
|
from langchain.chat_models import base
|
|
|
|
registry: dict[str, Any] | None = getattr(base, "_BUILTIN_PROVIDERS", None)
|
|
if registry is None:
|
|
registry = getattr(base, "_SUPPORTED_PROVIDERS", None)
|
|
_builtin_providers_cache = registry if registry is not None else {}
|
|
return _builtin_providers_cache
|
|
|
|
|
|
def _get_provider_profile_modules() -> list[tuple[str, str]]:
|
|
"""Build a `(provider, profile_module)` list from langchain's provider registry.
|
|
|
|
Reads the built-in provider registry from `langchain.chat_models.base`
|
|
to discover every provider that `init_chat_model` knows about, then derives
|
|
the `<package>.data._profiles` module path for each.
|
|
|
|
Returns:
|
|
List of `(provider_name, profile_module_path)` tuples.
|
|
"""
|
|
providers = _get_builtin_providers()
|
|
|
|
result: list[tuple[str, str]] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
|
|
for provider_name, (module_path, *_rest) in providers.items():
|
|
package_root = module_path.split(".", maxsplit=1)[0]
|
|
profile_module = f"{package_root}.data._profiles"
|
|
key = (provider_name, profile_module)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
result.append((provider_name, profile_module))
|
|
|
|
return result
|
|
|
|
|
|
def _load_provider_profiles(module_path: str) -> dict[str, Any]:
|
|
"""Load `_PROFILES` from a provider's data module.
|
|
|
|
Results are cached by `module_path` so repeated calls (e.g., from both
|
|
`get_available_models` and `get_model_profiles`) reuse the same dict.
|
|
Use `clear_caches()` to reset.
|
|
|
|
Locates the package on disk with `importlib.util.find_spec` and loads *only*
|
|
the `_profiles.py` file via `spec_from_file_location`.
|
|
|
|
Args:
|
|
module_path: Dotted module path (e.g., `"langchain_openai.data._profiles"`).
|
|
|
|
Returns:
|
|
The `_PROFILES` dictionary from the module, or an empty dict if
|
|
the module has no such attribute.
|
|
|
|
Raises:
|
|
ImportError: If the package is not installed or the profile module
|
|
cannot be found on disk.
|
|
"""
|
|
with _provider_profiles_lock:
|
|
cached = _provider_profiles_cache.get(module_path)
|
|
if cached is not None: # `is not None` so empty profile dicts are cached
|
|
return cached
|
|
|
|
parts = module_path.split(".")
|
|
package_root = parts[0]
|
|
|
|
spec = importlib.util.find_spec(package_root)
|
|
if spec is None:
|
|
msg = f"Package {package_root} is not installed"
|
|
raise ImportError(msg)
|
|
|
|
# Determine the package directory from the spec.
|
|
if spec.origin:
|
|
package_dir = Path(spec.origin).parent
|
|
elif spec.submodule_search_locations:
|
|
package_dir = Path(next(iter(spec.submodule_search_locations)))
|
|
else:
|
|
msg = f"Cannot determine location for {package_root}"
|
|
raise ImportError(msg)
|
|
|
|
# Build the path to the target file (e.g., data/_profiles.py).
|
|
relative_parts = parts[1:] # ["data", "_profiles"]
|
|
profiles_path = package_dir.joinpath(
|
|
*relative_parts[:-1], f"{relative_parts[-1]}.py"
|
|
)
|
|
|
|
if not profiles_path.exists():
|
|
msg = f"Profile module not found: {profiles_path}"
|
|
raise ImportError(msg)
|
|
|
|
file_spec = importlib.util.spec_from_file_location(module_path, profiles_path)
|
|
if file_spec is None or file_spec.loader is None:
|
|
msg = f"Could not create module spec for {profiles_path}"
|
|
raise ImportError(msg)
|
|
|
|
module = importlib.util.module_from_spec(file_spec)
|
|
file_spec.loader.exec_module(module)
|
|
profiles = getattr(module, "_PROFILES", {})
|
|
_provider_profiles_cache[module_path] = profiles
|
|
return profiles
|
|
|
|
|
|
def _profile_module_from_class_path(class_path: str) -> str | None:
|
|
"""Derive the profile module path from a `class_path` config value.
|
|
|
|
Args:
|
|
class_path: Fully-qualified class in `module.path:ClassName` format.
|
|
|
|
Returns:
|
|
Dotted module path like `langchain_baseten.data._profiles`, or None
|
|
if `class_path` is malformed.
|
|
"""
|
|
if ":" not in class_path:
|
|
return None
|
|
module_part, _ = class_path.split(":", 1)
|
|
package_root = module_part.split(".", maxsplit=1)[0]
|
|
if not package_root:
|
|
return None
|
|
return f"{package_root}.data._profiles"
|
|
|
|
|
|
def get_available_models() -> dict[str, list[str]]:
|
|
"""Get available models dynamically from installed LangChain provider packages.
|
|
|
|
Imports model profiles from each provider package and extracts model names.
|
|
|
|
Results are cached after the first call; use `clear_caches()` to reset.
|
|
|
|
Returns:
|
|
Dictionary mapping provider names to lists of model identifiers.
|
|
Includes providers from the langchain registry, config-file
|
|
providers with explicit model lists, and `class_path` providers
|
|
whose packages expose a `_profiles` module.
|
|
"""
|
|
global _available_models_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
if _available_models_cache is not None:
|
|
return _available_models_cache
|
|
|
|
available: dict[str, list[str]] = {}
|
|
config = ModelConfig.load()
|
|
|
|
# Try to load from langchain provider profile data.
|
|
# Build the list dynamically from langchain's supported-provider registry
|
|
# so new providers are picked up automatically when langchain adds them.
|
|
provider_modules = _get_provider_profile_modules()
|
|
registry_providers: set[str] = set()
|
|
|
|
for provider, module_path in provider_modules:
|
|
registry_providers.add(provider)
|
|
# Skip providers explicitly disabled in config.
|
|
if not config.is_provider_enabled(provider):
|
|
logger.debug(
|
|
"Provider '%s' is disabled in config; skipping registry discovery",
|
|
provider,
|
|
)
|
|
continue
|
|
try:
|
|
profiles = _load_provider_profiles(module_path)
|
|
except ImportError:
|
|
logger.debug(
|
|
"Could not import profiles from %s (package may not be installed)",
|
|
module_path,
|
|
)
|
|
continue
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to load profiles from %s, skipping provider '%s'",
|
|
module_path,
|
|
provider,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
|
|
# Filter to models that support tool calling and text I/O.
|
|
models = [
|
|
name
|
|
for name, profile in profiles.items()
|
|
if profile.get("tool_calling", False)
|
|
and profile.get("text_inputs", True) is not False
|
|
and profile.get("text_outputs", True) is not False
|
|
]
|
|
|
|
models.sort()
|
|
if models:
|
|
available[provider] = models
|
|
|
|
# Merge in models from config file (custom providers like ollama, fireworks)
|
|
for provider_name, provider_config in config.providers.items():
|
|
# Respect enabled = false (hide provider entirely).
|
|
if not config.is_provider_enabled(provider_name):
|
|
logger.debug(
|
|
"Provider '%s' is disabled in config; skipping",
|
|
provider_name,
|
|
)
|
|
continue
|
|
|
|
config_models = list(provider_config.get("models", []))
|
|
|
|
# For class_path providers not in the built-in registry, auto-discover
|
|
# models from the package's _profiles.py when no explicit models list.
|
|
if (
|
|
not config_models
|
|
and provider_name not in registry_providers
|
|
and provider_name not in available
|
|
):
|
|
class_path = provider_config.get("class_path", "")
|
|
profile_module = _profile_module_from_class_path(class_path)
|
|
if profile_module:
|
|
try:
|
|
profiles = _load_provider_profiles(profile_module)
|
|
except ImportError:
|
|
logger.debug(
|
|
"Could not import profiles from %s for class_path "
|
|
"provider '%s' (package may not be installed)",
|
|
profile_module,
|
|
provider_name,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to load profiles from %s for class_path provider '%s'",
|
|
profile_module,
|
|
provider_name,
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
config_models = sorted(
|
|
name
|
|
for name, profile in profiles.items()
|
|
if profile.get("tool_calling", False)
|
|
and profile.get("text_inputs", True) is not False
|
|
and profile.get("text_outputs", True) is not False
|
|
)
|
|
|
|
if provider_name not in available:
|
|
if config_models:
|
|
available[provider_name] = config_models
|
|
else:
|
|
# Append any config models not already discovered
|
|
existing = set(available[provider_name])
|
|
for model in config_models:
|
|
if model not in existing:
|
|
available[provider_name].append(model)
|
|
|
|
# `langchain-ollama` ships no profile data, so the steps above leave the
|
|
# switcher empty unless the user hand-curates `models = [...]` in config.
|
|
# Probe the daemon for installed models and merge them in,
|
|
# preserving explicit config order (config wins) with discoveries appended.
|
|
# Cached alongside the rest of `available`; refresh by
|
|
# calling `clear_caches()` (e.g. via the `/reload` slash command).
|
|
if (
|
|
_ollama_discovery_enabled()
|
|
and "ollama" in registry_providers
|
|
and config.is_provider_enabled("ollama")
|
|
and importlib.util.find_spec("langchain_ollama") is not None
|
|
):
|
|
endpoint = _get_provider_endpoint("ollama", config)
|
|
discovered = _get_ollama_installed_models(endpoint)
|
|
if discovered:
|
|
available["ollama"] = list(
|
|
dict.fromkeys([*available.get("ollama", []), *discovered])
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Ollama discovery returned no models for %s; "
|
|
"daemon may be down or have no pulls",
|
|
endpoint or OLLAMA_DEFAULT_BASE_URL,
|
|
)
|
|
|
|
# Mirror the curated `CODEX_MODELS` subset of `openai` models under a
|
|
# dedicated `openai_codex` provider entry so the switcher offers them under
|
|
# their own ChatGPT-OAuth auth context. Eligibility is filtered by the
|
|
# allowlist because the Codex backend serves a narrower lineup than the
|
|
# full `openai` API and rejects unsupported models at call time.
|
|
if config.is_provider_enabled(CODEX_PROVIDER):
|
|
openai_models = available.get("openai")
|
|
if openai_models:
|
|
mirrored = [name for name in openai_models if name in CODEX_MODELS]
|
|
codex_models = list(
|
|
dict.fromkeys([*available.get(CODEX_PROVIDER, []), *mirrored])
|
|
)
|
|
# Place `openai_codex` directly after `openai` so the switcher
|
|
# keeps the two OpenAI-backed providers adjacent (codex before
|
|
# azure_openai etc.) instead of trailing it at the end of the
|
|
# dict. dict insertion order is the switcher's display order, so
|
|
# rebuild the dict, dropping any prior codex entry and re-inserting
|
|
# it right after `openai`.
|
|
reordered: dict[str, list[str]] = {}
|
|
for name, models in available.items():
|
|
if name == CODEX_PROVIDER:
|
|
continue
|
|
reordered[name] = models
|
|
if name == "openai":
|
|
reordered[CODEX_PROVIDER] = codex_models
|
|
available = reordered
|
|
|
|
_available_models_cache = available
|
|
return available
|
|
|
|
|
|
def _build_entry(
|
|
base: dict[str, Any],
|
|
overrides: dict[str, Any],
|
|
cli_override: dict[str, Any] | None,
|
|
) -> ModelProfileEntry:
|
|
"""Build a profile entry by merging base, overrides, and app override.
|
|
|
|
Args:
|
|
base: Upstream profile dict (empty for config-only models).
|
|
overrides: `config.toml` profile overrides.
|
|
cli_override: Extra fields from `--profile-override`.
|
|
|
|
Returns:
|
|
Profile entry with merged data and override tracking.
|
|
"""
|
|
merged = {**base, **overrides}
|
|
overridden_keys = set(overrides)
|
|
if cli_override:
|
|
merged = {**merged, **cli_override}
|
|
overridden_keys |= set(cli_override)
|
|
return ModelProfileEntry(
|
|
profile=merged,
|
|
overridden_keys=frozenset(overridden_keys),
|
|
)
|
|
|
|
|
|
def get_model_profiles(
|
|
*,
|
|
cli_override: dict[str, Any] | None = None,
|
|
) -> Mapping[str, ModelProfileEntry]:
|
|
"""Load upstream profiles merged with config.toml overrides.
|
|
|
|
Keyed by `provider:model` spec string. Each entry contains the
|
|
merged profile dict and the set of keys overridden by config.toml.
|
|
|
|
Unlike `get_available_models()`, this includes all models from upstream
|
|
profiles regardless of capability filters (tool calling, text I/O).
|
|
|
|
Results are cached; use `clear_caches()` to reset. When `cli_override` is
|
|
provided the result is stored in a single-slot cache keyed by
|
|
`id(cli_override)`. This relies on the caller retaining the same dict
|
|
object for the session (the app stores it once on the app instance);
|
|
passing a different dict with the same contents will bypass the cache
|
|
and overwrite the previous entry.
|
|
|
|
Args:
|
|
cli_override: Extra profile fields from `--profile-override`.
|
|
|
|
When provided, these are merged on top of every profile entry
|
|
(after upstream + config.toml) and their keys are added to
|
|
`overridden_keys`.
|
|
|
|
Returns:
|
|
Read-only mapping of spec strings to profile entries.
|
|
"""
|
|
global _profiles_cache, _profiles_override_cache # noqa: PLW0603 # Module-level caches require global statement
|
|
if cli_override is None and _profiles_cache is not None:
|
|
return _profiles_cache
|
|
if cli_override is not None and _profiles_override_cache is not None:
|
|
cached_id, cached_result = _profiles_override_cache
|
|
if cached_id == id(cli_override):
|
|
return cached_result
|
|
|
|
result: dict[str, ModelProfileEntry] = {}
|
|
config = ModelConfig.load()
|
|
|
|
# Collect upstream profiles from provider packages.
|
|
seen_specs: set[str] = set()
|
|
provider_modules = _get_provider_profile_modules()
|
|
registry_providers: set[str] = set()
|
|
for provider, module_path in provider_modules:
|
|
registry_providers.add(provider)
|
|
# Skip providers explicitly disabled in config.
|
|
if not config.is_provider_enabled(provider):
|
|
logger.debug(
|
|
"Provider '%s' is disabled in config; skipping profiles",
|
|
provider,
|
|
)
|
|
continue
|
|
try:
|
|
profiles = _load_provider_profiles(module_path)
|
|
except ImportError:
|
|
logger.debug(
|
|
"Could not import profiles from %s for provider '%s'",
|
|
module_path,
|
|
provider,
|
|
)
|
|
continue
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to load profiles from %s for provider '%s'",
|
|
module_path,
|
|
provider,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
|
|
for model_name, upstream_profile in profiles.items():
|
|
spec = f"{provider}:{model_name}"
|
|
seen_specs.add(spec)
|
|
overrides = config.get_profile_overrides(provider, model_name=model_name)
|
|
result[spec] = _build_entry(upstream_profile, overrides, cli_override)
|
|
# Mirror the curated `CODEX_MODELS` subset of openai profiles under
|
|
# the `openai_codex` provider so `/model openai_codex:<model>`
|
|
# resolves to the same upstream profile without duplicating data.
|
|
# Filtered by the allowlist — see the note in `get_available_models`.
|
|
if (
|
|
provider == "openai"
|
|
and model_name in CODEX_MODELS
|
|
and config.is_provider_enabled(CODEX_PROVIDER)
|
|
):
|
|
codex_spec = f"{CODEX_PROVIDER}:{model_name}"
|
|
seen_specs.add(codex_spec)
|
|
codex_overrides = config.get_profile_overrides(
|
|
CODEX_PROVIDER, model_name=model_name
|
|
)
|
|
result[codex_spec] = _build_entry(
|
|
upstream_profile, codex_overrides, cli_override
|
|
)
|
|
|
|
# Add config-only models and class_path provider profiles.
|
|
for provider_name, provider_config in config.providers.items():
|
|
if not config.is_provider_enabled(provider_name):
|
|
logger.debug(
|
|
"Provider '%s' is disabled in config; skipping profiles",
|
|
provider_name,
|
|
)
|
|
continue
|
|
# For class_path providers not in the built-in registry, load
|
|
# upstream profiles from the package's _profiles.py.
|
|
if provider_name not in registry_providers:
|
|
class_path = provider_config.get("class_path", "")
|
|
profile_module = _profile_module_from_class_path(class_path)
|
|
if profile_module:
|
|
try:
|
|
pkg_profiles = _load_provider_profiles(profile_module)
|
|
except ImportError:
|
|
logger.debug(
|
|
"Could not import profiles from %s for class_path "
|
|
"provider '%s' (package may not be installed)",
|
|
profile_module,
|
|
provider_name,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to load profiles from %s for class_path provider '%s'",
|
|
profile_module,
|
|
provider_name,
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
for model_name, upstream_profile in pkg_profiles.items():
|
|
spec = f"{provider_name}:{model_name}"
|
|
seen_specs.add(spec)
|
|
overrides = config.get_profile_overrides(
|
|
provider_name, model_name=model_name
|
|
)
|
|
result[spec] = _build_entry(
|
|
upstream_profile, overrides, cli_override
|
|
)
|
|
|
|
config_models = provider_config.get("models", [])
|
|
for model_name in config_models:
|
|
spec = f"{provider_name}:{model_name}"
|
|
if spec not in seen_specs:
|
|
overrides = config.get_profile_overrides(
|
|
provider_name, model_name=model_name
|
|
)
|
|
result[spec] = _build_entry({}, overrides, cli_override)
|
|
|
|
# `langchain-ollama` does not ship static profile data. When discovery is
|
|
# enabled, ask the daemon for model metadata so the selector can show
|
|
# context length and capabilities for locally pulled models.
|
|
if (
|
|
_ollama_discovery_enabled()
|
|
and "ollama" in registry_providers
|
|
and config.is_provider_enabled("ollama")
|
|
and importlib.util.find_spec("langchain_ollama") is not None
|
|
):
|
|
endpoint = _get_provider_endpoint("ollama", config)
|
|
discovered_model_names = _get_ollama_installed_models(endpoint)
|
|
configured_model_names = [
|
|
spec.removeprefix("ollama:")
|
|
for spec in result
|
|
if spec.startswith("ollama:")
|
|
]
|
|
model_names = list(
|
|
dict.fromkeys([*configured_model_names, *discovered_model_names])
|
|
)
|
|
if model_names:
|
|
discovered_profiles = _fetch_ollama_installed_model_profiles(
|
|
endpoint,
|
|
model_names,
|
|
)
|
|
for model_name in model_names:
|
|
profile = discovered_profiles.get(model_name, {})
|
|
spec = f"ollama:{model_name}"
|
|
existing = result.get(spec)
|
|
base = dict(existing["profile"]) if existing is not None else {}
|
|
base.update(profile)
|
|
overrides = config.get_profile_overrides(
|
|
"ollama", model_name=model_name
|
|
)
|
|
result[spec] = _build_entry(base, overrides, cli_override)
|
|
seen_specs.add(spec)
|
|
|
|
frozen = MappingProxyType(result)
|
|
if cli_override is None:
|
|
_profiles_cache = frozen
|
|
else:
|
|
_profiles_override_cache = (id(cli_override), frozen)
|
|
return frozen
|
|
|
|
|
|
_LOCAL_HOSTNAMES: frozenset[str] = frozenset(
|
|
{
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"0.0.0.0", # noqa: S104 # hostname comparison, not socket binding
|
|
}
|
|
)
|
|
|
|
|
|
def _is_local_endpoint(url: object) -> bool:
|
|
"""Return whether a provider endpoint points at the local machine.
|
|
|
|
Accepts `object` rather than `str | None` because the endpoint originates
|
|
from untyped TOML; the `isinstance` guard below defends against drift.
|
|
"""
|
|
if not url:
|
|
return True
|
|
if not isinstance(url, str):
|
|
return False
|
|
|
|
# Bare hostname literal (no scheme, no port) — short-circuit so IPv6
|
|
# forms like `::1` don't get misparsed by urlparse.
|
|
if url in _LOCAL_HOSTNAMES:
|
|
return True
|
|
|
|
candidate = url if "://" in url else f"http://{url}"
|
|
try:
|
|
parsed = urlparse(candidate)
|
|
except ValueError:
|
|
return False
|
|
return parsed.hostname in _LOCAL_HOSTNAMES
|
|
|
|
|
|
def _get_provider_endpoint(provider: str, config: ModelConfig) -> str | None:
|
|
"""Return a provider endpoint from config or provider-specific env vars."""
|
|
base_url = config.get_base_url(provider)
|
|
if base_url:
|
|
return base_url
|
|
|
|
host_env = PROVIDER_HOST_ENV.get(provider)
|
|
if not host_env:
|
|
return None
|
|
return resolve_env_var(host_env)
|
|
|
|
|
|
_OLLAMA_DISCOVERY_FALSY: frozenset[str] = frozenset({"0", "false", "no", "off"})
|
|
"""Normalized values that disable Ollama discovery when set in `OLLAMA_DISCOVERY`."""
|
|
|
|
_OLLAMA_DISCOVERY_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
|
"""Normalized values that enable Ollama discovery when set in `OLLAMA_DISCOVERY`."""
|
|
|
|
|
|
def _ollama_discovery_enabled() -> bool:
|
|
"""Return whether Ollama model/profile discovery may run.
|
|
|
|
Defaults to enabled. Opt out via `_env_vars.OLLAMA_DISCOVERY` set to a
|
|
falsy value (`0`, `false`, `no`, `off`); truthy values (`1`, `true`,
|
|
`yes`, `on`) explicitly enable. Unrecognized values warn and fall through
|
|
to the default because the user clearly tried to configure something.
|
|
"""
|
|
raw = resolve_env_var(_env_vars.OLLAMA_DISCOVERY)
|
|
if raw is None:
|
|
return True
|
|
normalized = raw.strip().lower()
|
|
if normalized in _OLLAMA_DISCOVERY_FALSY:
|
|
return False
|
|
if normalized in _OLLAMA_DISCOVERY_TRUTHY:
|
|
return True
|
|
logger.warning(
|
|
"Unrecognized value for %s: %r; expected one of %s. Defaulting to enabled.",
|
|
_env_vars.OLLAMA_DISCOVERY,
|
|
raw,
|
|
sorted(_OLLAMA_DISCOVERY_FALSY | _OLLAMA_DISCOVERY_TRUTHY),
|
|
)
|
|
return True
|
|
|
|
|
|
def _get_ollama_installed_models(endpoint: str | None) -> list[str]:
|
|
"""Return cached Ollama model names for `endpoint`.
|
|
|
|
The result is cached when the daemon returns models, and also when a local
|
|
daemon definitively refuses the TCP presence preflight, so the two startup
|
|
callers (`get_available_models` and `get_model_profiles`) share a single
|
|
probe and a single "not detected" log line per reload. A reachable daemon
|
|
that reports no models -- and one whose preflight is merely ambiguous (a
|
|
connect timeout) -- is left uncached so a later pull can still be discovered
|
|
without `/reload`.
|
|
|
|
Args:
|
|
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
`OLLAMA_DEFAULT_BASE_URL`.
|
|
|
|
Returns:
|
|
Sorted list of model names reported by `/api/tags`.
|
|
"""
|
|
key = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
cached = _ollama_installed_models_cache.get(key)
|
|
if cached is not None:
|
|
return list(cached)
|
|
models = _fetch_ollama_installed_models(endpoint)
|
|
if models or key in _ollama_unreachable_endpoints:
|
|
_ollama_installed_models_cache[key] = models
|
|
return list(models)
|
|
|
|
|
|
def _ollama_host_reachable(
|
|
base: str, *, timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS
|
|
) -> bool:
|
|
"""Return whether a TCP listener appears to accept connections at `base`.
|
|
|
|
A lightweight presence preflight so Ollama discovery can skip the HTTP
|
|
probe entirely when no daemon is running (e.g. Ollama is not installed).
|
|
The check opens and immediately closes a TCP connection to the endpoint's
|
|
host and port. A *definitive* failure -- connection refused, DNS error, or
|
|
sockets blocked under `pytest-socket` -- reports "not reachable" so
|
|
discovery falls back gracefully (and the caller may negatively cache it). A
|
|
*connect timeout* is ambiguous -- a present-but-slow or still-booting daemon
|
|
times out just like an absent one -- so it defers to the HTTP probe
|
|
(reports "reachable") rather than being cached as absent. An unexpected
|
|
(non-`OSError`) failure is additionally logged at warning so a real bug
|
|
isn't misreported as absence.
|
|
|
|
Args:
|
|
base: Base URL of the Ollama daemon, e.g. `http://localhost:11434`.
|
|
timeout: Socket connection timeout in seconds.
|
|
|
|
Returns:
|
|
`True` when a connection is established (a daemon appears present) or
|
|
when presence cannot be determined -- unparseable target or a
|
|
connect timeout -- so the caller defers to the HTTP probe; `False`
|
|
when the connection is definitively refused.
|
|
"""
|
|
import socket
|
|
|
|
parsed = urlparse(base)
|
|
host = parsed.hostname
|
|
if not host:
|
|
# Can't determine a target host; let the HTTP probe make the decision.
|
|
return True
|
|
try:
|
|
port = parsed.port
|
|
except ValueError:
|
|
# Malformed port (out of range / non-numeric); defer to the HTTP probe.
|
|
return True
|
|
if port is None:
|
|
port = 443 if parsed.scheme == "https" else 80
|
|
# Expected transport failures split by how definitive they are. A refusal
|
|
# (`ECONNREFUSED` and friends -- an `OSError`) is a fast, certain "nothing
|
|
# is listening", so it reports absent and lets the caller negatively cache
|
|
# it. A connect *timeout* is ambiguous (present-but-slow vs. absent-and-
|
|
# firewalled), so it defers to the HTTP probe rather than being cached as
|
|
# absent and stuck until the next reload. `TimeoutError` is an `OSError`
|
|
# subclass, so its branch must precede the broad `OSError` one. Anything
|
|
# non-`OSError` is surfaced at warning so a real bug isn't misreported as
|
|
# "not detected"; `pytest-socket`'s `SocketBlockedError` inherits from
|
|
# `Exception` (not `OSError`), so the broad branch catches it. The socket
|
|
# is its own context manager, so `with` closes the probe connection.
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout):
|
|
return True
|
|
except TimeoutError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
except Exception as exc: # noqa: BLE001 # see comment above
|
|
logger.warning(
|
|
"Ollama presence preflight raised unexpected %s for %s: %s",
|
|
type(exc).__name__,
|
|
base,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
|
|
def _fetch_ollama_installed_models(
|
|
endpoint: str | None,
|
|
*,
|
|
timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS,
|
|
) -> list[str]:
|
|
"""Discover models installed in a local or hosted Ollama daemon.
|
|
|
|
Issues a `GET {endpoint}/api/tags` and returns the sorted list of model
|
|
names reported by the daemon. The probe is best-effort: any error
|
|
(timeout, connection refused, malformed JSON) yields an empty list and is
|
|
logged at debug level so the model switcher can fall back gracefully.
|
|
|
|
When probing a local endpoint and `OLLAMA_API_KEY` (or the
|
|
`DEEPAGENTS_CODE_`-prefixed variant) is set, its value is forwarded as a
|
|
`Bearer` token. Discovery never forwards credentials to non-local endpoints.
|
|
|
|
Args:
|
|
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
`OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated.
|
|
timeout: Socket timeout in seconds.
|
|
|
|
Returns:
|
|
Sorted list of model names; empty when the daemon is unreachable or
|
|
returns no models.
|
|
"""
|
|
import json
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
if not base.startswith(("http://", "https://")):
|
|
logger.warning(
|
|
"Skipping Ollama discovery: %r has no http:// or https:// scheme. "
|
|
"Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.",
|
|
base,
|
|
)
|
|
return []
|
|
|
|
# Presence preflight (local endpoints only -- remote hosts may be reachable
|
|
# only through a proxy that the HTTP probe honors but a raw socket does
|
|
# not). A dead/absent daemon (the common case when Ollama is not installed)
|
|
# refuses the connection; detecting that here lets us skip the HTTP probe
|
|
# and log a quiet "not detected" line instead of a misleading
|
|
# "discovery failed ... Connection refused" debug line.
|
|
if _is_local_endpoint(base) and not _ollama_host_reachable(base, timeout=timeout):
|
|
logger.debug("Ollama daemon not detected at %s; skipping discovery", base)
|
|
_ollama_unreachable_endpoints.add(base)
|
|
return []
|
|
|
|
url = f"{base}/api/tags"
|
|
|
|
headers = _ollama_discovery_headers(base, content_type=False)
|
|
request = Request(url, headers=headers) # noqa: S310 # scheme guarded above
|
|
# Catch-all is intentional: discovery is best-effort and must never break
|
|
# the model selector. The narrow tuple is fully subsumed by `Exception`
|
|
# below; we keep it only to log expected transport failures at debug while
|
|
# surfacing unexpected ones at warning so a real bug doesn't disappear.
|
|
# Notably catches `pytest-socket`'s `SocketBlockedError`, which inherits
|
|
# from `Exception` (not `OSError`) and would otherwise propagate during
|
|
# unit tests run with `--disable-socket`. `KeyboardInterrupt` and
|
|
# `SystemExit` derive from `BaseException` and bypass both branches.
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except (URLError, TimeoutError, OSError, ValueError) as exc:
|
|
logger.debug("Ollama model discovery failed for %s: %s", url, exc)
|
|
return []
|
|
except Exception as exc: # noqa: BLE001 # see comment above
|
|
logger.warning(
|
|
"Ollama model discovery raised unexpected %s for %s: %s",
|
|
type(exc).__name__,
|
|
url,
|
|
exc,
|
|
)
|
|
return []
|
|
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
|
logger.debug(
|
|
"Ollama discovery: %s returned unexpected payload shape (%s); "
|
|
"endpoint may not be an Ollama daemon",
|
|
url,
|
|
type(payload).__name__,
|
|
)
|
|
return []
|
|
|
|
names: list[str] = []
|
|
for entry in payload["models"]:
|
|
if isinstance(entry, dict):
|
|
name = entry.get("name")
|
|
if isinstance(name, str) and name:
|
|
names.append(name)
|
|
names.sort()
|
|
return names
|
|
|
|
|
|
def _ollama_discovery_headers(endpoint: str, *, content_type: bool) -> dict[str, str]:
|
|
"""Build headers for Ollama discovery requests.
|
|
|
|
Args:
|
|
endpoint: Base URL for the discovery request.
|
|
content_type: Whether to include a JSON `Content-Type` header.
|
|
|
|
Returns:
|
|
HTTP headers including optional bearer auth for local endpoints.
|
|
"""
|
|
headers: dict[str, str] = {"Accept": "application/json"}
|
|
if content_type:
|
|
headers["Content-Type"] = "application/json"
|
|
optional_env = OPTIONAL_AUTH_ENV.get("ollama")
|
|
if optional_env and _is_local_endpoint(endpoint):
|
|
api_key = resolve_env_var(optional_env)
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
return headers
|
|
|
|
|
|
def _coerce_positive_int(value: object) -> int | None:
|
|
"""Return `value` as a positive integer, or `None` when unavailable."""
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int) and value > 0:
|
|
return value
|
|
if isinstance(value, float) and value > 0 and value.is_integer():
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError:
|
|
return None
|
|
if parsed > 0:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
def _profile_from_ollama_show_payload(payload: object) -> dict[str, Any]:
|
|
"""Extract LangChain-style profile fields from an Ollama `/api/show` payload.
|
|
|
|
Args:
|
|
payload: Decoded JSON response from `POST /api/show`.
|
|
|
|
Returns:
|
|
Profile fields understood by the model selector, such as
|
|
`max_input_tokens` and `tool_calling`.
|
|
"""
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
payload_dict = cast("dict[str, object]", payload)
|
|
|
|
profile: dict[str, Any] = {}
|
|
model_info = payload_dict.get("model_info")
|
|
if isinstance(model_info, dict):
|
|
context_lengths = [
|
|
length
|
|
for key, value in model_info.items()
|
|
if isinstance(key, str)
|
|
and (key == "context_length" or key.endswith(".context_length"))
|
|
and (length := _coerce_positive_int(value)) is not None
|
|
]
|
|
if context_lengths:
|
|
profile["max_input_tokens"] = max(context_lengths)
|
|
|
|
capabilities = payload_dict.get("capabilities")
|
|
if isinstance(capabilities, list):
|
|
capability_names = {item for item in capabilities if isinstance(item, str)}
|
|
if "completion" in capability_names:
|
|
profile["text_inputs"] = True
|
|
profile["text_outputs"] = True
|
|
if "tools" in capability_names:
|
|
profile["tool_calling"] = True
|
|
if "thinking" in capability_names:
|
|
profile["reasoning_output"] = True
|
|
|
|
if not profile and ("model_info" in payload_dict or "capabilities" in payload_dict):
|
|
logger.debug(
|
|
"Ollama profile discovery returned a payload with no recognized "
|
|
"profile fields; top-level keys: %s",
|
|
sorted(str(key) for key in payload_dict),
|
|
)
|
|
|
|
return profile
|
|
|
|
|
|
def _fetch_ollama_installed_model_profiles(
|
|
endpoint: str | None,
|
|
model_names: list[str],
|
|
*,
|
|
timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Discover profile metadata for installed Ollama models.
|
|
|
|
Issues `POST {endpoint}/api/show` for each model. The probe is best-effort:
|
|
failures for one model are logged and do not stop profile discovery for the
|
|
remaining models.
|
|
|
|
Args:
|
|
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
`OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated.
|
|
model_names: Model names to inspect.
|
|
timeout: Socket timeout in seconds.
|
|
|
|
Returns:
|
|
Mapping of model name to extracted profile fields.
|
|
"""
|
|
import json
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
if not base.startswith(("http://", "https://")):
|
|
logger.warning(
|
|
"Skipping Ollama profile discovery: %r has no http:// or https:// scheme. "
|
|
"Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.",
|
|
base,
|
|
)
|
|
return {}
|
|
|
|
url = f"{base}/api/show"
|
|
profiles: dict[str, dict[str, Any]] = {}
|
|
headers = _ollama_discovery_headers(base, content_type=True)
|
|
|
|
for model_name in model_names:
|
|
cache_key = (base, model_name)
|
|
cached = _ollama_model_profiles_cache.get(cache_key)
|
|
if cached is not None:
|
|
profiles[model_name] = dict(cached)
|
|
continue
|
|
|
|
body = json.dumps({"model": model_name}).encode("utf-8")
|
|
request = Request( # noqa: S310 # scheme guarded above
|
|
url,
|
|
data=body,
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except (URLError, TimeoutError, OSError, ValueError) as exc:
|
|
logger.debug(
|
|
"Ollama profile discovery failed for %s via %s: %s",
|
|
model_name,
|
|
url,
|
|
exc,
|
|
)
|
|
continue
|
|
except Exception as exc: # noqa: BLE001 # see _fetch_ollama_installed_models
|
|
logger.warning(
|
|
"Ollama profile discovery raised unexpected %s for %s via %s: %s",
|
|
type(exc).__name__,
|
|
model_name,
|
|
url,
|
|
exc,
|
|
)
|
|
continue
|
|
|
|
profile = _profile_from_ollama_show_payload(payload)
|
|
if profile:
|
|
_ollama_model_profiles_cache[cache_key] = profile
|
|
profiles[model_name] = profile
|
|
|
|
return profiles
|
|
|
|
|
|
def _has_stored_credential(provider: str) -> bool:
|
|
"""Return whether `provider` has a credential persisted via `/auth`.
|
|
|
|
A corrupt `auth.json` is swallowed (logged, treated as absent) so the
|
|
model selector and other read-side callers can keep listing providers.
|
|
The user-visible signal lives in `AuthManagerScreen` — opening `/auth`
|
|
surfaces a corruption banner directly. Read-side resilience here means
|
|
you can still pick a different provider while the file is broken.
|
|
"""
|
|
try:
|
|
return auth_store.get_stored_key(provider) is not None
|
|
except RuntimeError:
|
|
logger.warning(
|
|
"Could not read stored credentials for provider %s; treating as absent",
|
|
provider,
|
|
)
|
|
return False
|
|
|
|
|
|
def resolve_provider_credential(provider: str) -> str | None:
|
|
"""Resolve the credential value for `provider` from any configured source.
|
|
|
|
Lookup order:
|
|
|
|
1. Stored API key in `~/.deepagents/.state/auth.json` (added via `/auth`).
|
|
2. Canonical env var via `resolve_env_var()` (which honors the
|
|
`DEEPAGENTS_CODE_` prefix and dotenv files).
|
|
|
|
A user who has *both* a stored key and an env var set gets the stored
|
|
key — entering one in the TUI is the more deliberate, more recent
|
|
action, so "I just typed this in" beats whatever the shell exported.
|
|
|
|
Args:
|
|
provider: Provider name (e.g., `"anthropic"`).
|
|
|
|
Returns:
|
|
The credential value, or `None` when no source has one or the
|
|
provider has no env-var mapping at all.
|
|
"""
|
|
try:
|
|
stored = auth_store.get_stored_key(provider)
|
|
except RuntimeError:
|
|
logger.warning(
|
|
"Could not read stored credentials for provider %s; falling back to env",
|
|
provider,
|
|
)
|
|
stored = None
|
|
if stored:
|
|
return stored
|
|
env_var = get_credential_env_var(provider)
|
|
if env_var:
|
|
return resolve_env_var(env_var)
|
|
return None
|
|
|
|
|
|
def _resolve_configured(provider: str, env_var: str) -> ProviderAuthStatus | None:
|
|
"""Return a `CONFIGURED` status if a stored or env credential is set.
|
|
|
|
Stored credentials beat env vars (matches `resolve_provider_credential`).
|
|
|
|
Args:
|
|
provider: Provider name (e.g., `"anthropic"`).
|
|
env_var: Canonical env var name to check when no stored credential
|
|
exists. Recorded on the returned status either way.
|
|
|
|
Returns:
|
|
A `CONFIGURED` status, or `None` when neither source is set.
|
|
"""
|
|
if _has_stored_credential(provider):
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.CONFIGURED,
|
|
provider=provider,
|
|
env_var=env_var,
|
|
source=ProviderAuthSource.STORED,
|
|
detail="stored credential",
|
|
)
|
|
if resolve_env_var(env_var):
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.CONFIGURED,
|
|
provider=provider,
|
|
env_var=env_var,
|
|
source=ProviderAuthSource.ENV,
|
|
detail="credentials set",
|
|
)
|
|
return None
|
|
|
|
|
|
def _get_codex_auth_status() -> ProviderAuthStatus:
|
|
"""Translate the ChatGPT OAuth on-disk state into a `ProviderAuthStatus`.
|
|
|
|
The codex provider uses a file-backed OAuth token store rather than
|
|
`auth_store`'s API-key map, so it gets its own branch in
|
|
`get_provider_auth_status`. The `STORED` source is reused only to satisfy
|
|
the `ProviderAuthStatus` "CONFIGURED implies a source" invariant; it is
|
|
cosmetic here, since `format_auth_badge` routes the codex provider to its
|
|
own `[chatgpt]` / `[sign in to chatgpt]` badge before the source is ever
|
|
consulted.
|
|
|
|
Returns:
|
|
`CONFIGURED` / `STORED` when a token bundle sits at the upstream
|
|
default store path; `MISSING` otherwise. Expired access tokens
|
|
are still reported as configured because the file-backed model
|
|
provider can refresh them with the saved refresh token when the
|
|
model is constructed.
|
|
"""
|
|
from deepagents_code.integrations import openai_codex
|
|
|
|
status = openai_codex.get_status()
|
|
if status.unreadable_reason:
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING,
|
|
provider=CODEX_PROVIDER,
|
|
detail=f"token store unreadable: {status.unreadable_reason}",
|
|
)
|
|
if not status.logged_in:
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING,
|
|
provider=CODEX_PROVIDER,
|
|
detail="not signed in to ChatGPT",
|
|
)
|
|
detail = "signed in to ChatGPT"
|
|
if status.plan_type:
|
|
detail = f"signed in to ChatGPT ({status.plan_type})"
|
|
if status.is_expired:
|
|
detail = f"{detail}; access token will refresh on use"
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.CONFIGURED,
|
|
provider=CODEX_PROVIDER,
|
|
source=ProviderAuthSource.STORED,
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def get_provider_auth_status(provider: str) -> ProviderAuthStatus:
|
|
"""Return credential readiness details for a provider.
|
|
|
|
Combines config, well-known provider metadata, optional provider auth,
|
|
and implicit-auth provider metadata before attempting model creation:
|
|
|
|
1. **Config-file providers** (`config.toml`
|
|
`[models.providers.<name>]`):
|
|
- If the section declares `api_key_env`, that env var is checked
|
|
via `resolve_env_var()` (which honors `DEEPAGENTS_CODE_` prefixes).
|
|
- If the section has `class_path` but no `api_key_env`, the provider is
|
|
assumed to manage its own auth (e.g., custom headers, JWT, mTLS).
|
|
- If neither `api_key_env` nor `class_path` is set, falls through
|
|
to provider-specific defaults.
|
|
2. **Hardcoded registry** (`PROVIDER_API_KEY_ENV`): a module-level dict
|
|
mapping well-known provider names to their canonical env var
|
|
(e.g., `"anthropic"` → `"ANTHROPIC_API_KEY"`). The env var is checked
|
|
via `resolve_env_var()`.
|
|
3. **Implicit auth providers** (e.g., Vertex AI ADC): a missing env var is
|
|
not treated as missing credentials.
|
|
4. **Optional auth env vars** (`OPTIONAL_AUTH_ENV`): when present, mark
|
|
the provider as configured for hosted/cloud use.
|
|
5. **No-auth-required providers** (`NO_AUTH_REQUIRED_PROVIDERS`): default
|
|
local endpoints report `NOT_REQUIRED`; non-local endpoints fall back
|
|
to `UNKNOWN` so the SDK can decide.
|
|
6. **Unknown providers** not present in any source defer auth failures to
|
|
the provider SDK.
|
|
|
|
Use `has_provider_credentials()` when compatibility with the historic
|
|
`True`/`False`/`None` contract is required.
|
|
|
|
Args:
|
|
provider: Provider name (e.g., `"anthropic"`, `"openai"`).
|
|
|
|
Returns:
|
|
Provider auth status for selectors, startup checks, and compatibility
|
|
wrappers.
|
|
"""
|
|
# ChatGPT-OAuth-backed codex provider has no env var and stores tokens
|
|
# in its own on-disk JSON; route it through a dedicated helper before
|
|
# the standard config / env-var lookup so callers get the codex-specific
|
|
# `[chatgpt]` / `[sign in to chatgpt]` badge and a "signed in as <plan>"
|
|
# detail.
|
|
if provider == CODEX_PROVIDER:
|
|
return _get_codex_auth_status()
|
|
|
|
# Config-file providers take priority when api_key_env is specified.
|
|
config = ModelConfig.load()
|
|
provider_config = config.providers.get(provider)
|
|
if provider_config:
|
|
env_var = provider_config.get("api_key_env")
|
|
if env_var:
|
|
configured = _resolve_configured(provider, env_var)
|
|
if configured:
|
|
return configured
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING,
|
|
provider=provider,
|
|
env_var=env_var,
|
|
detail=f"{env_var} is not set or is empty",
|
|
)
|
|
# class_path providers that omit api_key_env manage their own auth
|
|
# (e.g., custom headers, JWT, mTLS).
|
|
if provider_config.get("class_path"):
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MANAGED,
|
|
provider=provider,
|
|
detail="custom auth",
|
|
)
|
|
# No api_key_env in config — fall through to provider-specific and
|
|
# hardcoded maps.
|
|
|
|
# Fall back to hardcoded well-known providers.
|
|
env_var = PROVIDER_API_KEY_ENV.get(provider)
|
|
if env_var:
|
|
configured = _resolve_configured(provider, env_var)
|
|
if configured:
|
|
return configured
|
|
if provider in IMPLICIT_AUTH_PROVIDERS:
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.IMPLICIT,
|
|
provider=provider,
|
|
env_var=env_var,
|
|
detail="implicit auth",
|
|
)
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING,
|
|
provider=provider,
|
|
env_var=env_var,
|
|
detail=f"{env_var} is not set or is empty",
|
|
)
|
|
|
|
if provider in IMPLICIT_AUTH_PROVIDERS:
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.IMPLICIT,
|
|
provider=provider,
|
|
detail="implicit auth",
|
|
)
|
|
|
|
optional_env = OPTIONAL_AUTH_ENV.get(provider)
|
|
if optional_env:
|
|
configured = _resolve_configured(provider, optional_env)
|
|
if configured:
|
|
return configured
|
|
|
|
if provider in NO_AUTH_REQUIRED_PROVIDERS:
|
|
endpoint = _get_provider_endpoint(provider, config)
|
|
if _is_local_endpoint(endpoint):
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.NOT_REQUIRED,
|
|
provider=provider,
|
|
detail="local provider",
|
|
)
|
|
# Remote endpoint may or may not require auth (private network vs.
|
|
# hosted). Don't block; surface the optional env var as a hint.
|
|
detail = (
|
|
f"remote endpoint; set {optional_env} if auth is required"
|
|
if optional_env
|
|
else "remote endpoint"
|
|
)
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.UNKNOWN,
|
|
provider=provider,
|
|
env_var=optional_env,
|
|
detail=detail,
|
|
)
|
|
|
|
# Provider not found in config or hardcoded map — credential status is
|
|
# unknown. The provider itself will report auth failures at
|
|
# model-creation time.
|
|
logger.debug(
|
|
"No credential information for provider '%s'; deferring auth to provider",
|
|
provider,
|
|
)
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.UNKNOWN,
|
|
provider=provider,
|
|
detail="credentials unknown",
|
|
)
|
|
|
|
|
|
def has_provider_credentials(provider: str) -> bool | None:
|
|
"""Check if credentials are available for a provider.
|
|
|
|
This compatibility wrapper preserves the historic tri-state contract while
|
|
`get_provider_auth_status()` carries the richer user-facing distinctions:
|
|
configured credentials, missing credentials, no-auth local providers,
|
|
implicit auth, custom provider-managed auth, and unknown providers.
|
|
|
|
Args:
|
|
provider: Provider name (e.g., `"anthropic"`, `"openai"`).
|
|
|
|
Returns:
|
|
`True` if auth is configured, implicit, provider-managed, or not
|
|
required.
|
|
`False` if a required env var is known but not set.
|
|
`None` if credential status cannot be determined.
|
|
"""
|
|
return get_provider_auth_status(provider).as_legacy_bool()
|
|
|
|
|
|
def get_credential_env_var(provider: str) -> str | None:
|
|
"""Return the env var name that holds credentials for a provider.
|
|
|
|
Checks the config file first (user override), then falls back to the
|
|
hardcoded `PROVIDER_API_KEY_ENV` map.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
Environment variable name, or None if unknown.
|
|
"""
|
|
config = ModelConfig.load()
|
|
config_env = config.get_api_key_env(provider)
|
|
if config_env:
|
|
return config_env
|
|
return PROVIDER_API_KEY_ENV.get(provider)
|
|
|
|
|
|
def get_base_url_env_vars(provider: str) -> tuple[str, ...]:
|
|
"""Return base-URL env var names for a provider in resolution order.
|
|
|
|
Checks the config file's `base_url_env` first (user override), then falls
|
|
back to the hardcoded `PROVIDER_BASE_URL_ENV` map.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
Environment variable names, or an empty tuple if the provider has no
|
|
base-URL env var (config-declared or built-in).
|
|
"""
|
|
config = ModelConfig.load()
|
|
config_env = config.get_base_url_env(provider)
|
|
if config_env:
|
|
return (config_env,)
|
|
return PROVIDER_BASE_URL_ENV.get(provider, ())
|
|
|
|
|
|
def get_base_url_env_var(provider: str) -> str | None:
|
|
"""Return the canonical base-URL env var name for a provider.
|
|
|
|
Checks the config file's `base_url_env` first (user override), then falls
|
|
back to the canonical name in the hardcoded `PROVIDER_BASE_URL_ENV` map.
|
|
Parallel to `get_credential_env_var`.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
Environment variable name, or None if the provider has no base-URL env
|
|
var (config-declared or built-in).
|
|
"""
|
|
env_vars = get_base_url_env_vars(provider)
|
|
return env_vars[0] if env_vars else None
|
|
|
|
|
|
def get_default_base_url_env(provider: str) -> str | None:
|
|
"""Return the env var that supplies a provider's endpoint when none is stored.
|
|
|
|
Answers "what does leaving the `/auth` base-URL field blank fall back to?"
|
|
A blank save clears the *plain* endpoint env vars (so an inherited gateway
|
|
URL can't leak through — see `apply_stored_credentials`), so the only env
|
|
var that still supplies a value afterward is the `DEEPAGENTS_CODE_`-prefixed
|
|
one. The name is returned (not its value) for display next to the field, so
|
|
the user sees the knob rather than a long or sensitive URL.
|
|
|
|
Returns `None` when that variable holds no value — the endpoint then comes
|
|
from a `config.toml` literal or the provider SDK's own default, neither of
|
|
which is a single env var to name here.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
The `DEEPAGENTS_CODE_`-prefixed env var name still in effect after a
|
|
blank save, or `None`.
|
|
"""
|
|
for env_var in get_base_url_env_vars(provider):
|
|
prefixed = f"{_ENV_PREFIX}{env_var}"
|
|
if os.environ.get(prefixed):
|
|
return prefixed
|
|
return None
|
|
|
|
|
|
def is_service(name: str) -> bool:
|
|
"""Return whether `name` is a non-model service configurable via `/auth`."""
|
|
return name in SERVICE_API_KEY_ENV
|
|
|
|
|
|
def is_langsmith(name: str) -> bool:
|
|
"""Return whether `name` is the LangSmith tracing service.
|
|
|
|
Centralizes the identity check so the LangSmith-specific branches (project
|
|
field instead of a base URL, tracing auto-enable) share one definition
|
|
rather than scattering `== LANGSMITH_SERVICE` comparisons.
|
|
"""
|
|
return name == LANGSMITH_SERVICE
|
|
|
|
|
|
def get_service_auth_status(service: str) -> ProviderAuthStatus:
|
|
"""Return credential readiness for a non-model service (e.g. `"tavily"`).
|
|
|
|
Mirrors `get_provider_auth_status` but is scoped to `SERVICE_API_KEY_ENV`,
|
|
so a stored key beats the env var and the `/auth` manager can render the
|
|
same `[stored]` / `[env: ...]` / `[missing]` badges.
|
|
|
|
Args:
|
|
service: Service name (e.g. `"tavily"`).
|
|
|
|
Returns:
|
|
`CONFIGURED` when a stored or env credential is set, else `MISSING`.
|
|
"""
|
|
env_var = SERVICE_API_KEY_ENV[service]
|
|
configured = _resolve_configured(service, env_var)
|
|
if configured:
|
|
return configured
|
|
return ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING,
|
|
provider=service,
|
|
env_var=env_var,
|
|
detail=f"{env_var} is not set or is empty",
|
|
)
|
|
|
|
|
|
def apply_stored_service_credentials() -> None:
|
|
"""Export every stored service key into `os.environ`.
|
|
|
|
Services (e.g. web search via Tavily) have no base URL to reconcile, so
|
|
this is a plain key copy onto the canonical env var name the underlying
|
|
SDK reads. A stored key takes precedence over an existing plain env var,
|
|
matching `apply_stored_credentials`; a `DEEPAGENTS_CODE_`-prefixed override
|
|
is left authoritative because the app already treats it as the top-priority
|
|
per-session credential.
|
|
"""
|
|
for service, env_var in SERVICE_API_KEY_ENV.items():
|
|
try:
|
|
stored = auth_store.get_stored_key(service)
|
|
except RuntimeError:
|
|
logger.warning(
|
|
"Could not read stored credentials for service %s; the credential "
|
|
"file may be corrupt. Re-add the key via /auth.",
|
|
service,
|
|
)
|
|
continue
|
|
if not stored:
|
|
continue
|
|
prefixed = f"{_ENV_PREFIX}{env_var}"
|
|
if prefixed in os.environ:
|
|
continue
|
|
if os.environ.get(env_var) != stored:
|
|
os.environ[env_var] = stored
|
|
|
|
|
|
def apply_stored_credentials(provider: str) -> bool:
|
|
"""Export this provider's stored key *and endpoint* into `os.environ`.
|
|
|
|
LangChain's chat-model factories read credentials from process env vars,
|
|
so a stored key only takes effect once it's copied onto the env var name
|
|
registered for that provider. This is a no-op when the provider has no
|
|
env-var mapping (custom auth) or no stored credential.
|
|
|
|
The key env var is overwritten whether or not it was already set, matching
|
|
the precedence rule documented on `resolve_provider_credential`: a
|
|
credential the user typed in `/auth` is the most recent deliberate
|
|
action and should take effect.
|
|
|
|
Because a key and its endpoint are a coherent pair (a gateway key only
|
|
works against the gateway URL; a provider-native key only against the
|
|
provider's own endpoint), the base URL is applied atomically with the key:
|
|
|
|
- A stored `base_url` is written to the provider's canonical base-URL env
|
|
var, and every *other* base-URL name the SDK reads is cleared so an
|
|
inherited gateway URL can't leak through an alternate variable.
|
|
- No stored `base_url` (the user left the field blank) clears *all* of the
|
|
provider's base-URL env vars, so the SDK falls back to the provider
|
|
default rather than an inherited gateway URL. This is what prevents a
|
|
personal key from being shipped to the gateway.
|
|
|
|
Only the unprefixed canonical names are written, so an explicit
|
|
`DEEPAGENTS_CODE_{VAR}` override still wins via `resolve_env_var`.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
|
|
Returns:
|
|
`True` if a stored key was applied, `False` otherwise.
|
|
"""
|
|
env_var = get_credential_env_var(provider)
|
|
if not env_var:
|
|
return False
|
|
try:
|
|
stored = auth_store.get_stored_key(provider)
|
|
stored_base_url = auth_store.get_stored_base_url(provider)
|
|
except RuntimeError:
|
|
logger.warning("Could not read stored credentials for provider %s", provider)
|
|
return False
|
|
if not stored:
|
|
return False
|
|
# Reconcile the endpoint first: it resolves env-var names (which can touch
|
|
# the config) and so is the only step that might raise. Doing it before the
|
|
# key write means the key is never left applied while an inherited gateway
|
|
# URL stays uncleared — the key and endpoint move together.
|
|
_apply_stored_base_url(provider, stored_base_url)
|
|
if os.environ.get(env_var) != stored:
|
|
os.environ[env_var] = stored
|
|
return True
|
|
|
|
|
|
def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
|
|
"""Reconcile a provider's base-URL env vars with a `/auth` credential.
|
|
|
|
Writes `base_url` to the canonical name and clears the alternates, or
|
|
clears every name when `base_url` is `None` (reset to the provider
|
|
default). See `apply_stored_credentials` for the pairing rationale.
|
|
|
|
When switching to a provider-native key (no `base_url`), also clears the
|
|
provider's custom-headers env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) so a
|
|
gateway-provisioned auth header isn't sent to the native endpoint.
|
|
|
|
Args:
|
|
provider: Provider name.
|
|
base_url: The stored endpoint, or `None` to reset to the default.
|
|
"""
|
|
canonical = get_base_url_env_var(provider)
|
|
# Clear every name the SDK might read: the built-in alternates plus any
|
|
# config-declared `base_url_env` (which extends pairing to providers
|
|
# outside the hardcoded set).
|
|
names = set(PROVIDER_BASE_URL_ENV.get(provider, ()))
|
|
if canonical:
|
|
names.add(canonical)
|
|
if not names:
|
|
return
|
|
configured_base_url_survives = _configured_base_url_survives_env_clear(provider)
|
|
for name in names:
|
|
if base_url and name == canonical:
|
|
os.environ[name] = base_url
|
|
else:
|
|
os.environ.pop(name, None)
|
|
|
|
# A provider SDK's custom-header env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`)
|
|
# injects headers into every request. A gateway-provisioned environment
|
|
# often sets it to `X-Api-Key: <gateway-key>`, which overrides the SDK's
|
|
# own `api_key`-derived header. When switching to a provider-native key
|
|
# (no stored `base_url`), that header must also be cleared — otherwise the
|
|
# gateway key is sent to the native endpoint and rejected.
|
|
custom_headers_env = PROVIDER_CUSTOM_HEADERS_ENV.get(provider)
|
|
if custom_headers_env and not base_url:
|
|
if not configured_base_url_survives:
|
|
if os.environ.pop(custom_headers_env, None) is not None:
|
|
# Log the env var name only — never its value, which carries
|
|
# auth headers. Surfaces the removal for the user who set a
|
|
# header deliberately for the native endpoint and later wonders
|
|
# where it went.
|
|
logger.info(
|
|
"Cleared %s while applying a provider-native %s key",
|
|
custom_headers_env,
|
|
provider,
|
|
)
|
|
elif os.environ.get(custom_headers_env) is not None:
|
|
# A provider base URL still routes (config or a prefixed env var),
|
|
# so the custom-header env is deliberately kept. Log the name only —
|
|
# never the value — so the retention is observable when a user later
|
|
# wonders why a gateway header is still in effect after applying a
|
|
# native key.
|
|
logger.debug(
|
|
"Kept %s: a %s base URL is still configured",
|
|
custom_headers_env,
|
|
provider,
|
|
)
|
|
|
|
|
|
def _configured_base_url_survives_env_clear(provider: str) -> bool:
|
|
"""Return whether endpoint config still routes after plain env cleanup."""
|
|
config = ModelConfig.load()
|
|
provider_cfg = config.providers.get(provider)
|
|
if provider_cfg and provider_cfg.get("base_url"):
|
|
return True
|
|
for env_var in get_base_url_env_vars(provider):
|
|
if os.environ.get(f"{_ENV_PREFIX}{env_var}"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def warn_on_split_credential_source(provider: str) -> None:
|
|
"""Log when a provider's key and endpoint resolve from different env tiers.
|
|
|
|
The `DEEPAGENTS_CODE_` prefix is a *per-variable* override, not a credential
|
|
bundle: setting `DEEPAGENTS_CODE_OPENAI_API_KEY` while leaving the endpoint to
|
|
a plain `OPENAI_BASE_URL` makes the key resolve from the prefixed tier and the
|
|
endpoint from the unprefixed one. A key and its endpoint are a coherent pair
|
|
(see `PROVIDER_BASE_URL_ENV`), so a split source is a likely misconfiguration
|
|
-- e.g. a provider-native key shipped to a gateway URL, or vice versa.
|
|
|
|
This is purely diagnostic: it never mutates `os.environ` or changes
|
|
resolution. Only the env var *names* are logged, never the secret value or
|
|
the URL. It is emitted at DEBUG because the `deepagents_code` package logger
|
|
only attaches a handler when `DEEPAGENTS_CODE_DEBUG` is set, and DEBUG stays
|
|
below `logging.lastResort`'s WARNING stderr threshold so it cannot bleed onto
|
|
stderr and corrupt the Textual TUI. The `DEEPAGENTS_CODE_DEBUG` file log is
|
|
where someone chasing a wrong-endpoint bug will look.
|
|
|
|
A `config.toml` `base_url` literal wins over env vars in `get_base_url`, so
|
|
when one is set there is no env-tier split to flag and this returns early.
|
|
|
|
Args:
|
|
provider: Provider name (e.g. `"openai"`).
|
|
"""
|
|
key_env = get_credential_env_var(provider)
|
|
base_env = get_base_url_env_var(provider)
|
|
if not key_env or not base_env:
|
|
return
|
|
config = ModelConfig.load()
|
|
provider_cfg = config.providers.get(provider)
|
|
if provider_cfg and provider_cfg.get("base_url"):
|
|
return
|
|
prefixed_key = f"{_ENV_PREFIX}{key_env}"
|
|
prefixed_base = f"{_ENV_PREFIX}{base_env}"
|
|
# Key must actually resolve from the prefixed tier (present and non-empty),
|
|
# while the endpoint falls back to the plain tier: no prefixed override
|
|
# present (an empty prefixed var would shadow the plain one in
|
|
# `resolve_env_var`, so its mere presence means the endpoint is not "plain").
|
|
key_from_prefixed = bool(os.environ.get(prefixed_key))
|
|
base_from_plain = prefixed_base not in os.environ and bool(os.environ.get(base_env))
|
|
if key_from_prefixed and base_from_plain:
|
|
logger.debug(
|
|
"Provider %s: API key resolved from %s but base URL resolved from "
|
|
"the unprefixed %s. Key and endpoint came from different sources and "
|
|
"may not be a matching pair. Set %s to pin the endpoint, or unset %s.",
|
|
provider,
|
|
prefixed_key,
|
|
base_env,
|
|
prefixed_base,
|
|
base_env,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelConfig:
|
|
"""Parsed model configuration from `config.toml`.
|
|
|
|
Instances are immutable once constructed. The `providers` mapping is
|
|
wrapped in `MappingProxyType` to prevent accidental mutation of the
|
|
globally cached singleton returned by `load()`.
|
|
"""
|
|
|
|
default_model: str | None = None
|
|
"""The user's intentional default model (from config file `[models].default`)."""
|
|
|
|
recent_model: str | None = None
|
|
"""The most recently switched-to model (from config file `[models].recent`)."""
|
|
|
|
providers: Mapping[str, ProviderConfig] = field(default_factory=dict)
|
|
"""Read-only mapping of provider names to their configurations."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Freeze the providers dict into a read-only proxy."""
|
|
if not isinstance(self.providers, MappingProxyType):
|
|
object.__setattr__(self, "providers", MappingProxyType(self.providers))
|
|
|
|
@classmethod
|
|
def load(cls, config_path: Path | None = None) -> ModelConfig:
|
|
"""Load config from file.
|
|
|
|
When called with the default path, results are cached for the
|
|
lifetime of the process. Use `clear_caches()` to reset.
|
|
|
|
Args:
|
|
config_path: Path to config file. Defaults to ~/.deepagents/config.toml.
|
|
|
|
Returns:
|
|
Parsed `ModelConfig` instance.
|
|
Returns empty config if file is missing, unreadable, contains
|
|
invalid TOML syntax, or is structurally invalid (valid TOML of
|
|
the wrong shape, e.g. a scalar `[models]`).
|
|
"""
|
|
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
is_default = config_path is None
|
|
if is_default and _default_config_cache is not None:
|
|
return _default_config_cache
|
|
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
if not config_path.exists():
|
|
fallback = cls()
|
|
if is_default:
|
|
_default_config_cache = fallback
|
|
return fallback
|
|
|
|
try:
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
models_section = data.get("models", {})
|
|
config = cls(
|
|
default_model=models_section.get("default"),
|
|
recent_model=models_section.get("recent"),
|
|
providers=models_section.get("providers", {}),
|
|
)
|
|
except tomllib.TOMLDecodeError as e:
|
|
logger.warning(
|
|
"Config file %s has invalid TOML syntax: %s. "
|
|
"Ignoring config file. Fix the file or delete it to reset.",
|
|
config_path,
|
|
e,
|
|
)
|
|
config = cls()
|
|
except (PermissionError, OSError) as e:
|
|
logger.warning("Could not read config file %s: %s", config_path, e)
|
|
config = cls()
|
|
except (AttributeError, TypeError) as e:
|
|
# Syntactically valid TOML can still have the wrong shape — a scalar
|
|
# `[models]`, a non-table `providers` — which surfaces here as an
|
|
# AttributeError from `.get(...)` or a TypeError from the dataclass
|
|
# constructor. Treat it like any other unreadable config rather than
|
|
# letting it crash callers (e.g. the /auth modal on Ctrl+R) that
|
|
# assume load() is total and never raises.
|
|
logger.warning(
|
|
"Config file %s is structurally invalid: %s. "
|
|
"Ignoring config file. Fix the file or delete it to reset.",
|
|
config_path,
|
|
e,
|
|
)
|
|
config = cls()
|
|
|
|
# Validate config consistency
|
|
config._validate()
|
|
|
|
if is_default:
|
|
_default_config_cache = config
|
|
|
|
return config
|
|
|
|
def _validate(self) -> None:
|
|
"""Validate internal consistency of the config.
|
|
|
|
Issues warnings for invalid configurations but does not raise exceptions,
|
|
allowing the app to continue with potentially degraded functionality.
|
|
"""
|
|
# Warn if default_model is set but doesn't use provider:model format
|
|
if self.default_model and ":" not in self.default_model:
|
|
logger.warning(
|
|
"default_model '%s' should use provider:model format "
|
|
"(e.g., 'anthropic:claude-sonnet-4-5')",
|
|
self.default_model,
|
|
)
|
|
|
|
# Warn if recent_model is set but doesn't use provider:model format
|
|
if self.recent_model and ":" not in self.recent_model:
|
|
logger.warning(
|
|
"recent_model '%s' should use provider:model format "
|
|
"(e.g., 'anthropic:claude-sonnet-4-5')",
|
|
self.recent_model,
|
|
)
|
|
|
|
# Validate enabled field type and class_path format / params references
|
|
for name, provider in self.providers.items():
|
|
# `enabled` originates from untyped TOML; cast to `object` so the
|
|
# runtime non-bool validation below stays reachable (the TypedDict
|
|
# types it as `bool`, which would otherwise mark this branch dead).
|
|
enabled = cast("object", provider.get("enabled"))
|
|
if enabled is not None and not isinstance(enabled, bool):
|
|
logger.warning(
|
|
"Provider '%s' has non-boolean 'enabled' value %r "
|
|
"(expected true/false). Provider will remain visible.",
|
|
name,
|
|
enabled,
|
|
)
|
|
|
|
# `display_name`/`api_key_url` also originate from untyped TOML; cast
|
|
# to `object` so the runtime non-string checks stay reachable (the
|
|
# TypedDict types them as `str`).
|
|
display_name = cast("object", provider.get("display_name"))
|
|
if display_name is not None and not isinstance(display_name, str):
|
|
logger.warning(
|
|
"Provider '%s' has non-string 'display_name' value %r "
|
|
"(expected a string). Falling back to the default label.",
|
|
name,
|
|
display_name,
|
|
)
|
|
|
|
short_name = cast("object", provider.get("short_name"))
|
|
if short_name is not None and not isinstance(short_name, str):
|
|
logger.warning(
|
|
"Provider '%s' has non-string 'short_name' value %r "
|
|
"(expected a string). Falling back to the display name.",
|
|
name,
|
|
short_name,
|
|
)
|
|
|
|
api_key_url = cast("object", provider.get("api_key_url"))
|
|
if api_key_url is not None and not isinstance(api_key_url, str):
|
|
logger.warning(
|
|
"Provider '%s' has non-string 'api_key_url' value %r "
|
|
"(expected a string). Ignoring it.",
|
|
name,
|
|
api_key_url,
|
|
)
|
|
|
|
class_path = provider.get("class_path")
|
|
if class_path and ":" not in class_path:
|
|
logger.warning(
|
|
"Provider '%s' has invalid class_path '%s': "
|
|
"must be in module.path:ClassName format "
|
|
"(e.g., 'my_package.models:MyChatModel')",
|
|
name,
|
|
class_path,
|
|
)
|
|
|
|
models = set(provider.get("models", []))
|
|
|
|
params = provider.get("params", {})
|
|
for key, value in params.items():
|
|
if isinstance(value, dict) and key not in models:
|
|
logger.warning(
|
|
"Provider '%s' has params for '%s' "
|
|
"which is not in its models list",
|
|
name,
|
|
key,
|
|
)
|
|
|
|
def is_provider_enabled(self, provider_name: str) -> bool:
|
|
"""Check whether a provider should appear in the model switcher.
|
|
|
|
A provider is disabled when its config explicitly sets
|
|
`enabled = false`. Providers not present in the config file are
|
|
always considered enabled.
|
|
|
|
Args:
|
|
provider_name: The provider to check.
|
|
|
|
Returns:
|
|
`False` if the provider is explicitly disabled, `True` otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
if not provider:
|
|
return True
|
|
return provider.get("enabled") is not False
|
|
|
|
def get_all_models(self) -> list[tuple[str, str]]:
|
|
"""Get all models as `(model_name, provider_name)` tuples.
|
|
|
|
Returns raw config data — does not filter by `is_provider_enabled`.
|
|
For the filtered set shown in the model switcher, use
|
|
`get_available_models()`.
|
|
|
|
Returns:
|
|
List of tuples containing `(model_name, provider_name)`.
|
|
"""
|
|
return [
|
|
(model, provider_name)
|
|
for provider_name, provider_config in self.providers.items()
|
|
for model in provider_config.get("models", [])
|
|
]
|
|
|
|
def get_provider_for_model(self, model_name: str) -> str | None:
|
|
"""Find the provider that contains this model.
|
|
|
|
Returns raw config data — does not filter by `is_provider_enabled`.
|
|
|
|
Args:
|
|
model_name: The model identifier to look up.
|
|
|
|
Returns:
|
|
Provider name if found, None otherwise.
|
|
"""
|
|
for provider_name, provider_config in self.providers.items():
|
|
if model_name in provider_config.get("models", []):
|
|
return provider_name
|
|
return None
|
|
|
|
def has_credentials(self, provider_name: str) -> bool | None:
|
|
"""Check if credentials are available for a provider.
|
|
|
|
This is the config-file-driven credential check, supporting custom
|
|
providers (e.g., local Ollama with no key required). For the hardcoded
|
|
`PROVIDER_API_KEY_ENV`-based check used in the hot-swap path, see the
|
|
module-level `has_provider_credentials()`.
|
|
|
|
Args:
|
|
provider_name: The provider to check.
|
|
|
|
Returns:
|
|
True if credentials are confirmed available, False if confirmed
|
|
missing, or None if no `api_key_env` is configured and
|
|
credential status cannot be determined.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
if not provider:
|
|
return False
|
|
env_var = provider.get("api_key_env")
|
|
if not env_var:
|
|
return None # No key configured — can't verify
|
|
return bool(resolve_env_var(env_var))
|
|
|
|
def get_base_url(self, provider_name: str) -> str | None:
|
|
"""Get the configured base URL for a provider.
|
|
|
|
Resolution order (first match wins):
|
|
|
|
1. `base_url` in the provider's `config.toml` section.
|
|
2. The provider's base-URL env vars via `resolve_env_var`, in provider
|
|
precedence order, so `DEEPAGENTS_CODE_{VAR}` beats the plain `{VAR}`
|
|
for each name — mirroring how API keys resolve. This also surfaces
|
|
the value `apply_stored_credentials` bridged in from a `/auth`
|
|
credential, and the gateway-provisioned URL in the default
|
|
(no-override) case.
|
|
3. The endpoint stored with a `/auth` credential. This is the source
|
|
for providers that have no base-URL env var (e.g. an OpenAI-
|
|
compatible provider like Litellm): step 2 has no name to read, so
|
|
the stored endpoint is taken directly. It then reaches the model as
|
|
the `base_url` constructor kwarg via
|
|
`_get_provider_kwargs`, the same path a `config.toml` literal uses.
|
|
For providers that *do* have an env var, the stored endpoint already
|
|
arrives via step 2 (it was bridged onto the env var), so this step
|
|
is a redundant — and consistent — fallback.
|
|
|
|
This function only *resolves* the endpoint; whether it takes effect is a
|
|
separate contract owned by the provider's LangChain class. The value is
|
|
delivered as the `base_url` kwarg (see `_get_provider_kwargs`), which the
|
|
OpenAI/Anthropic-compatible classes accept via a Pydantic `base_url`
|
|
alias. A class that names the field differently may silently
|
|
ignore `base_url` — Pydantic models default to `extra="ignore"` — so for
|
|
those the endpoint must be set via `params`.
|
|
|
|
A corrupt credential store is treated as "no stored endpoint" rather than
|
|
propagating, so endpoint resolution never newly raises.
|
|
|
|
Args:
|
|
provider_name: The provider to get base URL for.
|
|
|
|
Returns:
|
|
Base URL if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
config_url = provider.get("base_url") if provider else None
|
|
if config_url:
|
|
return config_url
|
|
config_env = provider.get("base_url_env") if provider else None
|
|
env_vars = (
|
|
(config_env,)
|
|
if config_env
|
|
else PROVIDER_BASE_URL_ENV.get(provider_name, ())
|
|
)
|
|
for env_var in env_vars:
|
|
resolved = resolve_env_var(env_var)
|
|
if resolved:
|
|
return resolved
|
|
try:
|
|
return auth_store.get_stored_base_url(provider_name)
|
|
except RuntimeError:
|
|
return None
|
|
|
|
def get_api_key_env(self, provider_name: str) -> str | None:
|
|
"""Get the environment variable name for a provider's API key.
|
|
|
|
Args:
|
|
provider_name: The provider to get API key env var for.
|
|
|
|
Returns:
|
|
Environment variable name if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
return provider.get("api_key_env") if provider else None
|
|
|
|
def get_provider_display_name(self, provider_name: str) -> str | None:
|
|
"""Get the configured display name for a provider.
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
|
|
Returns:
|
|
Human-readable display name if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
name = provider.get("display_name") if provider else None
|
|
return name if isinstance(name, str) else None
|
|
|
|
def get_provider_short_name(self, provider_name: str) -> str | None:
|
|
"""Get the configured compact brand name for a provider.
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
|
|
Returns:
|
|
Compact brand name if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
name = provider.get("short_name") if provider else None
|
|
return name if isinstance(name, str) else None
|
|
|
|
def get_provider_api_key_url(self, provider_name: str) -> str | None:
|
|
"""Get the configured API-key management URL for a provider.
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
|
|
Returns:
|
|
API-key management URL if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
url = provider.get("api_key_url") if provider else None
|
|
return url if isinstance(url, str) else None
|
|
|
|
def get_base_url_env(self, provider_name: str) -> str | None:
|
|
"""Get the environment variable name for a provider's base URL.
|
|
|
|
Args:
|
|
provider_name: The provider to get the base-URL env var for.
|
|
|
|
Returns:
|
|
Environment variable name if configured, None otherwise.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
return provider.get("base_url_env") if provider else None
|
|
|
|
def get_class_path(self, provider_name: str) -> str | None:
|
|
"""Get the custom class path for a provider.
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
|
|
Returns:
|
|
Class path in `module.path:ClassName` format, or None.
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
return provider.get("class_path") if provider else None
|
|
|
|
def get_kwargs(
|
|
self, provider_name: str, *, model_name: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Get extra constructor kwargs for a provider.
|
|
|
|
Reads the `params` table from the provider config. Flat keys are
|
|
provider-wide defaults; model-keyed sub-tables are per-model
|
|
overrides that shallow-merge on top (model wins on conflict).
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
model_name: Optional model name for per-model overrides.
|
|
|
|
Returns:
|
|
Dictionary of extra kwargs (empty if none configured).
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
if not provider:
|
|
return {}
|
|
params = provider.get("params", {})
|
|
result = {k: v for k, v in params.items() if not isinstance(v, dict)}
|
|
if model_name:
|
|
overrides = params.get(model_name)
|
|
if isinstance(overrides, dict):
|
|
result.update(overrides)
|
|
return result
|
|
|
|
def get_profile_overrides(
|
|
self, provider_name: str, *, model_name: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Get profile overrides for a provider.
|
|
|
|
Reads the `profile` table from the provider config. Flat keys are
|
|
provider-wide defaults; model-keyed sub-tables are per-model overrides
|
|
that shallow-merge on top (model wins on conflict).
|
|
|
|
Args:
|
|
provider_name: The provider to look up.
|
|
model_name: Optional model name for per-model overrides.
|
|
|
|
Returns:
|
|
Dictionary of profile overrides (empty if none configured).
|
|
"""
|
|
provider = self.providers.get(provider_name)
|
|
if not provider:
|
|
return {}
|
|
profile = provider.get("profile", {})
|
|
result = {k: v for k, v in profile.items() if not isinstance(v, dict)}
|
|
if model_name:
|
|
overrides = profile.get(model_name)
|
|
if isinstance(overrides, dict):
|
|
result.update(overrides)
|
|
return result
|
|
|
|
|
|
def _save_toml_field(
|
|
section: str,
|
|
field: str,
|
|
value: str,
|
|
config_path: Path | None = None,
|
|
) -> bool:
|
|
"""Read-modify-write a `[section].<field>` key in the config file.
|
|
|
|
Args:
|
|
section: TOML table name (e.g., `'models'`, `'agents'`).
|
|
field: Key within the table (e.g., `'default'`, `'recent'`).
|
|
value: String value to persist.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Read existing config or start fresh
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
if section not in data:
|
|
data[section] = {}
|
|
data[section][field] = value
|
|
|
|
# Write to temp file then rename so an interrupted write can't corrupt
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
# Clean up temp file on any failure
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
# `TypeError` covers `tomli_w.dump` rejecting a non-serializable
|
|
# payload; `ValueError` covers things like `os.fdopen` on a
|
|
# closed fd. Folding them in keeps the `bool` contract intact for
|
|
# the UI branches that toggle on the return value.
|
|
logger.exception("Could not save %s.%s preference", section, field)
|
|
return False
|
|
else:
|
|
# Invalidate config cache so the next load() picks up the change.
|
|
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
_default_config_cache = None
|
|
return True
|
|
|
|
|
|
def _save_model_field(
|
|
field: str, model_spec: str, config_path: Path | None = None
|
|
) -> bool:
|
|
"""Read-modify-write a `[models].<field>` key in the config file.
|
|
|
|
Thin wrapper around `_save_toml_field` for the `[models]` section.
|
|
|
|
Args:
|
|
field: Key name under the `[models]` table (e.g., `'default'` or `'recent'`).
|
|
model_spec: The model to save in `provider:model` format.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
"""
|
|
return _save_toml_field("models", field, model_spec, config_path)
|
|
|
|
|
|
def save_default_model(model_spec: str, config_path: Path | None = None) -> bool:
|
|
"""Update the default model in config file.
|
|
|
|
Reads existing config (if any), updates `[models].default`, and writes
|
|
back using proper TOML serialization.
|
|
|
|
Args:
|
|
model_spec: The model to set as default in `provider:model` format.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
|
|
Note:
|
|
This function does not preserve comments in the config file.
|
|
"""
|
|
return _save_model_field("default", model_spec, config_path)
|
|
|
|
|
|
def clear_default_model(config_path: Path | None = None) -> bool:
|
|
"""Remove the default model from the config file.
|
|
|
|
Deletes the `[models].default` key so that future launches fall back to
|
|
`[models].recent` or environment auto-detection.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if the key was removed (or was already absent), False on I/O error.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
if not config_path.exists():
|
|
return True # Nothing to clear
|
|
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
|
|
models_section = data.get("models")
|
|
if not isinstance(models_section, dict) or "default" not in models_section:
|
|
return True # Already absent
|
|
|
|
del models_section["default"]
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
# See `_save_toml_field` for why `TypeError` / `ValueError` are
|
|
# folded into the bool return contract.
|
|
logger.exception("Could not clear default model preference")
|
|
return False
|
|
else:
|
|
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
_default_config_cache = None
|
|
return True
|
|
|
|
|
|
def save_effort_for_model(
|
|
model_spec: str,
|
|
effort: str,
|
|
config_path: Path | None = None,
|
|
) -> bool:
|
|
"""Persist the selected reasoning effort for a model.
|
|
|
|
Args:
|
|
model_spec: Model in `provider:model` format.
|
|
effort: Reasoning effort label selected by the user.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
`True` if save succeeded, `False` if it failed.
|
|
"""
|
|
return _update_effort_for_model(model_spec, effort, config_path)
|
|
|
|
|
|
def load_effort_for_model(
|
|
model_spec: str,
|
|
config_path: Path | None = None,
|
|
) -> str | None:
|
|
"""Load the selected reasoning effort for a model.
|
|
|
|
Args:
|
|
model_spec: Model in `provider:model` format.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
The persisted effort label, or `None`. `None` is returned both when no
|
|
preference is stored and when one exists but cannot be read (unreadable
|
|
file, invalid TOML, or a malformed `[effort]` section); the two cases
|
|
are not distinguished by the return value, but a read failure is always
|
|
logged rather than swallowed silently.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
if not config_path.exists():
|
|
return None
|
|
|
|
try:
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
effort_section = data.get("effort")
|
|
if effort_section is None:
|
|
return None # No preference stored; not a failure.
|
|
if not isinstance(effort_section, dict):
|
|
logger.warning(
|
|
"Ignoring malformed [effort] in %s: expected a table, got %s",
|
|
config_path,
|
|
type(effort_section).__name__,
|
|
)
|
|
return None
|
|
by_model = effort_section.get("by_model")
|
|
if by_model is None:
|
|
return None
|
|
if not isinstance(by_model, dict):
|
|
logger.warning(
|
|
"Ignoring malformed [effort.by_model] in %s: expected a table, got %s",
|
|
config_path,
|
|
type(by_model).__name__,
|
|
)
|
|
return None
|
|
effort = by_model.get(model_spec)
|
|
if effort is None:
|
|
return None
|
|
if not isinstance(effort, str):
|
|
logger.warning(
|
|
"Ignoring malformed reasoning effort for %s in %s: expected a "
|
|
"string, got %s",
|
|
model_spec,
|
|
config_path,
|
|
type(effort).__name__,
|
|
)
|
|
return None
|
|
return effort.strip() or None
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception(
|
|
"Could not load reasoning effort preference for %s", model_spec
|
|
)
|
|
return None
|
|
|
|
|
|
def clear_effort_for_model(
|
|
model_spec: str,
|
|
config_path: Path | None = None,
|
|
) -> bool:
|
|
"""Remove the selected reasoning effort for a model.
|
|
|
|
Args:
|
|
model_spec: Model in `provider:model` format.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
`True` if the entry was removed or absent, `False` if clearing failed.
|
|
"""
|
|
return _update_effort_for_model(model_spec, None, config_path)
|
|
|
|
|
|
def _update_effort_for_model(
|
|
model_spec: str,
|
|
effort: str | None,
|
|
config_path: Path | None = None,
|
|
) -> bool:
|
|
"""Read-modify-write one entry in `[effort.by_model]`.
|
|
|
|
Args:
|
|
model_spec: Model in `provider:model` format.
|
|
effort: Reasoning effort label to save, or `None` to clear it.
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
`True` if the update succeeded, `False` if it failed.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
if effort is None and not config_path.exists():
|
|
return True
|
|
|
|
def _require_table(value: object, name: str) -> dict:
|
|
if not isinstance(value, dict):
|
|
msg = f"{name} must be a table"
|
|
raise TypeError(msg)
|
|
return value
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
effort_section = _require_table(data.setdefault("effort", {}), "[effort]")
|
|
by_model = _require_table(
|
|
effort_section.setdefault("by_model", {}), "[effort.by_model]"
|
|
)
|
|
|
|
if effort is None:
|
|
if model_spec not in by_model:
|
|
return True
|
|
del by_model[model_spec]
|
|
if not by_model:
|
|
del effort_section["by_model"]
|
|
if not effort_section:
|
|
del data["effort"]
|
|
else:
|
|
by_model[model_spec] = effort
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
logger.exception(
|
|
"Could not update reasoning effort preference for %s", model_spec
|
|
)
|
|
return False
|
|
else:
|
|
# `_default_config_cache` holds only the `[models]` table (default /
|
|
# recent / providers), never `[effort]`, so this write cannot stale it.
|
|
# Invalidating anyway is defensive parity with the other config writers
|
|
# (`_save_toml_field`, `clear_default_model`, ...) that share the file.
|
|
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
_default_config_cache = None
|
|
return True
|
|
|
|
|
|
def is_warning_suppressed(key: str, config_path: Path | None = None) -> bool:
|
|
"""Check if a warning key is suppressed in the config file.
|
|
|
|
Reads the `[warnings].suppress` list from `config.toml` and checks
|
|
whether `key` is present.
|
|
|
|
Args:
|
|
key: Warning identifier to check (e.g., `'ripgrep'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
`True` if the warning is suppressed, `False` otherwise (including
|
|
when the file is missing, unreadable, or has no
|
|
`[warnings]` section).
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
if not config_path.exists():
|
|
return False
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.debug(
|
|
"Could not read config file %s for warning suppression check",
|
|
config_path,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
suppress_list = data.get("warnings", {}).get("suppress", [])
|
|
if not isinstance(suppress_list, list):
|
|
logger.debug(
|
|
"[warnings].suppress in %s should be a list, got %s",
|
|
config_path,
|
|
type(suppress_list).__name__,
|
|
)
|
|
return False
|
|
return key in suppress_list
|
|
|
|
|
|
def suppress_warning(key: str, config_path: Path | None = None) -> bool:
|
|
"""Add a warning key to the suppression list in the config file.
|
|
|
|
Reads existing config (if any), adds `key` to `[warnings].suppress`,
|
|
and writes back using atomic temp-file rename. Deduplicates entries.
|
|
|
|
Args:
|
|
key: Warning identifier to suppress (e.g., `'ripgrep'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
`True` if save succeeded, `False` if it failed due to I/O errors.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
if "warnings" not in data:
|
|
data["warnings"] = {}
|
|
suppress_list = data["warnings"].get("suppress", [])
|
|
if not isinstance(suppress_list, list):
|
|
logger.debug(
|
|
"[warnings].suppress in %s should be a list, got %s",
|
|
config_path,
|
|
type(suppress_list).__name__,
|
|
)
|
|
suppress_list = []
|
|
if key not in suppress_list:
|
|
suppress_list.append(key)
|
|
data["warnings"]["suppress"] = suppress_list
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception("Could not save warning suppression for '%s'", key)
|
|
return False
|
|
return True
|
|
|
|
|
|
def unsuppress_warning(key: str, config_path: Path | None = None) -> bool:
|
|
"""Remove a warning key from the suppression list in the config file.
|
|
|
|
Reads existing config (if any), removes `key` from `[warnings].suppress`,
|
|
and writes back using atomic temp-file rename. No-op if the key is not
|
|
present or the file does not exist.
|
|
|
|
Args:
|
|
key: Warning identifier to unsuppress (e.g., `'ripgrep'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
`True` if save succeeded, `False` if it failed due to I/O errors.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
if not config_path.exists():
|
|
return True # nothing to remove
|
|
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
|
|
suppress_list = data.get("warnings", {}).get("suppress", [])
|
|
if not isinstance(suppress_list, list):
|
|
logger.debug(
|
|
"[warnings].suppress in %s should be a list, got %s",
|
|
config_path,
|
|
type(suppress_list).__name__,
|
|
)
|
|
return True # treat as nothing to remove
|
|
if key not in suppress_list:
|
|
return True # already unsuppressed
|
|
|
|
suppress_list.remove(key)
|
|
data.setdefault("warnings", {})["suppress"] = suppress_list
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception("Could not remove warning suppression for '%s'", key)
|
|
return False
|
|
return True
|
|
|
|
|
|
@dataclass(frozen=True, order=True)
|
|
class McpProjectServerApproval:
|
|
"""A project-scoped, definition-bound MCP server approval.
|
|
|
|
Membership in a `McpServerTrustLists.approvals` set *is* the trust decision
|
|
(`is_enabled` reconstructs an approval and tests `approval in approvals`), so
|
|
value equality on the three raw strings must line up between the write side
|
|
(`add_enabled_project_mcp_servers`) and the read side (`is_enabled`). Build
|
|
runtime approvals through `create` and persisted ones through `from_toml`,
|
|
never the raw constructor, so every path normalizes the root and computes the
|
|
fingerprint identically. `order=True` exists only so `sorted()` yields
|
|
deterministic TOML output.
|
|
|
|
The raw constructor only enforces non-emptiness (`__post_init__`), not that
|
|
`project_root` is normalized or that `fingerprint` is a real digest. A
|
|
hand-built instance is therefore *safe but useless*: with a mismatched root
|
|
or fingerprint it simply never equals a `create`/`from_toml` peer, so it
|
|
fails closed (nothing is trusted) rather than granting stray access — but it
|
|
also won't authorize anything. Always go through the factories.
|
|
"""
|
|
|
|
project_root: str
|
|
"""Resolved project root that originated the approval."""
|
|
|
|
name: str
|
|
"""MCP server name within the project config."""
|
|
|
|
fingerprint: str
|
|
"""Fingerprint of the approved MCP server definition."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Reject degenerate approvals so a bad one can't silently never match.
|
|
|
|
An empty `project_root`, `name`, or `fingerprint` can only ever equal a
|
|
malformed peer, so forbid the state entirely rather than let it persist.
|
|
|
|
Raises:
|
|
ValueError: If any field is empty or whitespace-only.
|
|
"""
|
|
if not (
|
|
self.project_root.strip() and self.name.strip() and self.fingerprint.strip()
|
|
):
|
|
msg = (
|
|
"McpProjectServerApproval requires non-empty project_root, name, "
|
|
"and fingerprint"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls, *, project_root: str | Path | None, name: str, server: JsonValue
|
|
) -> McpProjectServerApproval | None:
|
|
"""Build an approval, normalizing the root and fingerprinting `server`.
|
|
|
|
The single construction path for runtime approvals, so the write and read
|
|
sides cannot drift on how the root is normalized or the server hashed.
|
|
|
|
Args:
|
|
project_root: Project root to normalize.
|
|
name: MCP server name.
|
|
server: Parsed MCP server definition to fingerprint.
|
|
|
|
Returns:
|
|
The approval, or `None` when `project_root` cannot be normalized.
|
|
"""
|
|
normalized_root = normalize_mcp_project_root(project_root)
|
|
if normalized_root is None:
|
|
return None
|
|
return cls(
|
|
project_root=normalized_root,
|
|
# Strip so the read side matches the write side and `from_toml`,
|
|
# both of which persist/compare a stripped name — otherwise a
|
|
# whitespace-padded server key would never match its own approval.
|
|
name=name.strip(),
|
|
fingerprint=fingerprint_mcp_server_config(server),
|
|
)
|
|
|
|
@classmethod
|
|
def from_toml(cls, item: Mapping[str, object]) -> McpProjectServerApproval | None:
|
|
"""Deserialize a persisted approval table, normalizing the root.
|
|
|
|
The read-side counterpart to `as_toml`. Applies the *same* root
|
|
normalization as `create` so a persisted approval and a freshly built
|
|
runtime one compare equal for the same project and definition — the
|
|
write/read symmetry the trust decision depends on lives here, not in the
|
|
caller.
|
|
|
|
Args:
|
|
item: A parsed TOML table with `project_root`, `name`, and
|
|
`fingerprint` string fields.
|
|
|
|
Returns:
|
|
The approval, or `None` for a malformed table (missing, blank, or
|
|
non-string field) — fail-closed for an allowlist.
|
|
"""
|
|
project_root = item.get("project_root")
|
|
name = item.get("name")
|
|
fingerprint = item.get("fingerprint")
|
|
if not (
|
|
isinstance(project_root, str)
|
|
and project_root.strip()
|
|
and isinstance(name, str)
|
|
and name.strip()
|
|
and isinstance(fingerprint, str)
|
|
and fingerprint.strip()
|
|
):
|
|
return None
|
|
normalized_root = normalize_mcp_project_root(project_root)
|
|
if normalized_root is None:
|
|
return None
|
|
return cls(
|
|
project_root=normalized_root,
|
|
name=name.strip(),
|
|
fingerprint=fingerprint.strip(),
|
|
)
|
|
|
|
def as_toml(self) -> dict[str, str]:
|
|
"""Return a TOML-serializable representation."""
|
|
return {
|
|
"project_root": self.project_root,
|
|
"name": self.name,
|
|
"fingerprint": self.fingerprint,
|
|
}
|
|
|
|
|
|
def normalize_mcp_project_root(project_root: str | Path | None) -> str | None:
|
|
"""Normalize a project root for persisted MCP trust comparisons.
|
|
|
|
Args:
|
|
project_root: Project root path to normalize.
|
|
|
|
Returns:
|
|
Resolved absolute project root string, or `None` when `project_root`
|
|
is `None`, cannot be expanded, or resolution detects a path loop.
|
|
On an `OSError` from `resolve()`, returns the expanded but
|
|
*unresolved* path as a fallback; a transient failure on only one of
|
|
the write/read sides then yields different strings and a spurious
|
|
re-prompt (fail-closed), never a false match.
|
|
"""
|
|
if project_root is None:
|
|
return None
|
|
try:
|
|
expanded_root = Path(project_root).expanduser()
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Could not expand MCP project root %s",
|
|
project_root,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
try:
|
|
return str(expanded_root.resolve())
|
|
except OSError:
|
|
logger.warning(
|
|
"Could not resolve MCP project root %s",
|
|
project_root,
|
|
exc_info=True,
|
|
)
|
|
return str(expanded_root)
|
|
except RuntimeError:
|
|
logger.warning(
|
|
"Could not resolve MCP project root %s",
|
|
project_root,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
def fingerprint_mcp_server_config(server: JsonValue) -> str:
|
|
"""Return a stable fingerprint for an MCP server definition.
|
|
|
|
The contract is a JSON-serializable value (in practice the `dict` parsed
|
|
from `.mcp.json`, though a malformed entry may be any JSON scalar/array); a
|
|
non-serializable input raises `TypeError` from `json.dumps`. `sort_keys=True`
|
|
makes the digest independent of key order, so reordering fields in the config
|
|
does not force a re-prompt.
|
|
|
|
Args:
|
|
server: Parsed MCP server config (a JSON-serializable value).
|
|
|
|
Returns:
|
|
A SHA-256 fingerprint over the canonical JSON representation.
|
|
"""
|
|
encoded = json.dumps(
|
|
server,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class McpServerTrustLists:
|
|
"""User-level allow/deny lists for project MCP servers.
|
|
|
|
Sourced only from the user's own configuration — the home `config.toml`, the
|
|
global `~/.deepagents/.env`, and shell-exported env — never from a repo, so a
|
|
committed `.mcp.json` cannot self-approve. Persisted approvals are scoped to
|
|
a resolved project root and the approved server definition's fingerprint.
|
|
Env-sourced approvals remain explicit process-wide name approvals.
|
|
|
|
The "reject wins" invariant — a name in both approval and rejection data is
|
|
only rejected — is enforced in `__post_init__`, so every instance is disjoint
|
|
no matter how it was constructed; callers need not pre-subtract.
|
|
"""
|
|
|
|
enabled: frozenset[str]
|
|
"""Env-sourced server names pre-approved for any project config."""
|
|
|
|
disabled: frozenset[str]
|
|
"""Server names always rejected; reject wins over approvals and over trust."""
|
|
|
|
read_error: str | None = field(default=None, compare=False)
|
|
"""Non-`None` when the user's `config.toml` existed but its trust policy
|
|
could not be fully read: the file was unreadable/unparseable, its `[mcp]`
|
|
value was not a table, or its `disabled_project_servers` was a wrong type
|
|
that could not be interpreted as a deny list. Callers must treat this as
|
|
fail-closed (do not grant whole-config project trust) and surface it, rather
|
|
than proceeding with a deny list that may not have loaded — use `load_failed`
|
|
for that check. Note the resolved `enabled`/`disabled` sets are not
|
|
necessarily empty here: names from a still-readable source (the env vars)
|
|
continue to apply. Excluded from equality so a failed load still compares
|
|
equal to empty lists for tests that only care about the resolved names."""
|
|
|
|
approvals: frozenset[McpProjectServerApproval] = field(
|
|
default_factory=frozenset, kw_only=True
|
|
)
|
|
"""Project-scoped approvals loaded from user `config.toml`."""
|
|
|
|
legacy_ignored: frozenset[str] = field(
|
|
default_factory=frozenset, compare=False, kw_only=True
|
|
)
|
|
"""Names found in a legacy `[mcp].enabled_project_servers` list that this
|
|
build no longer honors. Non-empty means the user relied on the removed flat
|
|
allowlist, so those servers silently stopped loading; callers should surface
|
|
it (a bare `logger.warning` is invisible outside debug mode) so
|
|
non-interactive paths can explain the change. Diagnostic, not resolved
|
|
policy — excluded from equality like `read_error`."""
|
|
|
|
legacy_env_ignored: bool = field(default=False, compare=False, kw_only=True)
|
|
"""`True` when the removed `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` env
|
|
var is set. It was renamed to the `DANGEROUSLY_`-prefixed var and is no longer
|
|
read, so its names silently stopped pre-approving. The diagnostic twin of
|
|
`legacy_ignored` for the env surface; callers should surface the rename so the
|
|
change is not silent. Excluded from equality like `read_error`."""
|
|
|
|
malformed_approvals: int = field(default=0, compare=False, kw_only=True)
|
|
"""Count of `[mcp].enabled_project_server_approvals` rows that were dropped as
|
|
malformed (wrong-typed key, non-table entry, or a table missing/blank
|
|
`project_root`/`name`/`fingerprint`). Non-zero means a persisted approval
|
|
could not be read, so its server silently re-prompts; callers should surface
|
|
it (a bare `logger.warning` is invisible outside debug mode) for parity with
|
|
`legacy_ignored`. Diagnostic, not resolved policy — excluded from equality."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Enforce reject precedence by stripping disabled names from both sets.
|
|
|
|
A rejected name must never survive in `enabled` or `approvals`, whatever
|
|
the caller passed, so a future allow-first consumer can't be tricked
|
|
into loading a denied server. Frozen dataclass, so assign via
|
|
`object.__setattr__`.
|
|
"""
|
|
if self.enabled & self.disabled:
|
|
object.__setattr__(self, "enabled", self.enabled - self.disabled)
|
|
if any(approval.name in self.disabled for approval in self.approvals):
|
|
object.__setattr__(
|
|
self,
|
|
"approvals",
|
|
frozenset(
|
|
approval
|
|
for approval in self.approvals
|
|
if approval.name not in self.disabled
|
|
),
|
|
)
|
|
|
|
@property
|
|
def load_failed(self) -> bool:
|
|
"""Whether the user's trust policy failed to load (see `read_error`).
|
|
|
|
Callers gating on trust MUST check this and fail closed: a failed load
|
|
means a configured deny may be missing, so whole-config project trust
|
|
must not be honored. Named so the fail-closed contract is discoverable
|
|
rather than resting on every caller remembering the `read_error`
|
|
sentinel.
|
|
"""
|
|
return self.read_error is not None
|
|
|
|
def is_enabled(
|
|
self,
|
|
name: str,
|
|
*,
|
|
project_root: str | Path | None,
|
|
server: JsonValue,
|
|
) -> bool:
|
|
"""Return whether `server` is approved by name or scoped fingerprint.
|
|
|
|
Args:
|
|
name: MCP server name.
|
|
project_root: Resolved project root for the config that defined it.
|
|
server: Parsed MCP server config for fingerprint comparison.
|
|
|
|
Returns:
|
|
`True` when the server is approved and not disabled.
|
|
"""
|
|
if not name.strip():
|
|
# A blank name can only come from a malformed config. Fail closed
|
|
# here rather than let `McpProjectServerApproval.create` raise
|
|
# `ValueError` from its non-empty invariant out of the trust filter.
|
|
return False
|
|
# These membership tests use the raw (unstripped) `name`, while the
|
|
# approval path below strips it via `create`. Reject precedence for a
|
|
# whitespace-padded name (e.g. `" docs "` vs `disabled={"docs"}`) does
|
|
# NOT rest on this check — it survives only because `__post_init__`
|
|
# already stripped every disabled name out of `enabled` and `approvals`
|
|
# (it compares the always-stripped `approval.name`). Keep that stripping
|
|
# in sync with this check: a padded name sails past both lines here.
|
|
if name in self.disabled:
|
|
return False
|
|
if name in self.enabled:
|
|
return True
|
|
approval = McpProjectServerApproval.create(
|
|
project_root=project_root, name=name, server=server
|
|
)
|
|
if approval is None:
|
|
return False
|
|
return approval in self.approvals
|
|
|
|
|
|
def _parse_csv_env(name: str) -> list[str] | None:
|
|
"""Parse a comma-separated env var into a list of trimmed, non-empty names.
|
|
|
|
Returns:
|
|
The parsed list when the variable is set (possibly empty after
|
|
trimming), or `None` when the variable is unset so callers can
|
|
distinguish "unset, fall back to TOML" from "set but empty".
|
|
"""
|
|
raw = os.environ.get(name)
|
|
if raw is None:
|
|
return None
|
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
|
|
|
|
def _toml_str_list(
|
|
value: object, *, key: str, config_path: Path
|
|
) -> tuple[list[str], bool]:
|
|
"""Coerce a raw TOML value into a list of trimmed, non-empty server names.
|
|
|
|
A bare string is *split on commas* (e.g. `disabled_project_servers = "a, b"`
|
|
yields `["a", "b"]`), so a scalar written in the TOML parses identically to
|
|
the comma-separated env form in `_parse_csv_env` — the two forms can never
|
|
silently diverge into one bogus `"a, b"` token that matches no server. Non-
|
|
string list elements are dropped (with a log) while the surrounding valid
|
|
names survive. A genuinely wrong type (number, table, bool) cannot be
|
|
interpreted as names at all: it yields an empty list *and* flags `malformed`,
|
|
so a caller enforcing a deny list can fail closed rather than silently drop
|
|
the rejection.
|
|
|
|
Args:
|
|
value: The raw value read from the `[mcp]` table (or `None` when the
|
|
key is absent).
|
|
key: The TOML key name, used only for log context.
|
|
config_path: The config file the value came from, for log context.
|
|
|
|
Returns:
|
|
`(names, malformed)`. `names` are the trimmed, non-empty server names.
|
|
`malformed` is `True` only when `value` is present but neither a
|
|
string nor a list (so it could not be read as names); it is `False`
|
|
for an absent value, a string, or any list — even one whose non-
|
|
string elements were dropped.
|
|
"""
|
|
if value is None:
|
|
return [], False
|
|
if isinstance(value, str):
|
|
# Split on commas so a bare string parses exactly like the env form; a
|
|
# single name with no comma still yields a one-element list.
|
|
return [item.strip() for item in value.split(",") if item.strip()], False
|
|
if not isinstance(value, list):
|
|
logger.warning(
|
|
"[mcp].%s in %s should be a list of strings, got %s; ignoring it",
|
|
key,
|
|
config_path,
|
|
type(value).__name__,
|
|
)
|
|
return [], True
|
|
result: list[str] = []
|
|
discarded = 0
|
|
for item in value:
|
|
if isinstance(item, str) and item.strip():
|
|
result.append(item.strip())
|
|
else:
|
|
discarded += 1
|
|
if discarded:
|
|
logger.warning(
|
|
"[mcp].%s in %s: ignored %d non-string or empty entr%s",
|
|
key,
|
|
config_path,
|
|
discarded,
|
|
"y" if discarded == 1 else "ies",
|
|
)
|
|
return result, False
|
|
|
|
|
|
def _toml_project_server_approvals(
|
|
value: object, *, config_path: Path
|
|
) -> tuple[list[McpProjectServerApproval], int]:
|
|
"""Parse `[mcp].enabled_project_server_approvals` entries.
|
|
|
|
Args:
|
|
value: Raw TOML value from the `[mcp]` table.
|
|
config_path: Config file the value came from, for log context.
|
|
|
|
Returns:
|
|
`(approvals, dropped)`: the well-formed project-scoped approvals and the
|
|
count of malformed rows ignored. Dropping is fail-closed for an
|
|
allowlist; the count lets callers surface the loss (a bare
|
|
`logger.warning` is invisible outside debug mode) so a corrupt saved
|
|
approval doesn't just silently re-prompt.
|
|
"""
|
|
if value is None:
|
|
return [], 0
|
|
if not isinstance(value, list):
|
|
logger.warning(
|
|
"[mcp].enabled_project_server_approvals in %s should be a list of "
|
|
"tables; ignoring it",
|
|
config_path,
|
|
)
|
|
# Count the whole-key type error as one dropped diagnostic so it is
|
|
# surfaced rather than only logged.
|
|
return [], 1
|
|
|
|
approvals: list[McpProjectServerApproval] = []
|
|
dropped = 0
|
|
for item in value:
|
|
if not isinstance(item, dict):
|
|
logger.warning(
|
|
"[mcp].enabled_project_server_approvals in %s ignored a "
|
|
"non-table entry",
|
|
config_path,
|
|
)
|
|
dropped += 1
|
|
continue
|
|
approval = McpProjectServerApproval.from_toml(
|
|
cast("Mapping[str, object]", item)
|
|
)
|
|
if approval is None:
|
|
logger.warning(
|
|
"[mcp].enabled_project_server_approvals in %s ignored an "
|
|
"entry missing project_root, name, or fingerprint",
|
|
config_path,
|
|
)
|
|
dropped += 1
|
|
continue
|
|
approvals.append(approval)
|
|
return approvals, dropped
|
|
|
|
|
|
def load_mcp_server_trust_lists(
|
|
config_path: Path | None = None,
|
|
) -> McpServerTrustLists:
|
|
"""Load per-server project MCP allow/deny lists from user-level config.
|
|
|
|
Security boundary: this reads the `[mcp]` table only from the user-level
|
|
`config.toml` (`DEFAULT_CONFIG_PATH`, i.e. `~/.deepagents/config.toml`) and
|
|
the `DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` /
|
|
`DEEPAGENTS_CODE_DISABLED_PROJECT_MCP_SERVERS` process env vars — never from
|
|
a project's `.mcp.json` or any repo-committed file. There is no
|
|
project-level `config.toml` discovery, so an attacker who commits a
|
|
malicious `.mcp.json` plus an in-repo config cannot pre-approve their own
|
|
servers; the approval must live in the user's home config. This mirrors
|
|
Claude Code's "untrusted folder → only non-checked-in settings" rule.
|
|
|
|
Source resolution differs by list, matching each one's security direction:
|
|
|
|
- `enabled` (permissive): the env var is an explicit process-wide name
|
|
allowlist.
|
|
- `approvals` (permissive): TOML approvals are scoped to project root and a
|
|
server-definition fingerprint. They remain active alongside env-enabled
|
|
names, so setting the process-wide escape hatch does not discard choices
|
|
remembered by the interactive prompt. Legacy flat TOML
|
|
`enabled_project_servers` entries are ignored because they cannot be safely
|
|
scoped.
|
|
- `disabled` (restrictive): the env var *unions* with the TOML list — denies
|
|
accumulate and a lower-effort source can never silently empty a deny
|
|
entry set in the other, which would be a fail-open. There is
|
|
deliberately no way to *remove* a configured deny via env.
|
|
|
|
Rejection wins: a name appearing in approval and disabled data is reported
|
|
only in `disabled`.
|
|
|
|
Args:
|
|
config_path: Config file to read. Defaults to `DEFAULT_CONFIG_PATH`;
|
|
callers should not point this at a project path — doing so would
|
|
defeat the boundary above.
|
|
|
|
Returns:
|
|
The resolved `McpServerTrustLists`. A missing file yields empty lists
|
|
(the normal "unset" case). `read_error` is set (so callers can fail
|
|
closed instead of treating a broken config as "nothing denied") when
|
|
the file exists but cannot be read/parsed, when `[mcp]` is not a
|
|
table, or when `disabled_project_servers` is a wrong type that cannot
|
|
be read as a deny list; env-sourced names still apply in that case.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
toml_approvals: list[McpProjectServerApproval] = []
|
|
malformed_approvals = 0
|
|
toml_disabled: list[str] = []
|
|
legacy_ignored: list[str] = []
|
|
read_error: str | None = None
|
|
try:
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
mcp_section = data.get("mcp", {})
|
|
if isinstance(mcp_section, dict):
|
|
toml_approvals, malformed_approvals = _toml_project_server_approvals(
|
|
mcp_section.get("enabled_project_server_approvals"),
|
|
config_path=config_path,
|
|
)
|
|
legacy_enabled, _ = _toml_str_list(
|
|
mcp_section.get("enabled_project_servers"),
|
|
key="enabled_project_servers",
|
|
config_path=config_path,
|
|
)
|
|
if legacy_enabled:
|
|
legacy_ignored = legacy_enabled
|
|
logger.warning(
|
|
"[mcp].enabled_project_servers in %s is ignored; run "
|
|
"the project MCP approval prompt again to save "
|
|
"project-scoped approvals",
|
|
config_path,
|
|
)
|
|
toml_disabled, disabled_malformed = _toml_str_list(
|
|
mcp_section.get("disabled_project_servers"),
|
|
key="disabled_project_servers",
|
|
config_path=config_path,
|
|
)
|
|
if disabled_malformed:
|
|
# A wrong-typed deny list cannot be read, so proceeding as
|
|
# if nothing were denied would be a fail-open. Surface it and
|
|
# fail closed, mirroring the unreadable-file path below.
|
|
read_error = (
|
|
f"[mcp].disabled_project_servers in {config_path} must be "
|
|
"a list of strings; refusing to proceed with an "
|
|
"unenforced deny list"
|
|
)
|
|
else:
|
|
# An `[mcp]` value that is not a table means the deny list is
|
|
# unreadable too; fail closed rather than leave it unenforced.
|
|
read_error = (
|
|
f"[mcp] in {config_path} must be a table, got "
|
|
f"{type(mcp_section).__name__}"
|
|
)
|
|
logger.warning(
|
|
"[mcp] in %s should be a table, got %s; treating project "
|
|
"configs as untrusted",
|
|
config_path,
|
|
type(mcp_section).__name__,
|
|
)
|
|
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
# The file exists but is unreadable/unparseable. Record it so callers
|
|
# fail closed rather than silently proceeding with an empty deny list.
|
|
read_error = f"Could not read MCP trust lists from {config_path}: {exc}"
|
|
logger.warning(
|
|
"Could not read %s for MCP server trust lists; treating project "
|
|
"configs as untrusted",
|
|
config_path,
|
|
exc_info=True,
|
|
)
|
|
|
|
env_enabled = _parse_csv_env(_env_vars.DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS)
|
|
env_disabled = _parse_csv_env(_env_vars.DISABLED_PROJECT_MCP_SERVERS)
|
|
# The old name was renamed to the `DANGEROUSLY_`-prefixed var and is no
|
|
# longer read; flag it set-but-ignored so callers can explain the rename
|
|
# instead of the names silently ceasing to pre-approve.
|
|
legacy_env_ignored = _env_vars.LEGACY_ENABLED_PROJECT_MCP_SERVERS in os.environ
|
|
if legacy_env_ignored:
|
|
logger.warning(
|
|
"%s is no longer used; it was renamed to %s",
|
|
_env_vars.LEGACY_ENABLED_PROJECT_MCP_SERVERS,
|
|
_env_vars.DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS,
|
|
)
|
|
|
|
# Process-wide env names and scoped TOML approvals are independent grants.
|
|
# Keep both active so the escape hatch cannot make the interactive prompt's
|
|
# successfully persisted choices ineffective on the next launch.
|
|
enabled = frozenset(env_enabled or ())
|
|
approvals = frozenset(() if read_error is not None else toml_approvals)
|
|
disabled = frozenset(toml_disabled) | frozenset(env_disabled or ())
|
|
# Corner: when `read_error` is set because `config.toml` was unreadable,
|
|
# `toml_disabled` is lost, so a name that is both TOML-`disabled` *and*
|
|
# exported in `DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` would survive here —
|
|
# "reject wins" does not hold in that one corner. It requires a
|
|
# self-contradicting config plus the explicit `DANGEROUSLY_` opt-in, and the
|
|
# read error is surfaced to the user, so it stays an accepted footgun rather
|
|
# than a silent fail-open.
|
|
# Reject precedence is enforced by `McpServerTrustLists.__post_init__`, so no
|
|
# subtraction here.
|
|
return McpServerTrustLists(
|
|
enabled=enabled,
|
|
disabled=disabled,
|
|
approvals=approvals,
|
|
read_error=read_error,
|
|
legacy_ignored=frozenset(legacy_ignored),
|
|
legacy_env_ignored=legacy_env_ignored,
|
|
malformed_approvals=malformed_approvals,
|
|
)
|
|
|
|
|
|
def add_enabled_project_mcp_servers(
|
|
names: Iterable[str],
|
|
config_path: Path | None = None,
|
|
*,
|
|
project_root: str | Path | None = None,
|
|
server_configs: Mapping[str, JsonValue] | None = None,
|
|
) -> bool:
|
|
"""Persist project-scoped MCP server approvals.
|
|
|
|
Backs the interactive approval prompt's "always allow" choice: the given
|
|
names are added to the user-level `config.toml` allowlist with the current
|
|
project root and each server definition's fingerprint, so a different repo
|
|
or a changed command/URL under the same name asks for approval again.
|
|
|
|
Defaults to the user-level config (`DEFAULT_CONFIG_PATH`), the sole source
|
|
`load_mcp_server_trust_lists` reads the allowlist from — so writing to the
|
|
user's home config is what preserves the read-side trust boundary (a
|
|
committed `.mcp.json` can never self-approve). Any name being persisted is
|
|
also pruned from the deprecated flat `[mcp].enabled_project_servers` key
|
|
(the key is removed once empty), migrating callers off the ignored legacy
|
|
list. The write is atomic (`tempfile.mkstemp` + `Path.replace`) and holds
|
|
`_config_write_lock` across the whole read-modify-write, matching
|
|
`suppress_warning`.
|
|
|
|
Args:
|
|
names: Server names to add to the allowlist. Blank/whitespace-only
|
|
names are ignored; a call with no usable names is a no-op success.
|
|
config_path: Config file to write. Defaults to `DEFAULT_CONFIG_PATH`
|
|
(`~/.deepagents/config.toml`). Callers should not point this at a
|
|
project path: the loader only ever reads the user-level config, so
|
|
an allowlist written elsewhere is never honored.
|
|
project_root: Project root whose MCP server definitions were approved.
|
|
server_configs: Current server definitions keyed by server name.
|
|
|
|
Returns:
|
|
`True` if the save succeeded (or there was nothing to add), `False` on
|
|
I/O, parse failure, an unknown server name, or missing
|
|
project/server context.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
clean_names = [name.strip() for name in names if name and name.strip()]
|
|
if not clean_names:
|
|
return True
|
|
|
|
normalized_root = normalize_mcp_project_root(project_root)
|
|
if normalized_root is None or server_configs is None:
|
|
logger.error(
|
|
"Cannot save enabled project MCP servers without project root and "
|
|
"server definitions"
|
|
)
|
|
return False
|
|
|
|
approvals_to_add: list[McpProjectServerApproval] = []
|
|
for name in clean_names:
|
|
if name not in server_configs:
|
|
logger.error("Cannot save unknown project MCP server %r", name)
|
|
return False
|
|
approval = McpProjectServerApproval.create(
|
|
project_root=normalized_root, name=name, server=server_configs[name]
|
|
)
|
|
if approval is None:
|
|
# normalized_root is already non-None here, so create cannot fail;
|
|
# guard anyway so a future change can't smuggle in a bad approval.
|
|
logger.error("Could not build approval for project MCP server %r", name)
|
|
return False
|
|
approvals_to_add.append(approval)
|
|
|
|
try:
|
|
# Hold the shared lock across read-through-replace: the atomic rename
|
|
# alone only prevents torn writes, not the lost update where a
|
|
# concurrent config.toml writer reads the same snapshot and its
|
|
# `replace()` lands last, silently dropping this approval. See the
|
|
# `_config_write_lock` contract; `suppress_warning` guards the same way.
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
mcp_section = data.get("mcp")
|
|
if not isinstance(mcp_section, dict):
|
|
mcp_section = {}
|
|
existing, _ = _toml_project_server_approvals(
|
|
mcp_section.get("enabled_project_server_approvals"),
|
|
config_path=config_path,
|
|
)
|
|
merged = set(existing) | set(approvals_to_add)
|
|
mcp_section["enabled_project_server_approvals"] = [
|
|
approval.as_toml() for approval in sorted(merged)
|
|
]
|
|
legacy, legacy_malformed = _toml_str_list(
|
|
mcp_section.get("enabled_project_servers"),
|
|
key="enabled_project_servers",
|
|
config_path=config_path,
|
|
)
|
|
if legacy and not legacy_malformed:
|
|
migrated = set(clean_names)
|
|
remaining = [name for name in legacy if name not in migrated]
|
|
if remaining:
|
|
mcp_section["enabled_project_servers"] = remaining
|
|
else:
|
|
mcp_section.pop("enabled_project_servers", None)
|
|
data["mcp"] = mcp_section
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
# Matches `suppress_warning`: `TypeError` covers `tomli_w.dump`
|
|
# rejecting a non-serializable payload; `ValueError` covers things like
|
|
# `os.fdopen` on a closed fd. Folding them in keeps the `bool` contract
|
|
# intact so the caller degrades to a "could not remember" warning
|
|
# instead of crashing with a raw traceback.
|
|
logger.exception(
|
|
"Could not save enabled project MCP servers to %s", config_path
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
THREAD_COLUMN_DEFAULTS: dict[str, bool] = {
|
|
"thread_id": False,
|
|
"messages": True,
|
|
"created_at": True,
|
|
"updated_at": True,
|
|
"git_branch": False,
|
|
"cwd": False,
|
|
"initial_prompt": True,
|
|
"agent_name": False,
|
|
}
|
|
"""Default visibility for thread selector columns."""
|
|
|
|
|
|
class ThreadConfig(NamedTuple):
|
|
"""Coalesced thread-selector configuration read from a single TOML parse."""
|
|
|
|
columns: dict[str, bool]
|
|
"""Column visibility settings."""
|
|
|
|
relative_time: bool
|
|
"""Whether to display timestamps as relative time."""
|
|
|
|
sort_order: str
|
|
"""`'updated_at'` or `'created_at'`."""
|
|
|
|
scope: str
|
|
"""`'cwd'` (current working directory) or `'all'` (all directories)."""
|
|
|
|
|
|
_thread_config_cache: ThreadConfig | None = None
|
|
|
|
|
|
def load_thread_config(config_path: Path | None = None) -> ThreadConfig:
|
|
"""Load all thread-selector settings from one config file read.
|
|
|
|
Returns a cached result when reading the default config path. The
|
|
prewarm worker calls this at startup so subsequent opens of the
|
|
`/threads` modal avoid disk I/O entirely.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
Coalesced thread configuration.
|
|
"""
|
|
global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
|
|
if config_path is None:
|
|
if _thread_config_cache is not None:
|
|
return _thread_config_cache
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
use_default = config_path == DEFAULT_CONFIG_PATH
|
|
|
|
columns = dict(THREAD_COLUMN_DEFAULTS)
|
|
relative_time = True
|
|
sort_order = "updated_at"
|
|
scope = "cwd"
|
|
|
|
try:
|
|
if not config_path.exists():
|
|
result = ThreadConfig(columns, relative_time, sort_order, scope)
|
|
if use_default:
|
|
_thread_config_cache = result
|
|
return result
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
threads_section = data.get("threads", {})
|
|
|
|
# columns
|
|
raw_columns = threads_section.get("columns", {})
|
|
if isinstance(raw_columns, dict):
|
|
for key in columns:
|
|
if key in raw_columns and isinstance(raw_columns[key], bool):
|
|
columns[key] = raw_columns[key]
|
|
|
|
# relative_time
|
|
rt_value = threads_section.get("relative_time")
|
|
if isinstance(rt_value, bool):
|
|
relative_time = rt_value
|
|
|
|
# sort_order
|
|
so_value = threads_section.get("sort_order")
|
|
if so_value in {"updated_at", "created_at"}:
|
|
sort_order = so_value
|
|
|
|
# scope
|
|
scope_value = threads_section.get("scope")
|
|
if scope_value in {"cwd", "all"}:
|
|
scope = scope_value
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.warning("Could not read thread config; using defaults", exc_info=True)
|
|
# Do not cache on error — allow retry on next call in case the
|
|
# file is fixed or permissions are restored.
|
|
return ThreadConfig(columns, relative_time, sort_order, scope)
|
|
|
|
result = ThreadConfig(columns, relative_time, sort_order, scope)
|
|
if use_default:
|
|
_thread_config_cache = result
|
|
return result
|
|
|
|
|
|
def invalidate_thread_config_cache() -> None:
|
|
"""Clear the cached `ThreadConfig` so the next load re-reads disk."""
|
|
global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
_thread_config_cache = None
|
|
|
|
|
|
def load_thread_columns(config_path: Path | None = None) -> dict[str, bool]:
|
|
"""Load thread column visibility from config file.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
Dict mapping column names to visibility booleans.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
result = dict(THREAD_COLUMN_DEFAULTS)
|
|
try:
|
|
if not config_path.exists():
|
|
return result
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
columns = data.get("threads", {}).get("columns", {})
|
|
if isinstance(columns, dict):
|
|
for key in result:
|
|
if key in columns and isinstance(columns[key], bool):
|
|
result[key] = columns[key]
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.debug("Could not read thread column config", exc_info=True)
|
|
return result
|
|
|
|
|
|
def save_thread_columns(
|
|
columns: dict[str, bool], config_path: Path | None = None
|
|
) -> bool:
|
|
"""Save thread column visibility to config file.
|
|
|
|
Args:
|
|
columns: Dict mapping column names to visibility booleans.
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
True if save succeeded, False on I/O error.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
if "threads" not in data:
|
|
data["threads"] = {}
|
|
data["threads"]["columns"] = columns
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception("Could not save thread column preferences")
|
|
return False
|
|
invalidate_thread_config_cache()
|
|
return True
|
|
|
|
|
|
def load_thread_relative_time(config_path: Path | None = None) -> bool:
|
|
"""Load the relative-time display preference for thread timestamps.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
True if timestamps should display as relative time.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
if not config_path.exists():
|
|
return True
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
value = data.get("threads", {}).get("relative_time")
|
|
if isinstance(value, bool):
|
|
return value
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.debug("Could not read thread relative_time config", exc_info=True)
|
|
return True
|
|
|
|
|
|
def save_thread_relative_time(enabled: bool, config_path: Path | None = None) -> bool:
|
|
"""Save the relative-time display preference for thread timestamps.
|
|
|
|
Args:
|
|
enabled: Whether to display relative timestamps.
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
True if save succeeded, False on I/O error.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
if "threads" not in data:
|
|
data["threads"] = {}
|
|
data["threads"]["relative_time"] = enabled
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception("Could not save thread relative_time preference")
|
|
return False
|
|
invalidate_thread_config_cache()
|
|
return True
|
|
|
|
|
|
def load_thread_sort_order(config_path: Path | None = None) -> str:
|
|
"""Load the sort order preference for the thread selector.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
`"updated_at"` or `"created_at"`.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
if not config_path.exists():
|
|
return "updated_at"
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
value = data.get("threads", {}).get("sort_order")
|
|
if value in {"updated_at", "created_at"}:
|
|
return value
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.debug("Could not read thread sort_order config", exc_info=True)
|
|
return "updated_at"
|
|
|
|
|
|
STARTUP_MODE_MANUAL = "manual"
|
|
"""Startup approval mode that keeps human-in-the-loop approvals enabled."""
|
|
|
|
STARTUP_MODE_AUTO = "auto"
|
|
"""Startup approval mode that uses classifier-backed action review."""
|
|
|
|
STARTUP_MODE_YOLO = "yolo"
|
|
"""Startup approval mode that executes gated actions without review."""
|
|
|
|
STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto"
|
|
"""Rejected legacy spelling retained only for migration diagnostics."""
|
|
|
|
VALID_STARTUP_MODES = frozenset(
|
|
{STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO, STARTUP_MODE_YOLO}
|
|
)
|
|
"""Accepted values for the `[startup].mode` config option."""
|
|
|
|
DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL
|
|
"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid."""
|
|
|
|
|
|
def load_startup_mode(config_path: Path | None = None) -> str:
|
|
"""Load the default startup approval mode from config.toml.
|
|
|
|
Reads `[startup].mode`, which accepts fail-closed `manual`, classifier-backed
|
|
`auto`, or unrestricted `yolo`. The removed `dangerously-auto` spelling is
|
|
invalid and falls back to `manual`.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
`"manual"`, `"auto"`, or `"yolo"`; falls back to `"manual"` when
|
|
unset, unreadable, or invalid.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
if not config_path.exists():
|
|
return DEFAULT_STARTUP_MODE
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
startup = data.get("startup")
|
|
value = startup.get("mode") if isinstance(startup, dict) else None
|
|
# `value` may be any TOML type; guard against non-strings (e.g. an
|
|
# array or table) before the frozenset membership test, which would
|
|
# otherwise raise `TypeError: unhashable type` and crash startup.
|
|
if isinstance(value, str) and value in VALID_STARTUP_MODES:
|
|
return value
|
|
if value is not None:
|
|
logger.warning(
|
|
"Ignoring [startup].mode=%r (expected 'manual', 'auto', or 'yolo')",
|
|
value,
|
|
)
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.debug("Could not read startup mode config", exc_info=True)
|
|
return DEFAULT_STARTUP_MODE
|
|
|
|
|
|
def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool:
|
|
"""Save the sort order preference for the thread selector.
|
|
|
|
Args:
|
|
sort_order: `"updated_at"` or `"created_at"`.
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
True if save succeeded, False on I/O error.
|
|
|
|
Raises:
|
|
ValueError: If `sort_order` is not a recognised value.
|
|
"""
|
|
if sort_order not in {"updated_at", "created_at"}:
|
|
msg = (
|
|
f"Invalid sort_order {sort_order!r}; expected 'updated_at' or 'created_at'"
|
|
)
|
|
raise ValueError(msg)
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
if "threads" not in data:
|
|
data["threads"] = {}
|
|
data["threads"]["sort_order"] = sort_order
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except Exception:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.exception("Could not save thread sort_order preference")
|
|
return False
|
|
invalidate_thread_config_cache()
|
|
return True
|
|
|
|
|
|
def save_thread_scope(scope: str, config_path: Path | None = None) -> bool:
|
|
"""Save the directory-scope preference for the thread selector.
|
|
|
|
Args:
|
|
scope: `"cwd"` (current working directory) or `"all"` (all directories).
|
|
config_path: Path to config file.
|
|
|
|
Returns:
|
|
True if save succeeded, False on I/O error.
|
|
|
|
Raises:
|
|
ValueError: If `scope` is not a recognised value.
|
|
"""
|
|
if scope not in {"cwd", "all"}:
|
|
msg = f"Invalid scope {scope!r}; expected 'cwd' or 'all'"
|
|
raise ValueError(msg)
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
try:
|
|
with _config_write_lock:
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if config_path.exists():
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
if "threads" not in data:
|
|
data["threads"] = {}
|
|
data["threads"]["scope"] = scope
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
# Clean up temp file on any failure, including interrupts.
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
# `TypeError`/`ValueError` cover `tomli_w.dump` rejecting a payload
|
|
# from a pre-existing config that does not round-trip; folding them in
|
|
# keeps the `bool` contract intact for `_persist_scope`'s failure toast.
|
|
logger.exception("Could not save thread scope preference")
|
|
return False
|
|
invalidate_thread_config_cache()
|
|
return True
|
|
|
|
|
|
def save_recent_model(model_spec: str, config_path: Path | None = None) -> bool:
|
|
"""Update the recently used model in config file.
|
|
|
|
Writes to `[models].recent` instead of `[models].default`, so that `/model`
|
|
switches do not overwrite the user's intentional default.
|
|
|
|
Args:
|
|
model_spec: The model to save in `provider:model` format.
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
|
|
Note:
|
|
This function does not preserve comments in the config file.
|
|
"""
|
|
return _save_model_field("recent", model_spec, config_path)
|
|
|
|
|
|
def _recent_models_path(state_dir: Path | None = None) -> Path:
|
|
"""Resolve the JSON file path for the recent-models MRU cache.
|
|
|
|
Args:
|
|
state_dir: Override for the state directory (test hook).
|
|
|
|
Returns:
|
|
Absolute path to `recent_models.json` under the chosen state dir.
|
|
"""
|
|
return (state_dir or DEFAULT_STATE_DIR) / RECENT_MODELS_FILENAME
|
|
|
|
|
|
def load_recent_models(state_dir: Path | None = None) -> list[str]:
|
|
"""Read the most-recent-first list of `provider:model` specs.
|
|
|
|
Missing or malformed files yield an empty list rather than raising; the
|
|
recent section is a non-essential UI affordance and must not block the
|
|
selector from rendering.
|
|
|
|
Args:
|
|
state_dir: Override for the state directory (test hook).
|
|
|
|
Returns:
|
|
Ordered list of recent `provider:model` specs, most recent first.
|
|
Capped at `RECENT_MODELS_LIMIT` and de-duplicated.
|
|
"""
|
|
path = _recent_models_path(state_dir)
|
|
if not path.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("Could not read recent models cache at %s", path, exc_info=True)
|
|
return []
|
|
raw = data.get("models") if isinstance(data, dict) else None
|
|
if not isinstance(raw, list):
|
|
return []
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for entry in raw:
|
|
if not isinstance(entry, str) or ":" not in entry or entry in seen:
|
|
continue
|
|
seen.add(entry)
|
|
out.append(entry)
|
|
if len(out) >= RECENT_MODELS_LIMIT:
|
|
break
|
|
return out
|
|
|
|
|
|
def touch_recent_model(model_spec: str, state_dir: Path | None = None) -> bool:
|
|
"""Promote `model_spec` to the front of the recent-models MRU list.
|
|
|
|
Existing entries for the same spec are moved (not duplicated); the list
|
|
is capped at `RECENT_MODELS_LIMIT`. Best-effort: returns `False` on I/O
|
|
error so callers can degrade silently — recents are a nice-to-have, not
|
|
a correctness requirement.
|
|
|
|
Args:
|
|
model_spec: The `provider:model` string just selected.
|
|
state_dir: Override for the state directory (test hook).
|
|
|
|
Returns:
|
|
`True` on success, `False` on I/O error or invalid spec.
|
|
"""
|
|
if not model_spec or ":" not in model_spec:
|
|
return False
|
|
existing = load_recent_models(state_dir)
|
|
deduped = [entry for entry in existing if entry != model_spec]
|
|
new_list = [model_spec, *deduped][:RECENT_MODELS_LIMIT]
|
|
path = _recent_models_path(state_dir)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump({"models": new_list}, f)
|
|
Path(tmp_path).replace(path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except OSError:
|
|
logger.warning(
|
|
"Could not update recent models cache at %s", path, exc_info=True
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def save_recent_agent(agent_name: str, config_path: Path | None = None) -> bool:
|
|
"""Update the recently used agent in config file.
|
|
|
|
Writes to `[agents].recent` so a later bare `deepagents` launch (no
|
|
`-a`) can bring the user back to their last agent instead of the
|
|
default.
|
|
|
|
Args:
|
|
agent_name: The agent directory name (e.g., `'coder'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
"""
|
|
return _save_toml_field("agents", "recent", agent_name, config_path)
|
|
|
|
|
|
def load_recent_agent(config_path: Path | None = None) -> str | None:
|
|
"""Read `[agents].recent` from the config file.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
The saved agent name, or `None` if the file or key is missing or
|
|
the file is unreadable.
|
|
"""
|
|
return _load_agents_field("recent", config_path)
|
|
|
|
|
|
def save_default_agent(agent_name: str, config_path: Path | None = None) -> bool:
|
|
"""Update the default agent in config file.
|
|
|
|
Writes to `[agents].default`. This is the user's intentional sticky
|
|
default — set via `Ctrl+S` in the `/agents` picker — and takes
|
|
precedence over `[agents].recent` on bare-launch resolution.
|
|
|
|
Args:
|
|
agent_name: The agent directory name (e.g., `'coder'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if save succeeded, False if it failed due to I/O errors.
|
|
"""
|
|
return _save_toml_field("agents", "default", agent_name, config_path)
|
|
|
|
|
|
def clear_default_agent(config_path: Path | None = None) -> bool:
|
|
"""Remove the default agent from the config file.
|
|
|
|
Deletes the `[agents].default` key so that future launches fall back
|
|
to `[agents].recent` and then `DEFAULT_AGENT_NAME`.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
True if the key was removed (or was already absent), False on I/O error.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
with _config_write_lock:
|
|
if not config_path.exists():
|
|
return True
|
|
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
|
|
agents_section = data.get("agents")
|
|
if not isinstance(agents_section, dict) or "default" not in agents_section:
|
|
return True
|
|
|
|
del agents_section["default"]
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(config_path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
# See `_save_toml_field` for why `TypeError` / `ValueError` are
|
|
# folded into the bool return contract.
|
|
logger.exception("Could not clear default agent preference")
|
|
return False
|
|
else:
|
|
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
_default_config_cache = None
|
|
return True
|
|
|
|
|
|
def load_default_agent(config_path: Path | None = None) -> str | None:
|
|
"""Read `[agents].default` from the config file.
|
|
|
|
Args:
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
The saved agent name, or `None` if the file or key is missing or
|
|
the file is unreadable.
|
|
"""
|
|
return _load_agents_field("default", config_path)
|
|
|
|
|
|
def _load_agents_field(field: str, config_path: Path | None = None) -> str | None:
|
|
"""Read `[agents].<field>` from the config file.
|
|
|
|
Args:
|
|
field: Key under the `[agents]` table (e.g., `'recent'`, `'default'`).
|
|
config_path: Path to config file.
|
|
|
|
Defaults to `~/.deepagents/config.toml`.
|
|
|
|
Returns:
|
|
The trimmed string value, or `None` if the file, section, or key
|
|
is missing or the file is unreadable.
|
|
"""
|
|
if config_path is None:
|
|
config_path = DEFAULT_CONFIG_PATH
|
|
if not config_path.exists():
|
|
return None
|
|
try:
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.warning("Could not read agents.%s from config", field, exc_info=True)
|
|
return None
|
|
agents_section = data.get("agents", {})
|
|
value = agents_section.get(field)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|