zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,4156 @@
|
|
|
1
|
+
"""Model configuration management.
|
|
2
|
+
|
|
3
|
+
Handles loading and saving model configuration from TOML files, providing a
|
|
4
|
+
structured way to define available models and providers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from types import MappingProxyType
|
|
21
|
+
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypedDict, cast
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
import tomli_w
|
|
25
|
+
|
|
26
|
+
from deepagents_code import _env_vars, auth_store
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_ENV_PREFIX = "DEEPAGENTS_CODE_"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolved_env_var_name(canonical: str) -> str:
|
|
37
|
+
"""Return whichever env var name actually carries the resolved value.
|
|
38
|
+
|
|
39
|
+
Mirrors `resolve_env_var`'s precedence: when the prefixed variant is
|
|
40
|
+
present in `os.environ` (even empty), it wins; otherwise the canonical
|
|
41
|
+
name is returned. Useful for UI labels that need to reflect what the
|
|
42
|
+
app is actually reading rather than the canonical name.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
canonical: The canonical environment variable name.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The resolving env var name (prefixed or canonical).
|
|
49
|
+
"""
|
|
50
|
+
if not canonical.startswith(_ENV_PREFIX):
|
|
51
|
+
prefixed = f"{_ENV_PREFIX}{canonical}"
|
|
52
|
+
if prefixed in os.environ:
|
|
53
|
+
return prefixed
|
|
54
|
+
return canonical
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_env_var(name: str) -> str | None:
|
|
58
|
+
"""Look up an env var with `DEEPAGENTS_CODE_` prefix override.
|
|
59
|
+
|
|
60
|
+
Checks `DEEPAGENTS_CODE_{name}` first, then falls back to `{name}`.
|
|
61
|
+
|
|
62
|
+
If the prefixed variable is *present* in the environment (even as an empty
|
|
63
|
+
string), the canonical variable is never consulted. This lets users
|
|
64
|
+
set `DEEPAGENTS_CODE_X=""` to shadow a canonically-set key -- the function
|
|
65
|
+
will return `None` (since empty strings are normalized to `None`),
|
|
66
|
+
effectively suppressing the canonical value.
|
|
67
|
+
|
|
68
|
+
If `name` already carries the prefix, the double-prefixed lookup is skipped
|
|
69
|
+
to avoid nonsensical `DEEPAGENTS_CODE_DEEPAGENTS_CODE_*` reads
|
|
70
|
+
(e.g., when the name comes from a user's `config.toml`).
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: The canonical environment variable name (e.g.
|
|
74
|
+
`ANTHROPIC_API_KEY`).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The resolved value, or `None` when absent or empty.
|
|
78
|
+
"""
|
|
79
|
+
if not name.startswith(_ENV_PREFIX):
|
|
80
|
+
prefixed = f"{_ENV_PREFIX}{name}"
|
|
81
|
+
if prefixed in os.environ:
|
|
82
|
+
val = os.environ[prefixed]
|
|
83
|
+
if not val and os.environ.get(name):
|
|
84
|
+
logger.debug(
|
|
85
|
+
"%s is set but empty, blocking non-empty %s. "
|
|
86
|
+
"Unset %s to use the canonical variable.",
|
|
87
|
+
prefixed,
|
|
88
|
+
name,
|
|
89
|
+
prefixed,
|
|
90
|
+
)
|
|
91
|
+
if val:
|
|
92
|
+
logger.debug("Resolved %s from %s", name, prefixed)
|
|
93
|
+
return val or None
|
|
94
|
+
return os.environ.get(name) or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
PROVIDERS_DOCS_URL = (
|
|
98
|
+
"https://docs.langchain.com/oss/python/deepagents/code/providers#provider-reference"
|
|
99
|
+
)
|
|
100
|
+
"""Public docs page for configuring model providers.
|
|
101
|
+
|
|
102
|
+
Referenced by `UnknownProviderError` and the `/auth` manager so the same
|
|
103
|
+
URL is used everywhere a user is sent to read about provider setup.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ModelConfigError(Exception):
|
|
108
|
+
"""Raised when model configuration or creation fails."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class NoCredentialsConfiguredError(ModelConfigError):
|
|
112
|
+
"""Raised when no credentials are configured for any default-resolvable provider.
|
|
113
|
+
|
|
114
|
+
Distinct from `MissingCredentialsError` (which targets a specific provider
|
|
115
|
+
the user has selected): this fires from `_get_default_model_spec()` when
|
|
116
|
+
auto-detection finds no usable credentials at all. Callers (the deferred-
|
|
117
|
+
start path in the TUI and CLI) `isinstance`-check this type to recover by
|
|
118
|
+
launching the TUI with model creation deferred, rather than string-matching
|
|
119
|
+
the formatted message.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class UnknownProviderError(ModelConfigError):
|
|
124
|
+
"""Raised when neither the app nor `init_chat_model` can infer a provider.
|
|
125
|
+
|
|
126
|
+
Carries the offending model spec as an attribute and exposes
|
|
127
|
+
`PROVIDERS_DOCS_URL` as a class-level constant so callers can render
|
|
128
|
+
a clickable link without string-scanning the formatted message. This
|
|
129
|
+
mirrors how `MissingCredentialsError` exposes `provider` / `env_var`
|
|
130
|
+
for targeted recovery hints.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
docs_url: ClassVar[str] = PROVIDERS_DOCS_URL
|
|
134
|
+
"""Provider-reference docs URL. Class-level so callers don't pass it."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, *, model_spec: str) -> None:
|
|
137
|
+
"""Initialize the error.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
model_spec: The bare model name the user supplied (e.g.
|
|
141
|
+
`'mystery-model'`). When the input had a `provider:model`
|
|
142
|
+
form, parsing succeeds and this exception does not fire.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If `model_spec` is empty.
|
|
146
|
+
"""
|
|
147
|
+
if not model_spec:
|
|
148
|
+
msg = "model_spec must be non-empty"
|
|
149
|
+
raise ValueError(msg)
|
|
150
|
+
message = (
|
|
151
|
+
f"Unable to infer a model provider for {model_spec!r}. "
|
|
152
|
+
f"Specify one explicitly (e.g. 'anthropic:{model_spec}') "
|
|
153
|
+
f"or see the provider reference at {self.docs_url}."
|
|
154
|
+
)
|
|
155
|
+
super().__init__(message)
|
|
156
|
+
self.model_spec = model_spec
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class MissingCredentialsError(ModelConfigError):
|
|
160
|
+
"""Raised when a provider is selected but its API key env var is unset.
|
|
161
|
+
|
|
162
|
+
Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks
|
|
163
|
+
keep working. Carries the `provider` name and the canonical `env_var` so
|
|
164
|
+
callers can render targeted recovery hints (e.g., "set OPENAI_API_KEY" or
|
|
165
|
+
"run `/model <other_provider>:<model>`") without string-matching on the
|
|
166
|
+
formatted exception message and without re-deriving the env-var name.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self, message: str, *, provider: str, env_var: str | None = None
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Initialize the error.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
message: Human-readable message describing the missing credential.
|
|
176
|
+
provider: The provider whose credentials are missing
|
|
177
|
+
(e.g., `'openai'`).
|
|
178
|
+
env_var: The canonical env var name expected to hold the
|
|
179
|
+
credential (e.g., `'OPENAI_API_KEY'`). `None` when the
|
|
180
|
+
provider has no registered env-var mapping.
|
|
181
|
+
"""
|
|
182
|
+
super().__init__(message)
|
|
183
|
+
self.provider = provider
|
|
184
|
+
self.env_var = env_var
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class MissingProviderPackageError(ModelConfigError):
|
|
188
|
+
"""Raised when a provider is selected but its LangChain package is not installed.
|
|
189
|
+
|
|
190
|
+
Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks
|
|
191
|
+
keep working. Carries the `provider` name and the `package` to install so
|
|
192
|
+
callers can render targeted recovery hints (e.g., suggest `/install fireworks`
|
|
193
|
+
or the `/model` slash command) without string-matching on the formatted
|
|
194
|
+
exception message.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, message: str, *, provider: str, package: str) -> None:
|
|
198
|
+
"""Initialize the error.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
message: Human-readable message describing the missing package.
|
|
202
|
+
provider: The provider whose package is missing (e.g., `'fireworks'`).
|
|
203
|
+
package: The pip-installable package name (e.g.,
|
|
204
|
+
`'langchain-fireworks'`).
|
|
205
|
+
"""
|
|
206
|
+
super().__init__(message)
|
|
207
|
+
self.provider = provider
|
|
208
|
+
self.package = package
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ProviderAuthState(StrEnum):
|
|
212
|
+
"""Credential readiness state for a model provider."""
|
|
213
|
+
|
|
214
|
+
CONFIGURED = "configured"
|
|
215
|
+
"""An explicit credential source is configured and non-empty."""
|
|
216
|
+
|
|
217
|
+
MISSING = "missing"
|
|
218
|
+
"""An explicit credential source is required but missing."""
|
|
219
|
+
|
|
220
|
+
NOT_REQUIRED = "not_required"
|
|
221
|
+
"""This provider configuration does not require API-key credentials."""
|
|
222
|
+
|
|
223
|
+
IMPLICIT = "implicit"
|
|
224
|
+
"""The provider supports ambient auth outside CLI env-var checks."""
|
|
225
|
+
|
|
226
|
+
MANAGED = "managed"
|
|
227
|
+
"""A custom provider class is expected to manage auth itself."""
|
|
228
|
+
|
|
229
|
+
UNKNOWN = "unknown"
|
|
230
|
+
"""The app cannot determine whether provider auth is ready."""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ProviderAuthSource(StrEnum):
|
|
234
|
+
"""Origin of a `CONFIGURED` credential, used to discriminate display."""
|
|
235
|
+
|
|
236
|
+
STORED = "stored"
|
|
237
|
+
"""Persisted in a local credential store under `~/.deepagents/.state`.
|
|
238
|
+
|
|
239
|
+
Usually the `/auth` API-key map (`auth.json`), but also covers the
|
|
240
|
+
file-backed ChatGPT OAuth token used by the codex provider
|
|
241
|
+
(`chatgpt-auth.json`).
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
ENV = "env"
|
|
245
|
+
"""Resolved from an environment variable."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class ProviderAuthStatus:
|
|
250
|
+
"""Credential readiness information for a provider.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
state: Provider auth state.
|
|
254
|
+
provider: Provider name.
|
|
255
|
+
env_var: Env var name associated with the state, when applicable.
|
|
256
|
+
source: For `CONFIGURED` states, where the credential value came
|
|
257
|
+
from. `None` for non-configured states or when the source is
|
|
258
|
+
not meaningful (e.g., implicit/managed auth).
|
|
259
|
+
detail: Short user-facing context for selectors and logs.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
state: ProviderAuthState
|
|
263
|
+
provider: str
|
|
264
|
+
env_var: str | None = None
|
|
265
|
+
source: ProviderAuthSource | None = None
|
|
266
|
+
detail: str | None = None
|
|
267
|
+
|
|
268
|
+
def __post_init__(self) -> None:
|
|
269
|
+
"""Enforce the source-vs-state invariant.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
ValueError: If `source` is set but `state` is not `CONFIGURED`,
|
|
273
|
+
or if `state` is `CONFIGURED` but no `source` is recorded.
|
|
274
|
+
"""
|
|
275
|
+
is_configured = self.state is ProviderAuthState.CONFIGURED
|
|
276
|
+
has_source = self.source is not None
|
|
277
|
+
if is_configured != has_source:
|
|
278
|
+
msg = (
|
|
279
|
+
f"ProviderAuthStatus invariant violated: "
|
|
280
|
+
f"state={self.state!r} requires "
|
|
281
|
+
f"{'a source' if is_configured else 'source=None'}, "
|
|
282
|
+
f"got source={self.source!r}"
|
|
283
|
+
)
|
|
284
|
+
raise ValueError(msg)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def blocks_start(self) -> bool:
|
|
288
|
+
"""Whether this status should block model creation or switching."""
|
|
289
|
+
return self.state is ProviderAuthState.MISSING
|
|
290
|
+
|
|
291
|
+
def as_legacy_bool(self) -> bool | None:
|
|
292
|
+
"""Return the historic `has_provider_credentials` tri-state value."""
|
|
293
|
+
if self.state is ProviderAuthState.MISSING:
|
|
294
|
+
return False
|
|
295
|
+
if self.state is ProviderAuthState.UNKNOWN:
|
|
296
|
+
return None
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def missing_detail(self) -> str:
|
|
300
|
+
"""Return a user-facing reason for a missing-credential status."""
|
|
301
|
+
if self.env_var:
|
|
302
|
+
return f"{self.env_var} is not set or is empty"
|
|
303
|
+
if self.detail:
|
|
304
|
+
return self.detail
|
|
305
|
+
return (
|
|
306
|
+
f"provider '{self.provider}' is not recognized. "
|
|
307
|
+
"Add it to ~/.deepagents/config.toml with an api_key_env field"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class ModelSpec:
|
|
313
|
+
"""A model specification in `provider:model` format.
|
|
314
|
+
|
|
315
|
+
Examples:
|
|
316
|
+
>>> spec = ModelSpec.parse("anthropic:claude-sonnet-4-5")
|
|
317
|
+
>>> spec.provider
|
|
318
|
+
'anthropic'
|
|
319
|
+
>>> spec.model
|
|
320
|
+
'claude-sonnet-4-5'
|
|
321
|
+
>>> str(spec)
|
|
322
|
+
'anthropic:claude-sonnet-4-5'
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
provider: str
|
|
326
|
+
"""The provider name (e.g., `'anthropic'`, `'openai'`)."""
|
|
327
|
+
|
|
328
|
+
model: str
|
|
329
|
+
"""The model identifier (e.g., `'claude-sonnet-4-5'`, `'gpt-5.5'`)."""
|
|
330
|
+
|
|
331
|
+
def __post_init__(self) -> None:
|
|
332
|
+
"""Validate the model spec after initialization.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
ValueError: If provider or model is empty.
|
|
336
|
+
"""
|
|
337
|
+
if not self.provider:
|
|
338
|
+
msg = "Provider cannot be empty"
|
|
339
|
+
raise ValueError(msg)
|
|
340
|
+
if not self.model:
|
|
341
|
+
msg = "Model cannot be empty"
|
|
342
|
+
raise ValueError(msg)
|
|
343
|
+
|
|
344
|
+
@classmethod
|
|
345
|
+
def parse(cls, spec: str) -> ModelSpec:
|
|
346
|
+
"""Parse a model specification string.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
spec: Model specification in `'provider:model'` format.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Parsed ModelSpec instance.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
ValueError: If the spec is not in valid `'provider:model'` format.
|
|
356
|
+
"""
|
|
357
|
+
if ":" not in spec:
|
|
358
|
+
msg = (
|
|
359
|
+
f"Invalid model spec '{spec}': must be in provider:model format "
|
|
360
|
+
"(e.g., 'anthropic:claude-sonnet-4-5')"
|
|
361
|
+
)
|
|
362
|
+
raise ValueError(msg)
|
|
363
|
+
provider, model = spec.split(":", 1)
|
|
364
|
+
return cls(provider=provider, model=model)
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def try_parse(cls, spec: str) -> ModelSpec | None:
|
|
368
|
+
"""Non-raising variant of `parse`.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
spec: Model specification in `provider:model` format.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Parsed `ModelSpec`, or `None` when *spec* is not valid.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
return cls.parse(spec)
|
|
378
|
+
except ValueError:
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
def __str__(self) -> str:
|
|
382
|
+
"""Return the model spec as a string in `provider:model` format."""
|
|
383
|
+
return f"{self.provider}:{self.model}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class ModelProfileEntry(TypedDict):
|
|
387
|
+
"""Profile data for a model with override tracking."""
|
|
388
|
+
|
|
389
|
+
profile: dict[str, Any]
|
|
390
|
+
"""Merged profile dict (upstream defaults + config.toml overrides).
|
|
391
|
+
|
|
392
|
+
Keys vary by provider (e.g., `max_input_tokens`, `tool_calling`).
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
overridden_keys: frozenset[str]
|
|
396
|
+
"""Keys in `profile` whose values came from config.toml rather than the
|
|
397
|
+
upstream provider package."""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class ProviderConfig(TypedDict, total=False):
|
|
401
|
+
"""Configuration for a model provider.
|
|
402
|
+
|
|
403
|
+
The optional `class_path` field allows bypassing `init_chat_model` entirely
|
|
404
|
+
and instantiating an arbitrary `BaseChatModel` subclass via importlib.
|
|
405
|
+
|
|
406
|
+
!!! warning
|
|
407
|
+
|
|
408
|
+
Setting `class_path` executes arbitrary Python code from the user's
|
|
409
|
+
config file. This has the same trust model as `pyproject.toml` build
|
|
410
|
+
scripts — the user controls their own machine.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
enabled: bool
|
|
414
|
+
"""Whether this provider appears in the model switcher.
|
|
415
|
+
|
|
416
|
+
Defaults to `True`. Set to `False` to hide a package-discovered provider
|
|
417
|
+
and all its models from the `/model` selector. Useful when a LangChain
|
|
418
|
+
provider package is installed as a transitive dependency but should not
|
|
419
|
+
be user-visible.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
models: list[str]
|
|
423
|
+
"""List of model identifiers available from this provider."""
|
|
424
|
+
|
|
425
|
+
api_key_env: str
|
|
426
|
+
"""Name of the environment variable that holds the API key.
|
|
427
|
+
|
|
428
|
+
This is the env var *name* (e.g., `"OPENAI_API_KEY"`), not the secret
|
|
429
|
+
itself. The app resolves it at startup to verify credentials before model
|
|
430
|
+
creation.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
display_name: str
|
|
434
|
+
"""Human-readable provider name shown in auth UI.
|
|
435
|
+
|
|
436
|
+
Useful for arbitrary providers whose config key is optimized for machine use
|
|
437
|
+
(e.g., `my_gateway`) but whose UI label should include spaces or brand
|
|
438
|
+
capitalization.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
default_model: str
|
|
442
|
+
"""Default model selected for this provider when no app-wide default exists."""
|
|
443
|
+
|
|
444
|
+
short_name: str
|
|
445
|
+
"""Compact brand label for space-constrained UI (e.g. the `/model` Recent
|
|
446
|
+
tag), where the full `display_name` — which may carry a parenthetical
|
|
447
|
+
qualifier like `"OpenAI Codex (ChatGPT login)"` — is too long. Optional;
|
|
448
|
+
when unset, callers fall back to `display_name`.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
api_key_url: str
|
|
452
|
+
"""Provider page where users can create or manage API keys.
|
|
453
|
+
|
|
454
|
+
Used by `/auth` as an acquisition link before the API-key input. The value is
|
|
455
|
+
a URL, not a credential. Must use an `http` or `https` scheme to render as a
|
|
456
|
+
clickable link; values with other schemes are ignored with a warning.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
base_url: str
|
|
460
|
+
"""Custom base URL."""
|
|
461
|
+
|
|
462
|
+
base_url_env: str
|
|
463
|
+
"""Name of the environment variable that holds this provider's base URL.
|
|
464
|
+
|
|
465
|
+
Parallel to `api_key_env`: lets a provider that is not one of the built-in
|
|
466
|
+
`PROVIDER_BASE_URL_ENV` entries participate in endpoint resolution and in
|
|
467
|
+
the key/endpoint pairing applied by `apply_stored_credentials` (so a stored
|
|
468
|
+
`/auth` override clears an inherited gateway URL). The static `base_url`
|
|
469
|
+
field still wins over this when both are set.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
# Level 2: arbitrary BaseChatModel classes
|
|
473
|
+
|
|
474
|
+
class_path: str
|
|
475
|
+
"""Fully-qualified Python class in `module.path:ClassName` format.
|
|
476
|
+
|
|
477
|
+
When set, `create_model` imports this class and instantiates it directly
|
|
478
|
+
instead of calling `init_chat_model`.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
params: dict[str, Any]
|
|
482
|
+
"""Extra keyword arguments forwarded to the model constructor.
|
|
483
|
+
|
|
484
|
+
Flat keys (e.g., `temperature = 0`) are provider-wide defaults applied to
|
|
485
|
+
every model from this provider. Model-keyed sub-tables (e.g.,
|
|
486
|
+
`[params."qwen3:4b"]`) override individual values for that model only;
|
|
487
|
+
the merge is shallow (model wins on conflict).
|
|
488
|
+
|
|
489
|
+
Do not set `api_key` here — the early credential check runs before
|
|
490
|
+
`params` are read, so the app will reject the model before it sees the key.
|
|
491
|
+
Use `api_key_env` to point at an environment variable instead.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
profile: dict[str, Any]
|
|
495
|
+
"""Overrides merged into the model's runtime profile dict.
|
|
496
|
+
|
|
497
|
+
Flat keys (e.g., `max_input_tokens = 4096`) are provider-wide defaults.
|
|
498
|
+
Model-keyed sub-tables (e.g., `[profile."claude-sonnet-4-5"]`) override
|
|
499
|
+
individual values for that model only; the merge is shallow.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".zjcode"
|
|
504
|
+
"""Directory for user-level Deep Agents configuration (`~/.zjcode`).
|
|
505
|
+
|
|
506
|
+
Renamed from `~/.deepagents` for the private-branded `zjcode` build so
|
|
507
|
+
config, sessions, tokens, and update caches are fully isolated from any
|
|
508
|
+
side-by-side install of the upstream `dcode`. Every other module reads this
|
|
509
|
+
constant rather than hard-coding the path, so switching brands is one line.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
|
513
|
+
"""Path to the user's model configuration file (`~/.deepagents/config.toml`)."""
|
|
514
|
+
|
|
515
|
+
DEFAULT_STATE_DIR = DEFAULT_CONFIG_DIR / ".state"
|
|
516
|
+
"""Directory for app-managed internal state (`~/.deepagents/.state`).
|
|
517
|
+
|
|
518
|
+
Holds files the app writes for its own bookkeeping — OAuth tokens, the
|
|
519
|
+
sessions database, version-check caches, input history. Kept separate from
|
|
520
|
+
top-level user-facing config and agent directories so listing/iterating
|
|
521
|
+
`~/.deepagents` doesn't conflate state with agents.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
RECENT_MODELS_FILENAME = "recent_models.json"
|
|
525
|
+
"""Filename under `DEFAULT_STATE_DIR` for the MRU list shown in `/model`."""
|
|
526
|
+
|
|
527
|
+
RECENT_MODELS_LIMIT = 5
|
|
528
|
+
"""Maximum number of `provider:model` specs retained in the recent list.
|
|
529
|
+
|
|
530
|
+
Sized to fit comfortably above the provider-grouped list in `/model` without
|
|
531
|
+
pushing the rest of the catalog off-screen on a typical terminal.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
PROVIDER_API_KEY_ENV: dict[str, str] = {
|
|
535
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
536
|
+
"azure_openai": "AZURE_OPENAI_API_KEY",
|
|
537
|
+
"baseten": "BASETEN_API_KEY",
|
|
538
|
+
"cohere": "COHERE_API_KEY",
|
|
539
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
540
|
+
"fireworks": "FIREWORKS_API_KEY",
|
|
541
|
+
"google_genai": "GOOGLE_API_KEY",
|
|
542
|
+
"google_vertexai": "GOOGLE_CLOUD_PROJECT",
|
|
543
|
+
"groq": "GROQ_API_KEY",
|
|
544
|
+
"huggingface": "HUGGINGFACEHUB_API_TOKEN",
|
|
545
|
+
"ibm": "WATSONX_APIKEY",
|
|
546
|
+
"litellm": "LITELLM_API_KEY",
|
|
547
|
+
"meta": "MODEL_API_KEY",
|
|
548
|
+
"mistralai": "MISTRAL_API_KEY",
|
|
549
|
+
"nvidia": "NVIDIA_API_KEY",
|
|
550
|
+
"openai": "OPENAI_API_KEY",
|
|
551
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
552
|
+
"perplexity": "PPLX_API_KEY",
|
|
553
|
+
"together": "TOGETHER_API_KEY",
|
|
554
|
+
"xai": "XAI_API_KEY",
|
|
555
|
+
}
|
|
556
|
+
"""Well-known providers mapped to the env var that holds their API key.
|
|
557
|
+
|
|
558
|
+
Used by `has_provider_credentials` to verify credentials *before* model
|
|
559
|
+
creation, so the UI can show a warning icon and a specific error message
|
|
560
|
+
(e.g., "ANTHROPIC_API_KEY not set") instead of letting the provider fail at call
|
|
561
|
+
time.
|
|
562
|
+
|
|
563
|
+
Providers not listed here fall through to the config-file check or the langchain
|
|
564
|
+
registry fallback.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
LANGSMITH_SERVICE = "langsmith"
|
|
568
|
+
"""Service name for LangSmith tracing in `SERVICE_API_KEY_ENV`.
|
|
569
|
+
|
|
570
|
+
Storing a key for this service via `/auth` also enables tracing at startup
|
|
571
|
+
(see `config._apply_stored_langsmith_tracing`) and can carry a custom project
|
|
572
|
+
name, so it gets special handling beyond a plain key copy.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
TAVILY_SERVICE = "tavily"
|
|
576
|
+
"""Service name for Tavily web search in `SERVICE_API_KEY_ENV`.
|
|
577
|
+
|
|
578
|
+
Storing a key for this service via `/auth` gates the spawn-time `web_search`
|
|
579
|
+
tool (see `server_graph._build_tools`), so a key added to a running server
|
|
580
|
+
takes effect only after a respawn — the app offers that restart, and this
|
|
581
|
+
constant is the single name its `/auth` handling compares against.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
SERVICE_API_KEY_ENV: dict[str, str] = {
|
|
585
|
+
LANGSMITH_SERVICE: "LANGSMITH_API_KEY",
|
|
586
|
+
TAVILY_SERVICE: "TAVILY_API_KEY",
|
|
587
|
+
}
|
|
588
|
+
"""Non-model services configurable via `/auth`, mapped to their API-key env var.
|
|
589
|
+
|
|
590
|
+
These are not LLM providers — they back features such as web search (Tavily) or
|
|
591
|
+
agent tracing (LangSmith) — but their credentials follow the same store-on-disk
|
|
592
|
+
model as model providers, so they appear in the `/auth` manager and can be
|
|
593
|
+
entered directly in the TUI instead of being exported as environment variables
|
|
594
|
+
before launch.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
CODEX_PROVIDER = "openai_codex"
|
|
598
|
+
"""Provider name for `_ChatOpenAICodex` models authenticated via ChatGPT OAuth.
|
|
599
|
+
|
|
600
|
+
Distinct from `"openai"` (which uses an `OPENAI_API_KEY`) because the auth
|
|
601
|
+
source, model class, and request endpoint all differ. See
|
|
602
|
+
`deepagents_code.integrations.openai_codex` for the OAuth flow.
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
CODEX_MODELS: frozenset[str] = frozenset(
|
|
606
|
+
{
|
|
607
|
+
"gpt-5.6-luna",
|
|
608
|
+
"gpt-5.6-sol",
|
|
609
|
+
"gpt-5.6-terra",
|
|
610
|
+
"gpt-5.5",
|
|
611
|
+
"gpt-5.4",
|
|
612
|
+
"gpt-5.4-mini",
|
|
613
|
+
"gpt-5.3-codex",
|
|
614
|
+
"gpt-5.2",
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
"""Curated allowlist of models the Codex (ChatGPT OAuth) backend serves.
|
|
618
|
+
|
|
619
|
+
The provider mirrors `openai` profiles, but only models in this set are
|
|
620
|
+
exposed under `openai_codex`. The Codex backend serves a narrower lineup than
|
|
621
|
+
the full `openai` API, so mirroring every openai model would surface specs the
|
|
622
|
+
backend rejects at call time.
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
RETRY_PARAM_BY_PROVIDER: dict[str, str] = {
|
|
627
|
+
"anthropic": "max_retries",
|
|
628
|
+
"azure_openai": "max_retries",
|
|
629
|
+
"baseten": "max_retries",
|
|
630
|
+
"bedrock": "max_retries",
|
|
631
|
+
"deepseek": "max_retries",
|
|
632
|
+
"fireworks": "max_retries",
|
|
633
|
+
"google_genai": "max_retries",
|
|
634
|
+
"google_vertexai": "max_retries",
|
|
635
|
+
"groq": "max_retries",
|
|
636
|
+
"litellm": "max_retries",
|
|
637
|
+
"meta": "max_retries",
|
|
638
|
+
"mistralai": "max_retries",
|
|
639
|
+
"openai": "max_retries",
|
|
640
|
+
"openrouter": "max_retries",
|
|
641
|
+
"perplexity": "max_retries",
|
|
642
|
+
"together": "max_retries",
|
|
643
|
+
"xai": "max_retries",
|
|
644
|
+
}
|
|
645
|
+
"""Maps a provider to the constructor kwarg that sets its retry count.
|
|
646
|
+
|
|
647
|
+
The value is the kwarg name to pass to the provider's chat model constructor.
|
|
648
|
+
It is uniformly `max_retries` for every provider listed today, but this is a
|
|
649
|
+
`dict` rather than a `set` of providers because retry-kwarg names diverge across
|
|
650
|
+
the ecosystem -- some integrations expose a differently named kwarg -- and the
|
|
651
|
+
value column lets a future provider register its own name without restructuring
|
|
652
|
+
callers.
|
|
653
|
+
|
|
654
|
+
Membership is verified against each provider's chat model constructor (e.g.
|
|
655
|
+
`ChatGoogleGenerativeAI` exposes `max_retries`, not `retries`), not inferred
|
|
656
|
+
from naming. Providers absent from this map either lack an integer retry-count
|
|
657
|
+
kwarg or are not yet wired as a credential-resolvable provider in this module;
|
|
658
|
+
a `[retries]` config for them is ignored with a warning by `_resolve_retry_kwargs`.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = {
|
|
662
|
+
# Each tuple lists every base-URL env var the provider's LangChain
|
|
663
|
+
# integration and underlying SDK may read, canonical name first. Names were
|
|
664
|
+
# verified against the integration and SDK source, not inferred:
|
|
665
|
+
# anthropic langchain_anthropic reads ANTHROPIC_API_URL; the anthropic
|
|
666
|
+
# SDK reads ANTHROPIC_BASE_URL.
|
|
667
|
+
# azure_openai AzureChatOpenAI and the openai SDK both read
|
|
668
|
+
# AZURE_OPENAI_ENDPOINT.
|
|
669
|
+
# baseten ChatBaseten reads BASETEN_BASE_URL, then falls back to
|
|
670
|
+
# BASETEN_API_BASE.
|
|
671
|
+
# cohere langchain_cohere passes base_url=None, so the cohere SDK's
|
|
672
|
+
# CO_API_URL is what takes effect.
|
|
673
|
+
# deepseek ChatDeepSeek reads DEEPSEEK_API_BASE (alias base_url).
|
|
674
|
+
# fireworks ChatFireworks reads FIREWORKS_API_BASE; when unset the
|
|
675
|
+
# fireworks SDK reads FIREWORKS_BASE_URL.
|
|
676
|
+
# google_genai the google-genai SDK reads GOOGLE_GEMINI_BASE_URL (the lone
|
|
677
|
+
# name langchain_google_genai threads through HttpOptions).
|
|
678
|
+
# groq ChatGroq reads GROQ_API_BASE; when unset the groq SDK reads
|
|
679
|
+
# GROQ_BASE_URL.
|
|
680
|
+
# huggingface the integration and huggingface_hub both read
|
|
681
|
+
# HF_INFERENCE_ENDPOINT.
|
|
682
|
+
# ibm ChatWatsonx reads WATSONX_URL.
|
|
683
|
+
# meta ChatMetaModel reads MODEL_API_BASE.
|
|
684
|
+
# mistralai ChatMistralAI reads MISTRAL_BASE_URL.
|
|
685
|
+
# nvidia ChatNVIDIA reads NVIDIA_BASE_URL.
|
|
686
|
+
# openai langchain_openai reads OPENAI_API_BASE; the openai SDK
|
|
687
|
+
# reads OPENAI_BASE_URL.
|
|
688
|
+
# openrouter ChatOpenRouter reads OPENROUTER_API_BASE (alias base_url).
|
|
689
|
+
# perplexity the integration passes no base_url, so the perplexity SDK's
|
|
690
|
+
# PERPLEXITY_BASE_URL is what takes effect.
|
|
691
|
+
# together ChatTogether reads TOGETHER_API_BASE (alias base_url).
|
|
692
|
+
# xai ChatXAI reads XAI_API_BASE (alias base_url).
|
|
693
|
+
#
|
|
694
|
+
# OpenAI-compatible providers (deepseek, openrouter, together, xai, baseten)
|
|
695
|
+
# sit on the openai SDK, whose only base-URL env var is the shared
|
|
696
|
+
# OPENAI_BASE_URL. That name is intentionally NOT listed under those
|
|
697
|
+
# providers: writing or clearing it under another provider's name would
|
|
698
|
+
# clobber the user's real OpenAI endpoint. Each is listed above under its own
|
|
699
|
+
# dedicated name(s) instead. In practice the integration always passes
|
|
700
|
+
# base_url explicitly, so the shared fallback never fires.
|
|
701
|
+
#
|
|
702
|
+
# Omitted (no dedicated, provider-specific endpoint env var): litellm
|
|
703
|
+
# (api_base arg, per-provider env), google_vertexai (endpoint derived from the
|
|
704
|
+
# region). A `/auth` endpoint for these still resolves through the
|
|
705
|
+
# stored-credential step of `get_base_url` and reaches the model as the
|
|
706
|
+
# `base_url` kwarg.
|
|
707
|
+
"anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_URL"),
|
|
708
|
+
"azure_openai": ("AZURE_OPENAI_ENDPOINT",),
|
|
709
|
+
"baseten": ("BASETEN_BASE_URL", "BASETEN_API_BASE"),
|
|
710
|
+
"cohere": ("CO_API_URL",),
|
|
711
|
+
"deepseek": ("DEEPSEEK_API_BASE",),
|
|
712
|
+
"fireworks": ("FIREWORKS_BASE_URL", "FIREWORKS_API_BASE"),
|
|
713
|
+
"google_genai": ("GOOGLE_GEMINI_BASE_URL",),
|
|
714
|
+
"groq": ("GROQ_BASE_URL", "GROQ_API_BASE"),
|
|
715
|
+
"huggingface": ("HF_INFERENCE_ENDPOINT",),
|
|
716
|
+
"ibm": ("WATSONX_URL",),
|
|
717
|
+
"meta": ("MODEL_API_BASE",),
|
|
718
|
+
"mistralai": ("MISTRAL_BASE_URL",),
|
|
719
|
+
"nvidia": ("NVIDIA_BASE_URL",),
|
|
720
|
+
"openai": ("OPENAI_BASE_URL", "OPENAI_API_BASE"),
|
|
721
|
+
"openrouter": ("OPENROUTER_API_BASE",),
|
|
722
|
+
"perplexity": ("PERPLEXITY_BASE_URL",),
|
|
723
|
+
"together": ("TOGETHER_API_BASE",),
|
|
724
|
+
"xai": ("XAI_API_BASE",),
|
|
725
|
+
}
|
|
726
|
+
"""Every base-URL env var a provider's SDK may read.
|
|
727
|
+
|
|
728
|
+
Element `[0]` is the *canonical* name — the one we write a stored endpoint to.
|
|
729
|
+
`get_base_url` reads each name in tuple order through `resolve_env_var`, so every
|
|
730
|
+
base URL gets the same `DEEPAGENTS_CODE_*` > plain-var precedence as API keys.
|
|
731
|
+
The remaining names are alternates the SDK might also honor;
|
|
732
|
+
`apply_stored_credentials` clears them when applying or resetting an endpoint, so
|
|
733
|
+
a stale value (e.g. an inherited gateway URL) can't leak through. Clearing every
|
|
734
|
+
name is what lets the write path treat the canonical as authoritative regardless
|
|
735
|
+
of which name the SDK prefers.
|
|
736
|
+
|
|
737
|
+
The key and its endpoint are a coherent pair: a gateway key only works against
|
|
738
|
+
the gateway URL, a provider-native key only against the provider's own endpoint,
|
|
739
|
+
so both must resolve from the same source.
|
|
740
|
+
"""
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _canonical_base_url_env(provider: str) -> str | None:
|
|
744
|
+
"""Return the canonical (written) base-URL env var name for a provider.
|
|
745
|
+
|
|
746
|
+
The canonical name is element `[0]` of the provider's `PROVIDER_BASE_URL_ENV`
|
|
747
|
+
tuple. Returns `None` for providers outside the built-in set.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
provider: Provider name.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Canonical env var name, or `None` if the provider has no built-in entry.
|
|
754
|
+
"""
|
|
755
|
+
names = PROVIDER_BASE_URL_ENV.get(provider)
|
|
756
|
+
return names[0] if names else None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
IMPLICIT_AUTH_PROVIDERS: frozenset[str] = frozenset({"google_vertexai"})
|
|
760
|
+
"""Providers that support ambient auth outside app env-var checks.
|
|
761
|
+
|
|
762
|
+
These providers can authenticate without the env var listed in
|
|
763
|
+
`PROVIDER_API_KEY_ENV`, so a missing env var should not be treated as a hard
|
|
764
|
+
credential failure. Used by `create_model` to skip the early credential check
|
|
765
|
+
and by `get_provider_auth_status` for user-facing auth labels.
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
NO_AUTH_REQUIRED_PROVIDERS: frozenset[str] = frozenset({"ollama"})
|
|
769
|
+
"""Providers whose default local configuration does not require API keys."""
|
|
770
|
+
|
|
771
|
+
OPTIONAL_AUTH_ENV: dict[str, str] = {"ollama": "OLLAMA_API_KEY"}
|
|
772
|
+
"""Optional env vars that enable authenticated provider modes when present."""
|
|
773
|
+
|
|
774
|
+
PROVIDER_HOST_ENV: dict[str, str] = {"ollama": "OLLAMA_HOST"}
|
|
775
|
+
"""Provider-specific env vars that can point a local provider at a remote host."""
|
|
776
|
+
|
|
777
|
+
PROVIDER_CUSTOM_HEADERS_ENV: dict[str, str] = {"anthropic": "ANTHROPIC_CUSTOM_HEADERS"}
|
|
778
|
+
"""Provider SDK env vars that inject custom request headers (e.g. gateway auth)."""
|
|
779
|
+
|
|
780
|
+
OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434"
|
|
781
|
+
"""Default endpoint assumed when no `base_url` or `OLLAMA_HOST` is configured."""
|
|
782
|
+
|
|
783
|
+
OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 1.0
|
|
784
|
+
"""Socket timeout for Ollama discovery probes.
|
|
785
|
+
|
|
786
|
+
Kept short so a dead daemon does not stall switcher loading. Discovery runs
|
|
787
|
+
off the UI loop in a worker thread and may call `/api/tags` and `/api/show`,
|
|
788
|
+
so this caps the worst-case wait visible to the user.
|
|
789
|
+
"""
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# Module-level caches — cleared by `clear_caches()`.
|
|
793
|
+
_available_models_cache: dict[str, list[str]] | None = None
|
|
794
|
+
_builtin_providers_cache: dict[str, Any] | None = None
|
|
795
|
+
_default_config_cache: ModelConfig | None = None
|
|
796
|
+
_provider_profiles_cache: dict[str, dict[str, Any]] = {}
|
|
797
|
+
_provider_profiles_lock = threading.Lock()
|
|
798
|
+
_ollama_installed_models_cache: dict[str, list[str]] = {}
|
|
799
|
+
_ollama_model_profiles_cache: dict[tuple[str, str], dict[str, Any]] = {}
|
|
800
|
+
_profiles_cache: Mapping[str, ModelProfileEntry] | None = None
|
|
801
|
+
_profiles_override_cache: tuple[int, Mapping[str, ModelProfileEntry]] | None = None
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def clear_caches() -> None:
|
|
805
|
+
"""Reset module-level caches so the next call recomputes from scratch.
|
|
806
|
+
|
|
807
|
+
Intended for tests and for the `/reload` command.
|
|
808
|
+
"""
|
|
809
|
+
global _available_models_cache, _builtin_providers_cache, _default_config_cache, _profiles_cache, _profiles_override_cache # noqa: PLW0603, E501 # Module-level caches require global statement
|
|
810
|
+
_available_models_cache = None
|
|
811
|
+
_builtin_providers_cache = None
|
|
812
|
+
_default_config_cache = None
|
|
813
|
+
_provider_profiles_cache.clear()
|
|
814
|
+
_ollama_installed_models_cache.clear()
|
|
815
|
+
_ollama_model_profiles_cache.clear()
|
|
816
|
+
_profiles_cache = None
|
|
817
|
+
_profiles_override_cache = None
|
|
818
|
+
invalidate_thread_config_cache()
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _get_builtin_providers() -> dict[str, Any]:
|
|
822
|
+
"""Return langchain's built-in provider registry.
|
|
823
|
+
|
|
824
|
+
Tries the newer `_BUILTIN_PROVIDERS` name first, then falls back to
|
|
825
|
+
the legacy `_SUPPORTED_PROVIDERS` for older langchain versions.
|
|
826
|
+
|
|
827
|
+
Results are cached after the first call; use `clear_caches()` to reset.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
The provider registry dict from `langchain.chat_models.base`.
|
|
831
|
+
"""
|
|
832
|
+
global _builtin_providers_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
833
|
+
if _builtin_providers_cache is not None:
|
|
834
|
+
return _builtin_providers_cache
|
|
835
|
+
|
|
836
|
+
# Deferred: langchain.chat_models pulls in heavy provider registry,
|
|
837
|
+
# only needed when resolving provider names for model config.
|
|
838
|
+
from langchain.chat_models import base
|
|
839
|
+
|
|
840
|
+
registry: dict[str, Any] | None = getattr(base, "_BUILTIN_PROVIDERS", None)
|
|
841
|
+
if registry is None:
|
|
842
|
+
registry = getattr(base, "_SUPPORTED_PROVIDERS", None)
|
|
843
|
+
_builtin_providers_cache = registry if registry is not None else {}
|
|
844
|
+
return _builtin_providers_cache
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _get_provider_profile_modules() -> list[tuple[str, str]]:
|
|
848
|
+
"""Build a `(provider, profile_module)` list from langchain's provider registry.
|
|
849
|
+
|
|
850
|
+
Reads the built-in provider registry from `langchain.chat_models.base`
|
|
851
|
+
to discover every provider that `init_chat_model` knows about, then derives
|
|
852
|
+
the `<package>.data._profiles` module path for each.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
List of `(provider_name, profile_module_path)` tuples.
|
|
856
|
+
"""
|
|
857
|
+
providers = _get_builtin_providers()
|
|
858
|
+
|
|
859
|
+
result: list[tuple[str, str]] = []
|
|
860
|
+
seen: set[tuple[str, str]] = set()
|
|
861
|
+
|
|
862
|
+
for provider_name, (module_path, *_rest) in providers.items():
|
|
863
|
+
package_root = module_path.split(".", maxsplit=1)[0]
|
|
864
|
+
profile_module = f"{package_root}.data._profiles"
|
|
865
|
+
key = (provider_name, profile_module)
|
|
866
|
+
if key not in seen:
|
|
867
|
+
seen.add(key)
|
|
868
|
+
result.append((provider_name, profile_module))
|
|
869
|
+
|
|
870
|
+
return result
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _load_provider_profiles(module_path: str) -> dict[str, Any]:
|
|
874
|
+
"""Load `_PROFILES` from a provider's data module.
|
|
875
|
+
|
|
876
|
+
Results are cached by `module_path` so repeated calls (e.g., from both
|
|
877
|
+
`get_available_models` and `get_model_profiles`) reuse the same dict.
|
|
878
|
+
Use `clear_caches()` to reset.
|
|
879
|
+
|
|
880
|
+
Locates the package on disk with `importlib.util.find_spec` and loads *only*
|
|
881
|
+
the `_profiles.py` file via `spec_from_file_location`.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
module_path: Dotted module path (e.g., `"langchain_openai.data._profiles"`).
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
The `_PROFILES` dictionary from the module, or an empty dict if
|
|
888
|
+
the module has no such attribute.
|
|
889
|
+
|
|
890
|
+
Raises:
|
|
891
|
+
ImportError: If the package is not installed or the profile module
|
|
892
|
+
cannot be found on disk.
|
|
893
|
+
"""
|
|
894
|
+
with _provider_profiles_lock:
|
|
895
|
+
cached = _provider_profiles_cache.get(module_path)
|
|
896
|
+
if cached is not None: # `is not None` so empty profile dicts are cached
|
|
897
|
+
return cached
|
|
898
|
+
|
|
899
|
+
parts = module_path.split(".")
|
|
900
|
+
package_root = parts[0]
|
|
901
|
+
|
|
902
|
+
spec = importlib.util.find_spec(package_root)
|
|
903
|
+
if spec is None:
|
|
904
|
+
msg = f"Package {package_root} is not installed"
|
|
905
|
+
raise ImportError(msg)
|
|
906
|
+
|
|
907
|
+
# Determine the package directory from the spec.
|
|
908
|
+
if spec.origin:
|
|
909
|
+
package_dir = Path(spec.origin).parent
|
|
910
|
+
elif spec.submodule_search_locations:
|
|
911
|
+
package_dir = Path(next(iter(spec.submodule_search_locations)))
|
|
912
|
+
else:
|
|
913
|
+
msg = f"Cannot determine location for {package_root}"
|
|
914
|
+
raise ImportError(msg)
|
|
915
|
+
|
|
916
|
+
# Build the path to the target file (e.g., data/_profiles.py).
|
|
917
|
+
relative_parts = parts[1:] # ["data", "_profiles"]
|
|
918
|
+
profiles_path = package_dir.joinpath(
|
|
919
|
+
*relative_parts[:-1], f"{relative_parts[-1]}.py"
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
if not profiles_path.exists():
|
|
923
|
+
msg = f"Profile module not found: {profiles_path}"
|
|
924
|
+
raise ImportError(msg)
|
|
925
|
+
|
|
926
|
+
file_spec = importlib.util.spec_from_file_location(module_path, profiles_path)
|
|
927
|
+
if file_spec is None or file_spec.loader is None:
|
|
928
|
+
msg = f"Could not create module spec for {profiles_path}"
|
|
929
|
+
raise ImportError(msg)
|
|
930
|
+
|
|
931
|
+
module = importlib.util.module_from_spec(file_spec)
|
|
932
|
+
file_spec.loader.exec_module(module)
|
|
933
|
+
profiles = getattr(module, "_PROFILES", {})
|
|
934
|
+
_provider_profiles_cache[module_path] = profiles
|
|
935
|
+
return profiles
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _profile_module_from_class_path(class_path: str) -> str | None:
|
|
939
|
+
"""Derive the profile module path from a `class_path` config value.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
class_path: Fully-qualified class in `module.path:ClassName` format.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
Dotted module path like `langchain_baseten.data._profiles`, or None
|
|
946
|
+
if `class_path` is malformed.
|
|
947
|
+
"""
|
|
948
|
+
if ":" not in class_path:
|
|
949
|
+
return None
|
|
950
|
+
module_part, _ = class_path.split(":", 1)
|
|
951
|
+
package_root = module_part.split(".", maxsplit=1)[0]
|
|
952
|
+
if not package_root:
|
|
953
|
+
return None
|
|
954
|
+
return f"{package_root}.data._profiles"
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def get_available_models() -> dict[str, list[str]]:
|
|
958
|
+
"""Get available models dynamically from installed LangChain provider packages.
|
|
959
|
+
|
|
960
|
+
Imports model profiles from each provider package and extracts model names.
|
|
961
|
+
|
|
962
|
+
Results are cached after the first call; use `clear_caches()` to reset.
|
|
963
|
+
|
|
964
|
+
Returns:
|
|
965
|
+
Dictionary mapping provider names to lists of model identifiers.
|
|
966
|
+
Includes providers from the langchain registry, config-file
|
|
967
|
+
providers with explicit model lists, and `class_path` providers
|
|
968
|
+
whose packages expose a `_profiles` module.
|
|
969
|
+
"""
|
|
970
|
+
global _available_models_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
971
|
+
if _available_models_cache is not None:
|
|
972
|
+
return _available_models_cache
|
|
973
|
+
|
|
974
|
+
available: dict[str, list[str]] = {}
|
|
975
|
+
config = ModelConfig.load()
|
|
976
|
+
|
|
977
|
+
# Try to load from langchain provider profile data.
|
|
978
|
+
# Build the list dynamically from langchain's supported-provider registry
|
|
979
|
+
# so new providers are picked up automatically when langchain adds them.
|
|
980
|
+
provider_modules = _get_provider_profile_modules()
|
|
981
|
+
registry_providers: set[str] = set()
|
|
982
|
+
|
|
983
|
+
for provider, module_path in provider_modules:
|
|
984
|
+
registry_providers.add(provider)
|
|
985
|
+
# Skip providers explicitly disabled in config.
|
|
986
|
+
if not config.is_provider_enabled(provider):
|
|
987
|
+
logger.debug(
|
|
988
|
+
"Provider '%s' is disabled in config; skipping registry discovery",
|
|
989
|
+
provider,
|
|
990
|
+
)
|
|
991
|
+
continue
|
|
992
|
+
try:
|
|
993
|
+
profiles = _load_provider_profiles(module_path)
|
|
994
|
+
except ImportError:
|
|
995
|
+
logger.debug(
|
|
996
|
+
"Could not import profiles from %s (package may not be installed)",
|
|
997
|
+
module_path,
|
|
998
|
+
)
|
|
999
|
+
continue
|
|
1000
|
+
except Exception:
|
|
1001
|
+
logger.warning(
|
|
1002
|
+
"Failed to load profiles from %s, skipping provider '%s'",
|
|
1003
|
+
module_path,
|
|
1004
|
+
provider,
|
|
1005
|
+
exc_info=True,
|
|
1006
|
+
)
|
|
1007
|
+
continue
|
|
1008
|
+
|
|
1009
|
+
# Filter to models that support tool calling and text I/O.
|
|
1010
|
+
models = [
|
|
1011
|
+
name
|
|
1012
|
+
for name, profile in profiles.items()
|
|
1013
|
+
if profile.get("tool_calling", False)
|
|
1014
|
+
and profile.get("text_inputs", True) is not False
|
|
1015
|
+
and profile.get("text_outputs", True) is not False
|
|
1016
|
+
]
|
|
1017
|
+
|
|
1018
|
+
models.sort()
|
|
1019
|
+
if models:
|
|
1020
|
+
available[provider] = models
|
|
1021
|
+
|
|
1022
|
+
# Merge in models from config file (custom providers like ollama, fireworks)
|
|
1023
|
+
logger.debug("Processing config providers: %s", list(config.providers.keys()))
|
|
1024
|
+
for provider_name, provider_config in config.providers.items():
|
|
1025
|
+
# Respect enabled = false (hide provider entirely).
|
|
1026
|
+
if not config.is_provider_enabled(provider_name):
|
|
1027
|
+
logger.debug(
|
|
1028
|
+
"Provider '%s' is disabled in config; skipping",
|
|
1029
|
+
provider_name,
|
|
1030
|
+
)
|
|
1031
|
+
continue
|
|
1032
|
+
|
|
1033
|
+
config_models = list(provider_config.get("models", []))
|
|
1034
|
+
|
|
1035
|
+
# For class_path providers not in the built-in registry, auto-discover
|
|
1036
|
+
# models from the package's _profiles.py when no explicit models list.
|
|
1037
|
+
if (
|
|
1038
|
+
not config_models
|
|
1039
|
+
and provider_name not in registry_providers
|
|
1040
|
+
and provider_name not in available
|
|
1041
|
+
):
|
|
1042
|
+
class_path = provider_config.get("class_path", "")
|
|
1043
|
+
profile_module = _profile_module_from_class_path(class_path)
|
|
1044
|
+
if profile_module:
|
|
1045
|
+
try:
|
|
1046
|
+
profiles = _load_provider_profiles(profile_module)
|
|
1047
|
+
except ImportError:
|
|
1048
|
+
logger.debug(
|
|
1049
|
+
"Could not import profiles from %s for class_path "
|
|
1050
|
+
"provider '%s' (package may not be installed)",
|
|
1051
|
+
profile_module,
|
|
1052
|
+
provider_name,
|
|
1053
|
+
)
|
|
1054
|
+
except Exception:
|
|
1055
|
+
logger.warning(
|
|
1056
|
+
"Failed to load profiles from %s for class_path provider '%s'",
|
|
1057
|
+
profile_module,
|
|
1058
|
+
provider_name,
|
|
1059
|
+
exc_info=True,
|
|
1060
|
+
)
|
|
1061
|
+
else:
|
|
1062
|
+
config_models = sorted(
|
|
1063
|
+
name
|
|
1064
|
+
for name, profile in profiles.items()
|
|
1065
|
+
if profile.get("tool_calling", False)
|
|
1066
|
+
and profile.get("text_inputs", True) is not False
|
|
1067
|
+
and profile.get("text_outputs", True) is not False
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if provider_name not in available:
|
|
1071
|
+
available[provider_name] = config_models or ["custom_model"]
|
|
1072
|
+
else:
|
|
1073
|
+
# Append any config models not already discovered
|
|
1074
|
+
existing = set(available[provider_name])
|
|
1075
|
+
for model in config_models:
|
|
1076
|
+
if model not in existing:
|
|
1077
|
+
available[provider_name].append(model)
|
|
1078
|
+
|
|
1079
|
+
# `langchain-ollama` ships no profile data, so the steps above leave the
|
|
1080
|
+
# switcher empty unless the user hand-curates `models = [...]` in config.
|
|
1081
|
+
# Probe the daemon for installed models and merge them in,
|
|
1082
|
+
# preserving explicit config order (config wins) with discoveries appended.
|
|
1083
|
+
# Cached alongside the rest of `available`; refresh by
|
|
1084
|
+
# calling `clear_caches()` (e.g. via the `/reload` slash command).
|
|
1085
|
+
if (
|
|
1086
|
+
_ollama_discovery_enabled()
|
|
1087
|
+
and "ollama" in registry_providers
|
|
1088
|
+
and config.is_provider_enabled("ollama")
|
|
1089
|
+
and importlib.util.find_spec("langchain_ollama") is not None
|
|
1090
|
+
):
|
|
1091
|
+
endpoint = _get_provider_endpoint("ollama", config)
|
|
1092
|
+
discovered = _get_ollama_installed_models(endpoint)
|
|
1093
|
+
if discovered:
|
|
1094
|
+
available["ollama"] = list(
|
|
1095
|
+
dict.fromkeys([*available.get("ollama", []), *discovered])
|
|
1096
|
+
)
|
|
1097
|
+
else:
|
|
1098
|
+
logger.debug(
|
|
1099
|
+
"Ollama discovery returned no models for %s; "
|
|
1100
|
+
"daemon may be down or have no pulls",
|
|
1101
|
+
endpoint or OLLAMA_DEFAULT_BASE_URL,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# Mirror the curated `CODEX_MODELS` subset of `openai` models under a
|
|
1105
|
+
# dedicated `openai_codex` provider entry so the switcher offers them under
|
|
1106
|
+
# their own ChatGPT-OAuth auth context. Eligibility is filtered by the
|
|
1107
|
+
# allowlist because the Codex backend serves a narrower lineup than the
|
|
1108
|
+
# full `openai` API and rejects unsupported models at call time.
|
|
1109
|
+
if config.is_provider_enabled(CODEX_PROVIDER):
|
|
1110
|
+
openai_models = available.get("openai")
|
|
1111
|
+
if openai_models:
|
|
1112
|
+
mirrored = [name for name in openai_models if name in CODEX_MODELS]
|
|
1113
|
+
codex_models = list(
|
|
1114
|
+
dict.fromkeys([*available.get(CODEX_PROVIDER, []), *mirrored])
|
|
1115
|
+
)
|
|
1116
|
+
# Place `openai_codex` directly after `openai` so the switcher
|
|
1117
|
+
# keeps the two OpenAI-backed providers adjacent (codex before
|
|
1118
|
+
# azure_openai etc.) instead of trailing it at the end of the
|
|
1119
|
+
# dict. dict insertion order is the switcher's display order, so
|
|
1120
|
+
# rebuild the dict, dropping any prior codex entry and re-inserting
|
|
1121
|
+
# it right after `openai`.
|
|
1122
|
+
reordered: dict[str, list[str]] = {}
|
|
1123
|
+
for name, models in available.items():
|
|
1124
|
+
if name == CODEX_PROVIDER:
|
|
1125
|
+
continue
|
|
1126
|
+
reordered[name] = models
|
|
1127
|
+
if name == "openai":
|
|
1128
|
+
reordered[CODEX_PROVIDER] = codex_models
|
|
1129
|
+
available = reordered
|
|
1130
|
+
|
|
1131
|
+
_available_models_cache = available
|
|
1132
|
+
return available
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _build_entry(
|
|
1136
|
+
base: dict[str, Any],
|
|
1137
|
+
overrides: dict[str, Any],
|
|
1138
|
+
cli_override: dict[str, Any] | None,
|
|
1139
|
+
) -> ModelProfileEntry:
|
|
1140
|
+
"""Build a profile entry by merging base, overrides, and app override.
|
|
1141
|
+
|
|
1142
|
+
Args:
|
|
1143
|
+
base: Upstream profile dict (empty for config-only models).
|
|
1144
|
+
overrides: `config.toml` profile overrides.
|
|
1145
|
+
cli_override: Extra fields from `--profile-override`.
|
|
1146
|
+
|
|
1147
|
+
Returns:
|
|
1148
|
+
Profile entry with merged data and override tracking.
|
|
1149
|
+
"""
|
|
1150
|
+
merged = {**base, **overrides}
|
|
1151
|
+
overridden_keys = set(overrides)
|
|
1152
|
+
if cli_override:
|
|
1153
|
+
merged = {**merged, **cli_override}
|
|
1154
|
+
overridden_keys |= set(cli_override)
|
|
1155
|
+
return ModelProfileEntry(
|
|
1156
|
+
profile=merged,
|
|
1157
|
+
overridden_keys=frozenset(overridden_keys),
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def get_model_profiles(
|
|
1162
|
+
*,
|
|
1163
|
+
cli_override: dict[str, Any] | None = None,
|
|
1164
|
+
) -> Mapping[str, ModelProfileEntry]:
|
|
1165
|
+
"""Load upstream profiles merged with config.toml overrides.
|
|
1166
|
+
|
|
1167
|
+
Keyed by `provider:model` spec string. Each entry contains the
|
|
1168
|
+
merged profile dict and the set of keys overridden by config.toml.
|
|
1169
|
+
|
|
1170
|
+
Unlike `get_available_models()`, this includes all models from upstream
|
|
1171
|
+
profiles regardless of capability filters (tool calling, text I/O).
|
|
1172
|
+
|
|
1173
|
+
Results are cached; use `clear_caches()` to reset. When `cli_override` is
|
|
1174
|
+
provided the result is stored in a single-slot cache keyed by
|
|
1175
|
+
`id(cli_override)`. This relies on the caller retaining the same dict
|
|
1176
|
+
object for the session (the app stores it once on the app instance);
|
|
1177
|
+
passing a different dict with the same contents will bypass the cache
|
|
1178
|
+
and overwrite the previous entry.
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
cli_override: Extra profile fields from `--profile-override`.
|
|
1182
|
+
|
|
1183
|
+
When provided, these are merged on top of every profile entry
|
|
1184
|
+
(after upstream + config.toml) and their keys are added to
|
|
1185
|
+
`overridden_keys`.
|
|
1186
|
+
|
|
1187
|
+
Returns:
|
|
1188
|
+
Read-only mapping of spec strings to profile entries.
|
|
1189
|
+
"""
|
|
1190
|
+
global _profiles_cache, _profiles_override_cache # noqa: PLW0603 # Module-level caches require global statement
|
|
1191
|
+
if cli_override is None and _profiles_cache is not None:
|
|
1192
|
+
return _profiles_cache
|
|
1193
|
+
if cli_override is not None and _profiles_override_cache is not None:
|
|
1194
|
+
cached_id, cached_result = _profiles_override_cache
|
|
1195
|
+
if cached_id == id(cli_override):
|
|
1196
|
+
return cached_result
|
|
1197
|
+
|
|
1198
|
+
result: dict[str, ModelProfileEntry] = {}
|
|
1199
|
+
config = ModelConfig.load()
|
|
1200
|
+
|
|
1201
|
+
# Collect upstream profiles from provider packages.
|
|
1202
|
+
seen_specs: set[str] = set()
|
|
1203
|
+
provider_modules = _get_provider_profile_modules()
|
|
1204
|
+
registry_providers: set[str] = set()
|
|
1205
|
+
for provider, module_path in provider_modules:
|
|
1206
|
+
registry_providers.add(provider)
|
|
1207
|
+
# Skip providers explicitly disabled in config.
|
|
1208
|
+
if not config.is_provider_enabled(provider):
|
|
1209
|
+
logger.debug(
|
|
1210
|
+
"Provider '%s' is disabled in config; skipping profiles",
|
|
1211
|
+
provider,
|
|
1212
|
+
)
|
|
1213
|
+
continue
|
|
1214
|
+
try:
|
|
1215
|
+
profiles = _load_provider_profiles(module_path)
|
|
1216
|
+
except ImportError:
|
|
1217
|
+
logger.debug(
|
|
1218
|
+
"Could not import profiles from %s for provider '%s'",
|
|
1219
|
+
module_path,
|
|
1220
|
+
provider,
|
|
1221
|
+
)
|
|
1222
|
+
continue
|
|
1223
|
+
except Exception:
|
|
1224
|
+
logger.warning(
|
|
1225
|
+
"Failed to load profiles from %s for provider '%s'",
|
|
1226
|
+
module_path,
|
|
1227
|
+
provider,
|
|
1228
|
+
exc_info=True,
|
|
1229
|
+
)
|
|
1230
|
+
continue
|
|
1231
|
+
|
|
1232
|
+
for model_name, upstream_profile in profiles.items():
|
|
1233
|
+
spec = f"{provider}:{model_name}"
|
|
1234
|
+
seen_specs.add(spec)
|
|
1235
|
+
overrides = config.get_profile_overrides(provider, model_name=model_name)
|
|
1236
|
+
result[spec] = _build_entry(upstream_profile, overrides, cli_override)
|
|
1237
|
+
# Mirror the curated `CODEX_MODELS` subset of openai profiles under
|
|
1238
|
+
# the `openai_codex` provider so `/model openai_codex:<model>`
|
|
1239
|
+
# resolves to the same upstream profile without duplicating data.
|
|
1240
|
+
# Filtered by the allowlist — see the note in `get_available_models`.
|
|
1241
|
+
if (
|
|
1242
|
+
provider == "openai"
|
|
1243
|
+
and model_name in CODEX_MODELS
|
|
1244
|
+
and config.is_provider_enabled(CODEX_PROVIDER)
|
|
1245
|
+
):
|
|
1246
|
+
codex_spec = f"{CODEX_PROVIDER}:{model_name}"
|
|
1247
|
+
seen_specs.add(codex_spec)
|
|
1248
|
+
codex_overrides = config.get_profile_overrides(
|
|
1249
|
+
CODEX_PROVIDER, model_name=model_name
|
|
1250
|
+
)
|
|
1251
|
+
result[codex_spec] = _build_entry(
|
|
1252
|
+
upstream_profile, codex_overrides, cli_override
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
# Add config-only models and class_path provider profiles.
|
|
1256
|
+
for provider_name, provider_config in config.providers.items():
|
|
1257
|
+
if not config.is_provider_enabled(provider_name):
|
|
1258
|
+
logger.debug(
|
|
1259
|
+
"Provider '%s' is disabled in config; skipping profiles",
|
|
1260
|
+
provider_name,
|
|
1261
|
+
)
|
|
1262
|
+
continue
|
|
1263
|
+
# For class_path providers not in the built-in registry, load
|
|
1264
|
+
# upstream profiles from the package's _profiles.py.
|
|
1265
|
+
if provider_name not in registry_providers:
|
|
1266
|
+
class_path = provider_config.get("class_path", "")
|
|
1267
|
+
profile_module = _profile_module_from_class_path(class_path)
|
|
1268
|
+
if profile_module:
|
|
1269
|
+
try:
|
|
1270
|
+
pkg_profiles = _load_provider_profiles(profile_module)
|
|
1271
|
+
except ImportError:
|
|
1272
|
+
logger.debug(
|
|
1273
|
+
"Could not import profiles from %s for class_path "
|
|
1274
|
+
"provider '%s' (package may not be installed)",
|
|
1275
|
+
profile_module,
|
|
1276
|
+
provider_name,
|
|
1277
|
+
)
|
|
1278
|
+
except Exception:
|
|
1279
|
+
logger.warning(
|
|
1280
|
+
"Failed to load profiles from %s for class_path provider '%s'",
|
|
1281
|
+
profile_module,
|
|
1282
|
+
provider_name,
|
|
1283
|
+
exc_info=True,
|
|
1284
|
+
)
|
|
1285
|
+
else:
|
|
1286
|
+
for model_name, upstream_profile in pkg_profiles.items():
|
|
1287
|
+
spec = f"{provider_name}:{model_name}"
|
|
1288
|
+
seen_specs.add(spec)
|
|
1289
|
+
overrides = config.get_profile_overrides(
|
|
1290
|
+
provider_name, model_name=model_name
|
|
1291
|
+
)
|
|
1292
|
+
result[spec] = _build_entry(
|
|
1293
|
+
upstream_profile, overrides, cli_override
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
config_models = provider_config.get("models", [])
|
|
1297
|
+
for model_name in config_models:
|
|
1298
|
+
spec = f"{provider_name}:{model_name}"
|
|
1299
|
+
if spec not in seen_specs:
|
|
1300
|
+
overrides = config.get_profile_overrides(
|
|
1301
|
+
provider_name, model_name=model_name
|
|
1302
|
+
)
|
|
1303
|
+
result[spec] = _build_entry({}, overrides, cli_override)
|
|
1304
|
+
|
|
1305
|
+
# `langchain-ollama` does not ship static profile data. When discovery is
|
|
1306
|
+
# enabled, ask the daemon for model metadata so the selector can show
|
|
1307
|
+
# context length and capabilities for locally pulled models.
|
|
1308
|
+
if (
|
|
1309
|
+
_ollama_discovery_enabled()
|
|
1310
|
+
and "ollama" in registry_providers
|
|
1311
|
+
and config.is_provider_enabled("ollama")
|
|
1312
|
+
and importlib.util.find_spec("langchain_ollama") is not None
|
|
1313
|
+
):
|
|
1314
|
+
endpoint = _get_provider_endpoint("ollama", config)
|
|
1315
|
+
discovered_model_names = _get_ollama_installed_models(endpoint)
|
|
1316
|
+
configured_model_names = [
|
|
1317
|
+
spec.removeprefix("ollama:")
|
|
1318
|
+
for spec in result
|
|
1319
|
+
if spec.startswith("ollama:")
|
|
1320
|
+
]
|
|
1321
|
+
model_names = list(
|
|
1322
|
+
dict.fromkeys([*configured_model_names, *discovered_model_names])
|
|
1323
|
+
)
|
|
1324
|
+
if model_names:
|
|
1325
|
+
discovered_profiles = _fetch_ollama_installed_model_profiles(
|
|
1326
|
+
endpoint,
|
|
1327
|
+
model_names,
|
|
1328
|
+
)
|
|
1329
|
+
for model_name in model_names:
|
|
1330
|
+
profile = discovered_profiles.get(model_name, {})
|
|
1331
|
+
spec = f"ollama:{model_name}"
|
|
1332
|
+
existing = result.get(spec)
|
|
1333
|
+
base = dict(existing["profile"]) if existing is not None else {}
|
|
1334
|
+
base.update(profile)
|
|
1335
|
+
overrides = config.get_profile_overrides(
|
|
1336
|
+
"ollama", model_name=model_name
|
|
1337
|
+
)
|
|
1338
|
+
result[spec] = _build_entry(base, overrides, cli_override)
|
|
1339
|
+
seen_specs.add(spec)
|
|
1340
|
+
|
|
1341
|
+
frozen = MappingProxyType(result)
|
|
1342
|
+
if cli_override is None:
|
|
1343
|
+
_profiles_cache = frozen
|
|
1344
|
+
else:
|
|
1345
|
+
_profiles_override_cache = (id(cli_override), frozen)
|
|
1346
|
+
return frozen
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
_LOCAL_HOSTNAMES: frozenset[str] = frozenset(
|
|
1350
|
+
{
|
|
1351
|
+
"localhost",
|
|
1352
|
+
"127.0.0.1",
|
|
1353
|
+
"::1",
|
|
1354
|
+
"0.0.0.0", # noqa: S104 # hostname comparison, not socket binding
|
|
1355
|
+
}
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _is_local_endpoint(url: object) -> bool:
|
|
1360
|
+
"""Return whether a provider endpoint points at the local machine.
|
|
1361
|
+
|
|
1362
|
+
Accepts `object` rather than `str | None` because the endpoint originates
|
|
1363
|
+
from untyped TOML; the `isinstance` guard below defends against drift.
|
|
1364
|
+
"""
|
|
1365
|
+
if not url:
|
|
1366
|
+
return True
|
|
1367
|
+
if not isinstance(url, str):
|
|
1368
|
+
return False
|
|
1369
|
+
|
|
1370
|
+
# Bare hostname literal (no scheme, no port) — short-circuit so IPv6
|
|
1371
|
+
# forms like `::1` don't get misparsed by urlparse.
|
|
1372
|
+
if url in _LOCAL_HOSTNAMES:
|
|
1373
|
+
return True
|
|
1374
|
+
|
|
1375
|
+
candidate = url if "://" in url else f"http://{url}"
|
|
1376
|
+
try:
|
|
1377
|
+
parsed = urlparse(candidate)
|
|
1378
|
+
except ValueError:
|
|
1379
|
+
return False
|
|
1380
|
+
return parsed.hostname in _LOCAL_HOSTNAMES
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _get_provider_endpoint(provider: str, config: ModelConfig) -> str | None:
|
|
1384
|
+
"""Return a provider endpoint from config or provider-specific env vars."""
|
|
1385
|
+
base_url = config.get_base_url(provider)
|
|
1386
|
+
if base_url:
|
|
1387
|
+
return base_url
|
|
1388
|
+
|
|
1389
|
+
host_env = PROVIDER_HOST_ENV.get(provider)
|
|
1390
|
+
if not host_env:
|
|
1391
|
+
return None
|
|
1392
|
+
return resolve_env_var(host_env)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
_OLLAMA_DISCOVERY_FALSY: frozenset[str] = frozenset({"0", "false", "no", "off"})
|
|
1396
|
+
"""Normalized values that disable Ollama discovery when set in `OLLAMA_DISCOVERY`."""
|
|
1397
|
+
|
|
1398
|
+
_OLLAMA_DISCOVERY_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
|
1399
|
+
"""Normalized values that enable Ollama discovery when set in `OLLAMA_DISCOVERY`."""
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _ollama_discovery_enabled() -> bool:
|
|
1403
|
+
"""Return whether Ollama model/profile discovery may run.
|
|
1404
|
+
|
|
1405
|
+
Defaults to enabled. Opt out via `_env_vars.OLLAMA_DISCOVERY` set to a
|
|
1406
|
+
falsy value (`0`, `false`, `no`, `off`); truthy values (`1`, `true`,
|
|
1407
|
+
`yes`, `on`) explicitly enable. Unrecognized values warn and fall through
|
|
1408
|
+
to the default because the user clearly tried to configure something.
|
|
1409
|
+
"""
|
|
1410
|
+
raw = resolve_env_var(_env_vars.OLLAMA_DISCOVERY)
|
|
1411
|
+
if raw is None:
|
|
1412
|
+
return True
|
|
1413
|
+
normalized = raw.strip().lower()
|
|
1414
|
+
if normalized in _OLLAMA_DISCOVERY_FALSY:
|
|
1415
|
+
return False
|
|
1416
|
+
if normalized in _OLLAMA_DISCOVERY_TRUTHY:
|
|
1417
|
+
return True
|
|
1418
|
+
logger.warning(
|
|
1419
|
+
"Unrecognized value for %s: %r; expected one of %s. Defaulting to enabled.",
|
|
1420
|
+
_env_vars.OLLAMA_DISCOVERY,
|
|
1421
|
+
raw,
|
|
1422
|
+
sorted(_OLLAMA_DISCOVERY_FALSY | _OLLAMA_DISCOVERY_TRUTHY),
|
|
1423
|
+
)
|
|
1424
|
+
return True
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _get_ollama_installed_models(endpoint: str | None) -> list[str]:
|
|
1428
|
+
"""Return cached Ollama model names for `endpoint`.
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
1432
|
+
`OLLAMA_DEFAULT_BASE_URL`.
|
|
1433
|
+
|
|
1434
|
+
Returns:
|
|
1435
|
+
Sorted list of model names reported by `/api/tags`.
|
|
1436
|
+
"""
|
|
1437
|
+
key = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
1438
|
+
cached = _ollama_installed_models_cache.get(key)
|
|
1439
|
+
if cached is not None:
|
|
1440
|
+
return list(cached)
|
|
1441
|
+
models = _fetch_ollama_installed_models(endpoint)
|
|
1442
|
+
if models:
|
|
1443
|
+
_ollama_installed_models_cache[key] = models
|
|
1444
|
+
return list(models)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _ollama_host_reachable(
|
|
1448
|
+
base: str, *, timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS
|
|
1449
|
+
) -> bool:
|
|
1450
|
+
"""Return whether a TCP listener appears to accept connections at `base`.
|
|
1451
|
+
|
|
1452
|
+
A lightweight presence preflight so Ollama discovery can skip the HTTP
|
|
1453
|
+
probe entirely when no daemon is running (e.g. Ollama is not installed).
|
|
1454
|
+
The check opens and immediately closes a TCP connection to the endpoint's
|
|
1455
|
+
host and port. Any failure -- connection refused, DNS error, timeout, or
|
|
1456
|
+
sockets blocked under `pytest-socket` -- is treated as "not reachable" so
|
|
1457
|
+
discovery falls back gracefully; an unexpected (non-`OSError`) failure is
|
|
1458
|
+
additionally logged at warning so a real bug isn't misreported as absence.
|
|
1459
|
+
|
|
1460
|
+
Args:
|
|
1461
|
+
base: Base URL of the Ollama daemon, e.g. `http://localhost:11434`.
|
|
1462
|
+
timeout: Socket connection timeout in seconds.
|
|
1463
|
+
|
|
1464
|
+
Returns:
|
|
1465
|
+
`True` when a connection is established (a daemon appears present) or
|
|
1466
|
+
when the target cannot be determined (deferring to the HTTP probe);
|
|
1467
|
+
`False` when the connection cannot be made.
|
|
1468
|
+
"""
|
|
1469
|
+
import socket
|
|
1470
|
+
|
|
1471
|
+
parsed = urlparse(base)
|
|
1472
|
+
host = parsed.hostname
|
|
1473
|
+
if not host:
|
|
1474
|
+
# Can't determine a target host; let the HTTP probe make the decision.
|
|
1475
|
+
return True
|
|
1476
|
+
try:
|
|
1477
|
+
port = parsed.port
|
|
1478
|
+
except ValueError:
|
|
1479
|
+
# Malformed port (out of range / non-numeric); defer to the HTTP probe.
|
|
1480
|
+
return True
|
|
1481
|
+
if port is None:
|
|
1482
|
+
port = 443 if parsed.scheme == "https" else 80
|
|
1483
|
+
# Mirror the discovery probe below: expected transport failures (refused,
|
|
1484
|
+
# DNS, timeout) just mean the daemon is absent and stay silent; anything
|
|
1485
|
+
# else is surfaced at warning so a real bug isn't misreported as "not
|
|
1486
|
+
# detected". `pytest-socket`'s `SocketBlockedError` inherits from
|
|
1487
|
+
# `Exception` (not `OSError`), so the broad branch catches it. The socket
|
|
1488
|
+
# is its own context manager, so `with` closes the probe connection.
|
|
1489
|
+
try:
|
|
1490
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
1491
|
+
return True
|
|
1492
|
+
except OSError:
|
|
1493
|
+
return False
|
|
1494
|
+
except Exception as exc: # noqa: BLE001 # see comment above
|
|
1495
|
+
logger.warning(
|
|
1496
|
+
"Ollama presence preflight raised unexpected %s for %s: %s",
|
|
1497
|
+
type(exc).__name__,
|
|
1498
|
+
base,
|
|
1499
|
+
exc,
|
|
1500
|
+
)
|
|
1501
|
+
return False
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _fetch_ollama_installed_models(
|
|
1505
|
+
endpoint: str | None,
|
|
1506
|
+
*,
|
|
1507
|
+
timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS,
|
|
1508
|
+
) -> list[str]:
|
|
1509
|
+
"""Discover models installed in a local or hosted Ollama daemon.
|
|
1510
|
+
|
|
1511
|
+
Issues a `GET {endpoint}/api/tags` and returns the sorted list of model
|
|
1512
|
+
names reported by the daemon. The probe is best-effort: any error
|
|
1513
|
+
(timeout, connection refused, malformed JSON) yields an empty list and is
|
|
1514
|
+
logged at debug level so the model switcher can fall back gracefully.
|
|
1515
|
+
|
|
1516
|
+
When probing a local endpoint and `OLLAMA_API_KEY` (or the
|
|
1517
|
+
`DEEPAGENTS_CODE_`-prefixed variant) is set, its value is forwarded as a
|
|
1518
|
+
`Bearer` token. Discovery never forwards credentials to non-local endpoints.
|
|
1519
|
+
|
|
1520
|
+
Args:
|
|
1521
|
+
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
1522
|
+
`OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated.
|
|
1523
|
+
timeout: Socket timeout in seconds.
|
|
1524
|
+
|
|
1525
|
+
Returns:
|
|
1526
|
+
Sorted list of model names; empty when the daemon is unreachable or
|
|
1527
|
+
returns no models.
|
|
1528
|
+
"""
|
|
1529
|
+
import json
|
|
1530
|
+
from urllib.error import URLError
|
|
1531
|
+
from urllib.request import Request, urlopen
|
|
1532
|
+
|
|
1533
|
+
base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
1534
|
+
if not base.startswith(("http://", "https://")):
|
|
1535
|
+
logger.warning(
|
|
1536
|
+
"Skipping Ollama discovery: %r has no http:// or https:// scheme. "
|
|
1537
|
+
"Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.",
|
|
1538
|
+
base,
|
|
1539
|
+
)
|
|
1540
|
+
return []
|
|
1541
|
+
|
|
1542
|
+
# Presence preflight (local endpoints only -- remote hosts may be reachable
|
|
1543
|
+
# only through a proxy that the HTTP probe honors but a raw socket does
|
|
1544
|
+
# not). A dead/absent daemon (the common case when Ollama is not installed)
|
|
1545
|
+
# refuses the connection; detecting that here lets us skip the HTTP probe
|
|
1546
|
+
# and log a quiet "not detected" line instead of a misleading
|
|
1547
|
+
# "discovery failed ... Connection refused" debug line.
|
|
1548
|
+
if _is_local_endpoint(base) and not _ollama_host_reachable(base, timeout=timeout):
|
|
1549
|
+
logger.debug("Ollama daemon not detected at %s; skipping discovery", base)
|
|
1550
|
+
return []
|
|
1551
|
+
|
|
1552
|
+
url = f"{base}/api/tags"
|
|
1553
|
+
|
|
1554
|
+
headers = _ollama_discovery_headers(base, content_type=False)
|
|
1555
|
+
request = Request(url, headers=headers) # noqa: S310 # scheme guarded above
|
|
1556
|
+
# Catch-all is intentional: discovery is best-effort and must never break
|
|
1557
|
+
# the model selector. The narrow tuple is fully subsumed by `Exception`
|
|
1558
|
+
# below; we keep it only to log expected transport failures at debug while
|
|
1559
|
+
# surfacing unexpected ones at warning so a real bug doesn't disappear.
|
|
1560
|
+
# Notably catches `pytest-socket`'s `SocketBlockedError`, which inherits
|
|
1561
|
+
# from `Exception` (not `OSError`) and would otherwise propagate during
|
|
1562
|
+
# unit tests run with `--disable-socket`. `KeyboardInterrupt` and
|
|
1563
|
+
# `SystemExit` derive from `BaseException` and bypass both branches.
|
|
1564
|
+
try:
|
|
1565
|
+
with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above
|
|
1566
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1567
|
+
except (URLError, TimeoutError, OSError, ValueError) as exc:
|
|
1568
|
+
logger.debug("Ollama model discovery failed for %s: %s", url, exc)
|
|
1569
|
+
return []
|
|
1570
|
+
except Exception as exc: # noqa: BLE001 # see comment above
|
|
1571
|
+
logger.warning(
|
|
1572
|
+
"Ollama model discovery raised unexpected %s for %s: %s",
|
|
1573
|
+
type(exc).__name__,
|
|
1574
|
+
url,
|
|
1575
|
+
exc,
|
|
1576
|
+
)
|
|
1577
|
+
return []
|
|
1578
|
+
|
|
1579
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
|
1580
|
+
logger.debug(
|
|
1581
|
+
"Ollama discovery: %s returned unexpected payload shape (%s); "
|
|
1582
|
+
"endpoint may not be an Ollama daemon",
|
|
1583
|
+
url,
|
|
1584
|
+
type(payload).__name__,
|
|
1585
|
+
)
|
|
1586
|
+
return []
|
|
1587
|
+
|
|
1588
|
+
names: list[str] = []
|
|
1589
|
+
for entry in payload["models"]:
|
|
1590
|
+
if isinstance(entry, dict):
|
|
1591
|
+
name = entry.get("name")
|
|
1592
|
+
if isinstance(name, str) and name:
|
|
1593
|
+
names.append(name)
|
|
1594
|
+
names.sort()
|
|
1595
|
+
return names
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _ollama_discovery_headers(endpoint: str, *, content_type: bool) -> dict[str, str]:
|
|
1599
|
+
"""Build headers for Ollama discovery requests.
|
|
1600
|
+
|
|
1601
|
+
Args:
|
|
1602
|
+
endpoint: Base URL for the discovery request.
|
|
1603
|
+
content_type: Whether to include a JSON `Content-Type` header.
|
|
1604
|
+
|
|
1605
|
+
Returns:
|
|
1606
|
+
HTTP headers including optional bearer auth for local endpoints.
|
|
1607
|
+
"""
|
|
1608
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
1609
|
+
if content_type:
|
|
1610
|
+
headers["Content-Type"] = "application/json"
|
|
1611
|
+
optional_env = OPTIONAL_AUTH_ENV.get("ollama")
|
|
1612
|
+
if optional_env and _is_local_endpoint(endpoint):
|
|
1613
|
+
api_key = resolve_env_var(optional_env)
|
|
1614
|
+
if api_key:
|
|
1615
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
1616
|
+
return headers
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _coerce_positive_int(value: object) -> int | None:
|
|
1620
|
+
"""Return `value` as a positive integer, or `None` when unavailable."""
|
|
1621
|
+
if isinstance(value, bool):
|
|
1622
|
+
return None
|
|
1623
|
+
if isinstance(value, int) and value > 0:
|
|
1624
|
+
return value
|
|
1625
|
+
if isinstance(value, float) and value > 0 and value.is_integer():
|
|
1626
|
+
return int(value)
|
|
1627
|
+
if isinstance(value, str):
|
|
1628
|
+
try:
|
|
1629
|
+
parsed = int(value)
|
|
1630
|
+
except ValueError:
|
|
1631
|
+
return None
|
|
1632
|
+
if parsed > 0:
|
|
1633
|
+
return parsed
|
|
1634
|
+
return None
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _profile_from_ollama_show_payload(payload: object) -> dict[str, Any]:
|
|
1638
|
+
"""Extract LangChain-style profile fields from an Ollama `/api/show` payload.
|
|
1639
|
+
|
|
1640
|
+
Args:
|
|
1641
|
+
payload: Decoded JSON response from `POST /api/show`.
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
Profile fields understood by the model selector, such as
|
|
1645
|
+
`max_input_tokens` and `tool_calling`.
|
|
1646
|
+
"""
|
|
1647
|
+
if not isinstance(payload, dict):
|
|
1648
|
+
return {}
|
|
1649
|
+
payload_dict = cast("dict[str, object]", payload)
|
|
1650
|
+
|
|
1651
|
+
profile: dict[str, Any] = {}
|
|
1652
|
+
model_info = payload_dict.get("model_info")
|
|
1653
|
+
if isinstance(model_info, dict):
|
|
1654
|
+
context_lengths = [
|
|
1655
|
+
length
|
|
1656
|
+
for key, value in model_info.items()
|
|
1657
|
+
if isinstance(key, str)
|
|
1658
|
+
and (key == "context_length" or key.endswith(".context_length"))
|
|
1659
|
+
and (length := _coerce_positive_int(value)) is not None
|
|
1660
|
+
]
|
|
1661
|
+
if context_lengths:
|
|
1662
|
+
profile["max_input_tokens"] = max(context_lengths)
|
|
1663
|
+
|
|
1664
|
+
capabilities = payload_dict.get("capabilities")
|
|
1665
|
+
if isinstance(capabilities, list):
|
|
1666
|
+
capability_names = {item for item in capabilities if isinstance(item, str)}
|
|
1667
|
+
if "completion" in capability_names:
|
|
1668
|
+
profile["text_inputs"] = True
|
|
1669
|
+
profile["text_outputs"] = True
|
|
1670
|
+
if "tools" in capability_names:
|
|
1671
|
+
profile["tool_calling"] = True
|
|
1672
|
+
if "thinking" in capability_names:
|
|
1673
|
+
profile["reasoning_output"] = True
|
|
1674
|
+
|
|
1675
|
+
if not profile and ("model_info" in payload_dict or "capabilities" in payload_dict):
|
|
1676
|
+
logger.debug(
|
|
1677
|
+
"Ollama profile discovery returned a payload with no recognized "
|
|
1678
|
+
"profile fields; top-level keys: %s",
|
|
1679
|
+
sorted(str(key) for key in payload_dict),
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
return profile
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def _fetch_ollama_installed_model_profiles(
|
|
1686
|
+
endpoint: str | None,
|
|
1687
|
+
model_names: list[str],
|
|
1688
|
+
*,
|
|
1689
|
+
timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS,
|
|
1690
|
+
) -> dict[str, dict[str, Any]]:
|
|
1691
|
+
"""Discover profile metadata for installed Ollama models.
|
|
1692
|
+
|
|
1693
|
+
Issues `POST {endpoint}/api/show` for each model. The probe is best-effort:
|
|
1694
|
+
failures for one model are logged and do not stop profile discovery for the
|
|
1695
|
+
remaining models.
|
|
1696
|
+
|
|
1697
|
+
Args:
|
|
1698
|
+
endpoint: Base URL of the Ollama daemon. When `None`, defaults to
|
|
1699
|
+
`OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated.
|
|
1700
|
+
model_names: Model names to inspect.
|
|
1701
|
+
timeout: Socket timeout in seconds.
|
|
1702
|
+
|
|
1703
|
+
Returns:
|
|
1704
|
+
Mapping of model name to extracted profile fields.
|
|
1705
|
+
"""
|
|
1706
|
+
import json
|
|
1707
|
+
from urllib.error import URLError
|
|
1708
|
+
from urllib.request import Request, urlopen
|
|
1709
|
+
|
|
1710
|
+
base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/")
|
|
1711
|
+
if not base.startswith(("http://", "https://")):
|
|
1712
|
+
logger.warning(
|
|
1713
|
+
"Skipping Ollama profile discovery: %r has no http:// or https:// scheme. "
|
|
1714
|
+
"Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.",
|
|
1715
|
+
base,
|
|
1716
|
+
)
|
|
1717
|
+
return {}
|
|
1718
|
+
|
|
1719
|
+
url = f"{base}/api/show"
|
|
1720
|
+
profiles: dict[str, dict[str, Any]] = {}
|
|
1721
|
+
headers = _ollama_discovery_headers(base, content_type=True)
|
|
1722
|
+
|
|
1723
|
+
for model_name in model_names:
|
|
1724
|
+
cache_key = (base, model_name)
|
|
1725
|
+
cached = _ollama_model_profiles_cache.get(cache_key)
|
|
1726
|
+
if cached is not None:
|
|
1727
|
+
profiles[model_name] = dict(cached)
|
|
1728
|
+
continue
|
|
1729
|
+
|
|
1730
|
+
body = json.dumps({"model": model_name}).encode("utf-8")
|
|
1731
|
+
request = Request( # noqa: S310 # scheme guarded above
|
|
1732
|
+
url,
|
|
1733
|
+
data=body,
|
|
1734
|
+
headers=headers,
|
|
1735
|
+
method="POST",
|
|
1736
|
+
)
|
|
1737
|
+
try:
|
|
1738
|
+
with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above
|
|
1739
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1740
|
+
except (URLError, TimeoutError, OSError, ValueError) as exc:
|
|
1741
|
+
logger.debug(
|
|
1742
|
+
"Ollama profile discovery failed for %s via %s: %s",
|
|
1743
|
+
model_name,
|
|
1744
|
+
url,
|
|
1745
|
+
exc,
|
|
1746
|
+
)
|
|
1747
|
+
continue
|
|
1748
|
+
except Exception as exc: # noqa: BLE001 # see _fetch_ollama_installed_models
|
|
1749
|
+
logger.warning(
|
|
1750
|
+
"Ollama profile discovery raised unexpected %s for %s via %s: %s",
|
|
1751
|
+
type(exc).__name__,
|
|
1752
|
+
model_name,
|
|
1753
|
+
url,
|
|
1754
|
+
exc,
|
|
1755
|
+
)
|
|
1756
|
+
continue
|
|
1757
|
+
|
|
1758
|
+
profile = _profile_from_ollama_show_payload(payload)
|
|
1759
|
+
if profile:
|
|
1760
|
+
_ollama_model_profiles_cache[cache_key] = profile
|
|
1761
|
+
profiles[model_name] = profile
|
|
1762
|
+
|
|
1763
|
+
return profiles
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _has_stored_credential(provider: str) -> bool:
|
|
1767
|
+
"""Return whether `provider` has a credential persisted via `/auth`.
|
|
1768
|
+
|
|
1769
|
+
A corrupt `auth.json` is swallowed (logged, treated as absent) so the
|
|
1770
|
+
model selector and other read-side callers can keep listing providers.
|
|
1771
|
+
The user-visible signal lives in `AuthManagerScreen` — opening `/auth`
|
|
1772
|
+
surfaces a corruption banner directly. Read-side resilience here means
|
|
1773
|
+
you can still pick a different provider while the file is broken.
|
|
1774
|
+
"""
|
|
1775
|
+
try:
|
|
1776
|
+
return auth_store.get_stored_key(provider) is not None
|
|
1777
|
+
except RuntimeError:
|
|
1778
|
+
logger.warning(
|
|
1779
|
+
"Could not read stored credentials for provider %s; treating as absent",
|
|
1780
|
+
provider,
|
|
1781
|
+
)
|
|
1782
|
+
return False
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def resolve_provider_credential(provider: str) -> str | None:
|
|
1786
|
+
"""Resolve the credential value for `provider` from any configured source.
|
|
1787
|
+
|
|
1788
|
+
Lookup order:
|
|
1789
|
+
|
|
1790
|
+
1. Stored API key in `~/.deepagents/.state/auth.json` (added via `/auth`).
|
|
1791
|
+
2. Canonical env var via `resolve_env_var()` (which honors the
|
|
1792
|
+
`DEEPAGENTS_CODE_` prefix and dotenv files).
|
|
1793
|
+
|
|
1794
|
+
A user who has *both* a stored key and an env var set gets the stored
|
|
1795
|
+
key — entering one in the TUI is the more deliberate, more recent
|
|
1796
|
+
action, so "I just typed this in" beats whatever the shell exported.
|
|
1797
|
+
|
|
1798
|
+
Args:
|
|
1799
|
+
provider: Provider name (e.g., `"anthropic"`).
|
|
1800
|
+
|
|
1801
|
+
Returns:
|
|
1802
|
+
The credential value, or `None` when no source has one or the
|
|
1803
|
+
provider has no env-var mapping at all.
|
|
1804
|
+
"""
|
|
1805
|
+
try:
|
|
1806
|
+
stored = auth_store.get_stored_key(provider)
|
|
1807
|
+
except RuntimeError:
|
|
1808
|
+
logger.warning(
|
|
1809
|
+
"Could not read stored credentials for provider %s; falling back to env",
|
|
1810
|
+
provider,
|
|
1811
|
+
)
|
|
1812
|
+
stored = None
|
|
1813
|
+
if stored:
|
|
1814
|
+
return stored
|
|
1815
|
+
env_var = get_credential_env_var(provider)
|
|
1816
|
+
if env_var:
|
|
1817
|
+
return resolve_env_var(env_var)
|
|
1818
|
+
return None
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def _resolve_configured(provider: str, env_var: str) -> ProviderAuthStatus | None:
|
|
1822
|
+
"""Return a `CONFIGURED` status if a stored or env credential is set.
|
|
1823
|
+
|
|
1824
|
+
Stored credentials beat env vars (matches `resolve_provider_credential`).
|
|
1825
|
+
|
|
1826
|
+
Args:
|
|
1827
|
+
provider: Provider name (e.g., `"anthropic"`).
|
|
1828
|
+
env_var: Canonical env var name to check when no stored credential
|
|
1829
|
+
exists. Recorded on the returned status either way.
|
|
1830
|
+
|
|
1831
|
+
Returns:
|
|
1832
|
+
A `CONFIGURED` status, or `None` when neither source is set.
|
|
1833
|
+
"""
|
|
1834
|
+
if _has_stored_credential(provider):
|
|
1835
|
+
return ProviderAuthStatus(
|
|
1836
|
+
state=ProviderAuthState.CONFIGURED,
|
|
1837
|
+
provider=provider,
|
|
1838
|
+
env_var=env_var,
|
|
1839
|
+
source=ProviderAuthSource.STORED,
|
|
1840
|
+
detail="stored credential",
|
|
1841
|
+
)
|
|
1842
|
+
if resolve_env_var(env_var):
|
|
1843
|
+
return ProviderAuthStatus(
|
|
1844
|
+
state=ProviderAuthState.CONFIGURED,
|
|
1845
|
+
provider=provider,
|
|
1846
|
+
env_var=env_var,
|
|
1847
|
+
source=ProviderAuthSource.ENV,
|
|
1848
|
+
detail="credentials set",
|
|
1849
|
+
)
|
|
1850
|
+
return None
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _get_codex_auth_status() -> ProviderAuthStatus:
|
|
1854
|
+
"""Translate the ChatGPT OAuth on-disk state into a `ProviderAuthStatus`.
|
|
1855
|
+
|
|
1856
|
+
The codex provider uses a file-backed OAuth token store rather than
|
|
1857
|
+
`auth_store`'s API-key map, so it gets its own branch in
|
|
1858
|
+
`get_provider_auth_status`. The `STORED` source is reused only to satisfy
|
|
1859
|
+
the `ProviderAuthStatus` "CONFIGURED implies a source" invariant; it is
|
|
1860
|
+
cosmetic here, since `format_auth_badge` routes the codex provider to its
|
|
1861
|
+
own `[chatgpt]` / `[sign in to chatgpt]` badge before the source is ever
|
|
1862
|
+
consulted.
|
|
1863
|
+
|
|
1864
|
+
Returns:
|
|
1865
|
+
`CONFIGURED` / `STORED` when a token bundle sits at the upstream
|
|
1866
|
+
default store path; `MISSING` otherwise. Expired access tokens
|
|
1867
|
+
are still reported as configured because the file-backed model
|
|
1868
|
+
provider can refresh them with the saved refresh token when the
|
|
1869
|
+
model is constructed.
|
|
1870
|
+
"""
|
|
1871
|
+
from deepagents_code.integrations import openai_codex
|
|
1872
|
+
|
|
1873
|
+
status = openai_codex.get_status()
|
|
1874
|
+
if status.unreadable_reason:
|
|
1875
|
+
return ProviderAuthStatus(
|
|
1876
|
+
state=ProviderAuthState.MISSING,
|
|
1877
|
+
provider=CODEX_PROVIDER,
|
|
1878
|
+
detail=f"token store unreadable: {status.unreadable_reason}",
|
|
1879
|
+
)
|
|
1880
|
+
if not status.logged_in:
|
|
1881
|
+
return ProviderAuthStatus(
|
|
1882
|
+
state=ProviderAuthState.MISSING,
|
|
1883
|
+
provider=CODEX_PROVIDER,
|
|
1884
|
+
detail="not signed in to ChatGPT",
|
|
1885
|
+
)
|
|
1886
|
+
detail = "signed in to ChatGPT"
|
|
1887
|
+
if status.plan_type:
|
|
1888
|
+
detail = f"signed in to ChatGPT ({status.plan_type})"
|
|
1889
|
+
if status.is_expired:
|
|
1890
|
+
detail = f"{detail}; access token will refresh on use"
|
|
1891
|
+
return ProviderAuthStatus(
|
|
1892
|
+
state=ProviderAuthState.CONFIGURED,
|
|
1893
|
+
provider=CODEX_PROVIDER,
|
|
1894
|
+
source=ProviderAuthSource.STORED,
|
|
1895
|
+
detail=detail,
|
|
1896
|
+
)
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def get_provider_auth_status(provider: str) -> ProviderAuthStatus:
|
|
1900
|
+
"""Return credential readiness details for a provider.
|
|
1901
|
+
|
|
1902
|
+
Combines config, well-known provider metadata, optional provider auth,
|
|
1903
|
+
and implicit-auth provider metadata before attempting model creation:
|
|
1904
|
+
|
|
1905
|
+
1. **Config-file providers** (`config.toml`
|
|
1906
|
+
`[models.providers.<name>]`):
|
|
1907
|
+
- If the section declares `api_key_env`, that env var is checked
|
|
1908
|
+
via `resolve_env_var()` (which honors `DEEPAGENTS_CODE_` prefixes).
|
|
1909
|
+
- If the section has `class_path` but no `api_key_env`, the provider is
|
|
1910
|
+
assumed to manage its own auth (e.g., custom headers, JWT, mTLS).
|
|
1911
|
+
- If neither `api_key_env` nor `class_path` is set, falls through
|
|
1912
|
+
to provider-specific defaults.
|
|
1913
|
+
2. **Hardcoded registry** (`PROVIDER_API_KEY_ENV`): a module-level dict
|
|
1914
|
+
mapping well-known provider names to their canonical env var
|
|
1915
|
+
(e.g., `"anthropic"` → `"ANTHROPIC_API_KEY"`). The env var is checked
|
|
1916
|
+
via `resolve_env_var()`.
|
|
1917
|
+
3. **Implicit auth providers** (e.g., Vertex AI ADC): a missing env var is
|
|
1918
|
+
not treated as missing credentials.
|
|
1919
|
+
4. **Optional auth env vars** (`OPTIONAL_AUTH_ENV`): when present, mark
|
|
1920
|
+
the provider as configured for hosted/cloud use.
|
|
1921
|
+
5. **No-auth-required providers** (`NO_AUTH_REQUIRED_PROVIDERS`): default
|
|
1922
|
+
local endpoints report `NOT_REQUIRED`; non-local endpoints fall back
|
|
1923
|
+
to `UNKNOWN` so the SDK can decide.
|
|
1924
|
+
6. **Unknown providers** not present in any source defer auth failures to
|
|
1925
|
+
the provider SDK.
|
|
1926
|
+
|
|
1927
|
+
Use `has_provider_credentials()` when compatibility with the historic
|
|
1928
|
+
`True`/`False`/`None` contract is required.
|
|
1929
|
+
|
|
1930
|
+
Args:
|
|
1931
|
+
provider: Provider name (e.g., `"anthropic"`, `"openai"`).
|
|
1932
|
+
|
|
1933
|
+
Returns:
|
|
1934
|
+
Provider auth status for selectors, startup checks, and compatibility
|
|
1935
|
+
wrappers.
|
|
1936
|
+
"""
|
|
1937
|
+
# ChatGPT-OAuth-backed codex provider has no env var and stores tokens
|
|
1938
|
+
# in its own on-disk JSON; route it through a dedicated helper before
|
|
1939
|
+
# the standard config / env-var lookup so callers get the codex-specific
|
|
1940
|
+
# `[chatgpt]` / `[sign in to chatgpt]` badge and a "signed in as <plan>"
|
|
1941
|
+
# detail.
|
|
1942
|
+
if provider == CODEX_PROVIDER:
|
|
1943
|
+
return _get_codex_auth_status()
|
|
1944
|
+
|
|
1945
|
+
# Config-file providers take priority when api_key_env is specified.
|
|
1946
|
+
config = ModelConfig.load()
|
|
1947
|
+
provider_config = config.providers.get(provider)
|
|
1948
|
+
if provider_config:
|
|
1949
|
+
env_var = provider_config.get("api_key_env")
|
|
1950
|
+
if env_var:
|
|
1951
|
+
configured = _resolve_configured(provider, env_var)
|
|
1952
|
+
if configured:
|
|
1953
|
+
return configured
|
|
1954
|
+
return ProviderAuthStatus(
|
|
1955
|
+
state=ProviderAuthState.MISSING,
|
|
1956
|
+
provider=provider,
|
|
1957
|
+
env_var=env_var,
|
|
1958
|
+
detail=f"{env_var} is not set or is empty",
|
|
1959
|
+
)
|
|
1960
|
+
# class_path providers that omit api_key_env manage their own auth
|
|
1961
|
+
# (e.g., custom headers, JWT, mTLS).
|
|
1962
|
+
if provider_config.get("class_path"):
|
|
1963
|
+
return ProviderAuthStatus(
|
|
1964
|
+
state=ProviderAuthState.MANAGED,
|
|
1965
|
+
provider=provider,
|
|
1966
|
+
detail="custom auth",
|
|
1967
|
+
)
|
|
1968
|
+
# No api_key_env in config — fall through to provider-specific and
|
|
1969
|
+
# hardcoded maps.
|
|
1970
|
+
|
|
1971
|
+
# Fall back to hardcoded well-known providers.
|
|
1972
|
+
env_var = PROVIDER_API_KEY_ENV.get(provider)
|
|
1973
|
+
if env_var:
|
|
1974
|
+
configured = _resolve_configured(provider, env_var)
|
|
1975
|
+
if configured:
|
|
1976
|
+
return configured
|
|
1977
|
+
if provider in IMPLICIT_AUTH_PROVIDERS:
|
|
1978
|
+
return ProviderAuthStatus(
|
|
1979
|
+
state=ProviderAuthState.IMPLICIT,
|
|
1980
|
+
provider=provider,
|
|
1981
|
+
env_var=env_var,
|
|
1982
|
+
detail="implicit auth",
|
|
1983
|
+
)
|
|
1984
|
+
return ProviderAuthStatus(
|
|
1985
|
+
state=ProviderAuthState.MISSING,
|
|
1986
|
+
provider=provider,
|
|
1987
|
+
env_var=env_var,
|
|
1988
|
+
detail=f"{env_var} is not set or is empty",
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
if provider in IMPLICIT_AUTH_PROVIDERS:
|
|
1992
|
+
return ProviderAuthStatus(
|
|
1993
|
+
state=ProviderAuthState.IMPLICIT,
|
|
1994
|
+
provider=provider,
|
|
1995
|
+
detail="implicit auth",
|
|
1996
|
+
)
|
|
1997
|
+
|
|
1998
|
+
optional_env = OPTIONAL_AUTH_ENV.get(provider)
|
|
1999
|
+
if optional_env:
|
|
2000
|
+
configured = _resolve_configured(provider, optional_env)
|
|
2001
|
+
if configured:
|
|
2002
|
+
return configured
|
|
2003
|
+
|
|
2004
|
+
if provider in NO_AUTH_REQUIRED_PROVIDERS:
|
|
2005
|
+
endpoint = _get_provider_endpoint(provider, config)
|
|
2006
|
+
if _is_local_endpoint(endpoint):
|
|
2007
|
+
return ProviderAuthStatus(
|
|
2008
|
+
state=ProviderAuthState.NOT_REQUIRED,
|
|
2009
|
+
provider=provider,
|
|
2010
|
+
detail="local provider",
|
|
2011
|
+
)
|
|
2012
|
+
# Remote endpoint may or may not require auth (private network vs.
|
|
2013
|
+
# hosted). Don't block; surface the optional env var as a hint.
|
|
2014
|
+
detail = (
|
|
2015
|
+
f"remote endpoint; set {optional_env} if auth is required"
|
|
2016
|
+
if optional_env
|
|
2017
|
+
else "remote endpoint"
|
|
2018
|
+
)
|
|
2019
|
+
return ProviderAuthStatus(
|
|
2020
|
+
state=ProviderAuthState.UNKNOWN,
|
|
2021
|
+
provider=provider,
|
|
2022
|
+
env_var=optional_env,
|
|
2023
|
+
detail=detail,
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
# Provider not found in config or hardcoded map — credential status is
|
|
2027
|
+
# unknown. The provider itself will report auth failures at
|
|
2028
|
+
# model-creation time.
|
|
2029
|
+
logger.debug(
|
|
2030
|
+
"No credential information for provider '%s'; deferring auth to provider",
|
|
2031
|
+
provider,
|
|
2032
|
+
)
|
|
2033
|
+
return ProviderAuthStatus(
|
|
2034
|
+
state=ProviderAuthState.UNKNOWN,
|
|
2035
|
+
provider=provider,
|
|
2036
|
+
detail="credentials unknown",
|
|
2037
|
+
)
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def has_provider_credentials(provider: str) -> bool | None:
|
|
2041
|
+
"""Check if credentials are available for a provider.
|
|
2042
|
+
|
|
2043
|
+
This compatibility wrapper preserves the historic tri-state contract while
|
|
2044
|
+
`get_provider_auth_status()` carries the richer user-facing distinctions:
|
|
2045
|
+
configured credentials, missing credentials, no-auth local providers,
|
|
2046
|
+
implicit auth, custom provider-managed auth, and unknown providers.
|
|
2047
|
+
|
|
2048
|
+
Args:
|
|
2049
|
+
provider: Provider name (e.g., `"anthropic"`, `"openai"`).
|
|
2050
|
+
|
|
2051
|
+
Returns:
|
|
2052
|
+
`True` if auth is configured, implicit, provider-managed, or not
|
|
2053
|
+
required.
|
|
2054
|
+
`False` if a required env var is known but not set.
|
|
2055
|
+
`None` if credential status cannot be determined.
|
|
2056
|
+
"""
|
|
2057
|
+
return get_provider_auth_status(provider).as_legacy_bool()
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def get_credential_env_var(provider: str) -> str | None:
|
|
2061
|
+
"""Return the env var name that holds credentials for a provider.
|
|
2062
|
+
|
|
2063
|
+
Checks the config file first (user override), then falls back to the
|
|
2064
|
+
hardcoded `PROVIDER_API_KEY_ENV` map.
|
|
2065
|
+
|
|
2066
|
+
Args:
|
|
2067
|
+
provider: Provider name.
|
|
2068
|
+
|
|
2069
|
+
Returns:
|
|
2070
|
+
Environment variable name, or None if unknown.
|
|
2071
|
+
"""
|
|
2072
|
+
config = ModelConfig.load()
|
|
2073
|
+
config_env = config.get_api_key_env(provider)
|
|
2074
|
+
if config_env:
|
|
2075
|
+
return config_env
|
|
2076
|
+
return PROVIDER_API_KEY_ENV.get(provider)
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def get_base_url_env_vars(provider: str) -> tuple[str, ...]:
|
|
2080
|
+
"""Return base-URL env var names for a provider in resolution order.
|
|
2081
|
+
|
|
2082
|
+
Checks the config file's `base_url_env` first (user override), then falls
|
|
2083
|
+
back to the hardcoded `PROVIDER_BASE_URL_ENV` map.
|
|
2084
|
+
|
|
2085
|
+
Args:
|
|
2086
|
+
provider: Provider name.
|
|
2087
|
+
|
|
2088
|
+
Returns:
|
|
2089
|
+
Environment variable names, or an empty tuple if the provider has no
|
|
2090
|
+
base-URL env var (config-declared or built-in).
|
|
2091
|
+
"""
|
|
2092
|
+
config = ModelConfig.load()
|
|
2093
|
+
config_env = config.get_base_url_env(provider)
|
|
2094
|
+
if config_env:
|
|
2095
|
+
return (config_env,)
|
|
2096
|
+
return PROVIDER_BASE_URL_ENV.get(provider, ())
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def get_base_url_env_var(provider: str) -> str | None:
|
|
2100
|
+
"""Return the canonical base-URL env var name for a provider.
|
|
2101
|
+
|
|
2102
|
+
Checks the config file's `base_url_env` first (user override), then falls
|
|
2103
|
+
back to the canonical name in the hardcoded `PROVIDER_BASE_URL_ENV` map.
|
|
2104
|
+
Parallel to `get_credential_env_var`.
|
|
2105
|
+
|
|
2106
|
+
Args:
|
|
2107
|
+
provider: Provider name.
|
|
2108
|
+
|
|
2109
|
+
Returns:
|
|
2110
|
+
Environment variable name, or None if the provider has no base-URL env
|
|
2111
|
+
var (config-declared or built-in).
|
|
2112
|
+
"""
|
|
2113
|
+
env_vars = get_base_url_env_vars(provider)
|
|
2114
|
+
return env_vars[0] if env_vars else None
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def get_default_base_url_env(provider: str) -> str | None:
|
|
2118
|
+
"""Return the env var that supplies a provider's endpoint when none is stored.
|
|
2119
|
+
|
|
2120
|
+
Answers "what does leaving the `/auth` base-URL field blank fall back to?"
|
|
2121
|
+
A blank save clears the *plain* endpoint env vars (so an inherited gateway
|
|
2122
|
+
URL can't leak through — see `apply_stored_credentials`), so the only env
|
|
2123
|
+
var that still supplies a value afterward is the `DEEPAGENTS_CODE_`-prefixed
|
|
2124
|
+
one. The name is returned (not its value) for display next to the field, so
|
|
2125
|
+
the user sees the knob rather than a long or sensitive URL.
|
|
2126
|
+
|
|
2127
|
+
Returns `None` when that variable holds no value — the endpoint then comes
|
|
2128
|
+
from a `config.toml` literal or the provider SDK's own default, neither of
|
|
2129
|
+
which is a single env var to name here.
|
|
2130
|
+
|
|
2131
|
+
Args:
|
|
2132
|
+
provider: Provider name.
|
|
2133
|
+
|
|
2134
|
+
Returns:
|
|
2135
|
+
The `DEEPAGENTS_CODE_`-prefixed env var name still in effect after a
|
|
2136
|
+
blank save, or `None`.
|
|
2137
|
+
"""
|
|
2138
|
+
for env_var in get_base_url_env_vars(provider):
|
|
2139
|
+
prefixed = f"{_ENV_PREFIX}{env_var}"
|
|
2140
|
+
if os.environ.get(prefixed):
|
|
2141
|
+
return prefixed
|
|
2142
|
+
return None
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def is_service(name: str) -> bool:
|
|
2146
|
+
"""Return whether `name` is a non-model service configurable via `/auth`."""
|
|
2147
|
+
return name in SERVICE_API_KEY_ENV
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
def is_langsmith(name: str) -> bool:
|
|
2151
|
+
"""Return whether `name` is the LangSmith tracing service.
|
|
2152
|
+
|
|
2153
|
+
Centralizes the identity check so the LangSmith-specific branches (project
|
|
2154
|
+
field instead of a base URL, tracing auto-enable) share one definition
|
|
2155
|
+
rather than scattering `== LANGSMITH_SERVICE` comparisons.
|
|
2156
|
+
"""
|
|
2157
|
+
return name == LANGSMITH_SERVICE
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def get_service_auth_status(service: str) -> ProviderAuthStatus:
|
|
2161
|
+
"""Return credential readiness for a non-model service (e.g. `"tavily"`).
|
|
2162
|
+
|
|
2163
|
+
Mirrors `get_provider_auth_status` but is scoped to `SERVICE_API_KEY_ENV`,
|
|
2164
|
+
so a stored key beats the env var and the `/auth` manager can render the
|
|
2165
|
+
same `[stored]` / `[env: ...]` / `[missing]` badges.
|
|
2166
|
+
|
|
2167
|
+
Args:
|
|
2168
|
+
service: Service name (e.g. `"tavily"`).
|
|
2169
|
+
|
|
2170
|
+
Returns:
|
|
2171
|
+
`CONFIGURED` when a stored or env credential is set, else `MISSING`.
|
|
2172
|
+
"""
|
|
2173
|
+
env_var = SERVICE_API_KEY_ENV[service]
|
|
2174
|
+
configured = _resolve_configured(service, env_var)
|
|
2175
|
+
if configured:
|
|
2176
|
+
return configured
|
|
2177
|
+
return ProviderAuthStatus(
|
|
2178
|
+
state=ProviderAuthState.MISSING,
|
|
2179
|
+
provider=service,
|
|
2180
|
+
env_var=env_var,
|
|
2181
|
+
detail=f"{env_var} is not set or is empty",
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
def apply_stored_service_credentials() -> None:
|
|
2186
|
+
"""Export every stored service key into `os.environ`.
|
|
2187
|
+
|
|
2188
|
+
Services (e.g. web search via Tavily) have no base URL to reconcile, so
|
|
2189
|
+
this is a plain key copy onto the canonical env var name the underlying
|
|
2190
|
+
SDK reads. A stored key takes precedence over an existing plain env var,
|
|
2191
|
+
matching `apply_stored_credentials`; a `DEEPAGENTS_CODE_`-prefixed override
|
|
2192
|
+
is left authoritative because the app already treats it as the top-priority
|
|
2193
|
+
per-session credential.
|
|
2194
|
+
"""
|
|
2195
|
+
for service, env_var in SERVICE_API_KEY_ENV.items():
|
|
2196
|
+
try:
|
|
2197
|
+
stored = auth_store.get_stored_key(service)
|
|
2198
|
+
except RuntimeError:
|
|
2199
|
+
logger.warning(
|
|
2200
|
+
"Could not read stored credentials for service %s; the credential "
|
|
2201
|
+
"file may be corrupt. Re-add the key via /auth.",
|
|
2202
|
+
service,
|
|
2203
|
+
)
|
|
2204
|
+
continue
|
|
2205
|
+
if not stored:
|
|
2206
|
+
continue
|
|
2207
|
+
prefixed = f"{_ENV_PREFIX}{env_var}"
|
|
2208
|
+
if prefixed in os.environ:
|
|
2209
|
+
continue
|
|
2210
|
+
if os.environ.get(env_var) != stored:
|
|
2211
|
+
os.environ[env_var] = stored
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def apply_stored_credentials(provider: str) -> bool:
|
|
2215
|
+
"""Export this provider's stored key *and endpoint* into `os.environ`.
|
|
2216
|
+
|
|
2217
|
+
LangChain's chat-model factories read credentials from process env vars,
|
|
2218
|
+
so a stored key only takes effect once it's copied onto the env var name
|
|
2219
|
+
registered for that provider. This is a no-op when the provider has no
|
|
2220
|
+
env-var mapping (custom auth) or no stored credential.
|
|
2221
|
+
|
|
2222
|
+
The key env var is overwritten whether or not it was already set, matching
|
|
2223
|
+
the precedence rule documented on `resolve_provider_credential`: a
|
|
2224
|
+
credential the user typed in `/auth` is the most recent deliberate
|
|
2225
|
+
action and should take effect.
|
|
2226
|
+
|
|
2227
|
+
Because a key and its endpoint are a coherent pair (a gateway key only
|
|
2228
|
+
works against the gateway URL; a provider-native key only against the
|
|
2229
|
+
provider's own endpoint), the base URL is applied atomically with the key:
|
|
2230
|
+
|
|
2231
|
+
- A stored `base_url` is written to the provider's canonical base-URL env
|
|
2232
|
+
var, and every *other* base-URL name the SDK reads is cleared so an
|
|
2233
|
+
inherited gateway URL can't leak through an alternate variable.
|
|
2234
|
+
- No stored `base_url` (the user left the field blank) clears *all* of the
|
|
2235
|
+
provider's base-URL env vars, so the SDK falls back to the provider
|
|
2236
|
+
default rather than an inherited gateway URL. This is what prevents a
|
|
2237
|
+
personal key from being shipped to the gateway.
|
|
2238
|
+
|
|
2239
|
+
Only the unprefixed canonical names are written, so an explicit
|
|
2240
|
+
`DEEPAGENTS_CODE_{VAR}` override still wins via `resolve_env_var`.
|
|
2241
|
+
|
|
2242
|
+
Args:
|
|
2243
|
+
provider: Provider name.
|
|
2244
|
+
|
|
2245
|
+
Returns:
|
|
2246
|
+
`True` if a stored key was applied, `False` otherwise.
|
|
2247
|
+
"""
|
|
2248
|
+
env_var = get_credential_env_var(provider)
|
|
2249
|
+
if not env_var:
|
|
2250
|
+
return False
|
|
2251
|
+
try:
|
|
2252
|
+
stored = auth_store.get_stored_key(provider)
|
|
2253
|
+
stored_base_url = auth_store.get_stored_base_url(provider)
|
|
2254
|
+
except RuntimeError:
|
|
2255
|
+
logger.warning("Could not read stored credentials for provider %s", provider)
|
|
2256
|
+
return False
|
|
2257
|
+
if not stored:
|
|
2258
|
+
return False
|
|
2259
|
+
# Reconcile the endpoint first: it resolves env-var names (which can touch
|
|
2260
|
+
# the config) and so is the only step that might raise. Doing it before the
|
|
2261
|
+
# key write means the key is never left applied while an inherited gateway
|
|
2262
|
+
# URL stays uncleared — the key and endpoint move together.
|
|
2263
|
+
_apply_stored_base_url(provider, stored_base_url)
|
|
2264
|
+
if os.environ.get(env_var) != stored:
|
|
2265
|
+
os.environ[env_var] = stored
|
|
2266
|
+
return True
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
|
|
2270
|
+
"""Reconcile a provider's base-URL env vars with a `/auth` credential.
|
|
2271
|
+
|
|
2272
|
+
Writes `base_url` to the canonical name and clears the alternates, or
|
|
2273
|
+
clears every name when `base_url` is `None` (reset to the provider
|
|
2274
|
+
default). See `apply_stored_credentials` for the pairing rationale.
|
|
2275
|
+
|
|
2276
|
+
When switching to a provider-native key (no `base_url`), also clears the
|
|
2277
|
+
provider's custom-headers env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) so a
|
|
2278
|
+
gateway-provisioned auth header isn't sent to the native endpoint.
|
|
2279
|
+
|
|
2280
|
+
Args:
|
|
2281
|
+
provider: Provider name.
|
|
2282
|
+
base_url: The stored endpoint, or `None` to reset to the default.
|
|
2283
|
+
"""
|
|
2284
|
+
canonical = get_base_url_env_var(provider)
|
|
2285
|
+
# Clear every name the SDK might read: the built-in alternates plus any
|
|
2286
|
+
# config-declared `base_url_env` (which extends pairing to providers
|
|
2287
|
+
# outside the hardcoded set).
|
|
2288
|
+
names = set(PROVIDER_BASE_URL_ENV.get(provider, ()))
|
|
2289
|
+
if canonical:
|
|
2290
|
+
names.add(canonical)
|
|
2291
|
+
if not names:
|
|
2292
|
+
return
|
|
2293
|
+
configured_base_url_survives = _configured_base_url_survives_env_clear(provider)
|
|
2294
|
+
for name in names:
|
|
2295
|
+
if base_url and name == canonical:
|
|
2296
|
+
os.environ[name] = base_url
|
|
2297
|
+
else:
|
|
2298
|
+
os.environ.pop(name, None)
|
|
2299
|
+
|
|
2300
|
+
# A provider SDK's custom-header env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`)
|
|
2301
|
+
# injects headers into every request. A gateway-provisioned environment
|
|
2302
|
+
# often sets it to `X-Api-Key: <gateway-key>`, which overrides the SDK's
|
|
2303
|
+
# own `api_key`-derived header. When switching to a provider-native key
|
|
2304
|
+
# (no stored `base_url`), that header must also be cleared — otherwise the
|
|
2305
|
+
# gateway key is sent to the native endpoint and rejected.
|
|
2306
|
+
custom_headers_env = PROVIDER_CUSTOM_HEADERS_ENV.get(provider)
|
|
2307
|
+
if custom_headers_env and not base_url:
|
|
2308
|
+
if not configured_base_url_survives:
|
|
2309
|
+
if os.environ.pop(custom_headers_env, None) is not None:
|
|
2310
|
+
# Log the env var name only — never its value, which carries
|
|
2311
|
+
# auth headers. Surfaces the removal for the user who set a
|
|
2312
|
+
# header deliberately for the native endpoint and later wonders
|
|
2313
|
+
# where it went.
|
|
2314
|
+
logger.info(
|
|
2315
|
+
"Cleared %s while applying a provider-native %s key",
|
|
2316
|
+
custom_headers_env,
|
|
2317
|
+
provider,
|
|
2318
|
+
)
|
|
2319
|
+
elif os.environ.get(custom_headers_env) is not None:
|
|
2320
|
+
# A provider base URL still routes (config or a prefixed env var),
|
|
2321
|
+
# so the custom-header env is deliberately kept. Log the name only —
|
|
2322
|
+
# never the value — so the retention is observable when a user later
|
|
2323
|
+
# wonders why a gateway header is still in effect after applying a
|
|
2324
|
+
# native key.
|
|
2325
|
+
logger.debug(
|
|
2326
|
+
"Kept %s: a %s base URL is still configured",
|
|
2327
|
+
custom_headers_env,
|
|
2328
|
+
provider,
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
def _configured_base_url_survives_env_clear(provider: str) -> bool:
|
|
2333
|
+
"""Return whether endpoint config still routes after plain env cleanup."""
|
|
2334
|
+
config = ModelConfig.load()
|
|
2335
|
+
provider_cfg = config.providers.get(provider)
|
|
2336
|
+
if provider_cfg and provider_cfg.get("base_url"):
|
|
2337
|
+
return True
|
|
2338
|
+
for env_var in get_base_url_env_vars(provider):
|
|
2339
|
+
if os.environ.get(f"{_ENV_PREFIX}{env_var}"):
|
|
2340
|
+
return True
|
|
2341
|
+
return False
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
def warn_on_split_credential_source(provider: str) -> None:
|
|
2345
|
+
"""Log when a provider's key and endpoint resolve from different env tiers.
|
|
2346
|
+
|
|
2347
|
+
The `DEEPAGENTS_CODE_` prefix is a *per-variable* override, not a credential
|
|
2348
|
+
bundle: setting `DEEPAGENTS_CODE_OPENAI_API_KEY` while leaving the endpoint to
|
|
2349
|
+
a plain `OPENAI_BASE_URL` makes the key resolve from the prefixed tier and the
|
|
2350
|
+
endpoint from the unprefixed one. A key and its endpoint are a coherent pair
|
|
2351
|
+
(see `PROVIDER_BASE_URL_ENV`), so a split source is a likely misconfiguration
|
|
2352
|
+
-- e.g. a provider-native key shipped to a gateway URL, or vice versa.
|
|
2353
|
+
|
|
2354
|
+
This is purely diagnostic: it never mutates `os.environ` or changes
|
|
2355
|
+
resolution. Only the env var *names* are logged, never the secret value or
|
|
2356
|
+
the URL. It is emitted at DEBUG because the `deepagents_code` package logger
|
|
2357
|
+
only attaches a handler when `DEEPAGENTS_CODE_DEBUG` is set, and DEBUG stays
|
|
2358
|
+
below `logging.lastResort`'s WARNING stderr threshold so it cannot bleed onto
|
|
2359
|
+
stderr and corrupt the Textual TUI. The `DEEPAGENTS_CODE_DEBUG` file log is
|
|
2360
|
+
where someone chasing a wrong-endpoint bug will look.
|
|
2361
|
+
|
|
2362
|
+
A `config.toml` `base_url` literal wins over env vars in `get_base_url`, so
|
|
2363
|
+
when one is set there is no env-tier split to flag and this returns early.
|
|
2364
|
+
|
|
2365
|
+
Args:
|
|
2366
|
+
provider: Provider name (e.g. `"openai"`).
|
|
2367
|
+
"""
|
|
2368
|
+
key_env = get_credential_env_var(provider)
|
|
2369
|
+
base_env = get_base_url_env_var(provider)
|
|
2370
|
+
if not key_env or not base_env:
|
|
2371
|
+
return
|
|
2372
|
+
config = ModelConfig.load()
|
|
2373
|
+
provider_cfg = config.providers.get(provider)
|
|
2374
|
+
if provider_cfg and provider_cfg.get("base_url"):
|
|
2375
|
+
return
|
|
2376
|
+
prefixed_key = f"{_ENV_PREFIX}{key_env}"
|
|
2377
|
+
prefixed_base = f"{_ENV_PREFIX}{base_env}"
|
|
2378
|
+
# Key must actually resolve from the prefixed tier (present and non-empty),
|
|
2379
|
+
# while the endpoint falls back to the plain tier: no prefixed override
|
|
2380
|
+
# present (an empty prefixed var would shadow the plain one in
|
|
2381
|
+
# `resolve_env_var`, so its mere presence means the endpoint is not "plain").
|
|
2382
|
+
key_from_prefixed = bool(os.environ.get(prefixed_key))
|
|
2383
|
+
base_from_plain = prefixed_base not in os.environ and bool(os.environ.get(base_env))
|
|
2384
|
+
if key_from_prefixed and base_from_plain:
|
|
2385
|
+
logger.debug(
|
|
2386
|
+
"Provider %s: API key resolved from %s but base URL resolved from "
|
|
2387
|
+
"the unprefixed %s. Key and endpoint came from different sources and "
|
|
2388
|
+
"may not be a matching pair. Set %s to pin the endpoint, or unset %s.",
|
|
2389
|
+
provider,
|
|
2390
|
+
prefixed_key,
|
|
2391
|
+
base_env,
|
|
2392
|
+
prefixed_base,
|
|
2393
|
+
base_env,
|
|
2394
|
+
)
|
|
2395
|
+
|
|
2396
|
+
|
|
2397
|
+
@dataclass(frozen=True)
|
|
2398
|
+
class ModelConfig:
|
|
2399
|
+
"""Parsed model configuration from `config.toml`.
|
|
2400
|
+
|
|
2401
|
+
Instances are immutable once constructed. The `providers` mapping is
|
|
2402
|
+
wrapped in `MappingProxyType` to prevent accidental mutation of the
|
|
2403
|
+
globally cached singleton returned by `load()`.
|
|
2404
|
+
"""
|
|
2405
|
+
|
|
2406
|
+
default_model: str | None = None
|
|
2407
|
+
"""The user's intentional default model (from config file `[models].default`)."""
|
|
2408
|
+
|
|
2409
|
+
recent_model: str | None = None
|
|
2410
|
+
"""The most recently switched-to model (from config file `[models].recent`)."""
|
|
2411
|
+
|
|
2412
|
+
providers: Mapping[str, ProviderConfig] = field(default_factory=dict)
|
|
2413
|
+
"""Read-only mapping of provider names to their configurations."""
|
|
2414
|
+
|
|
2415
|
+
def __post_init__(self) -> None:
|
|
2416
|
+
"""Freeze the providers dict into a read-only proxy."""
|
|
2417
|
+
if not isinstance(self.providers, MappingProxyType):
|
|
2418
|
+
object.__setattr__(self, "providers", MappingProxyType(self.providers))
|
|
2419
|
+
|
|
2420
|
+
@classmethod
|
|
2421
|
+
def load(cls, config_path: Path | None = None) -> ModelConfig:
|
|
2422
|
+
"""Load config from file.
|
|
2423
|
+
|
|
2424
|
+
When called with the default path, results are cached for the
|
|
2425
|
+
lifetime of the process. Use `clear_caches()` to reset.
|
|
2426
|
+
|
|
2427
|
+
Args:
|
|
2428
|
+
config_path: Path to config file. Defaults to ~/.deepagents/config.toml.
|
|
2429
|
+
|
|
2430
|
+
Returns:
|
|
2431
|
+
Parsed `ModelConfig` instance.
|
|
2432
|
+
Returns empty config if file is missing, unreadable, contains
|
|
2433
|
+
invalid TOML syntax, or is structurally invalid (valid TOML of
|
|
2434
|
+
the wrong shape, e.g. a scalar `[models]`).
|
|
2435
|
+
"""
|
|
2436
|
+
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
2437
|
+
is_default = config_path is None
|
|
2438
|
+
if is_default and _default_config_cache is not None:
|
|
2439
|
+
return _default_config_cache
|
|
2440
|
+
|
|
2441
|
+
if config_path is None:
|
|
2442
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
2443
|
+
|
|
2444
|
+
if not config_path.exists():
|
|
2445
|
+
fallback = cls()
|
|
2446
|
+
if is_default:
|
|
2447
|
+
_default_config_cache = fallback
|
|
2448
|
+
return fallback
|
|
2449
|
+
|
|
2450
|
+
try:
|
|
2451
|
+
with config_path.open("rb") as f:
|
|
2452
|
+
data = tomllib.load(f)
|
|
2453
|
+
models_section = data.get("models", {})
|
|
2454
|
+
config = cls(
|
|
2455
|
+
default_model=models_section.get("default"),
|
|
2456
|
+
recent_model=models_section.get("recent"),
|
|
2457
|
+
providers=models_section.get("providers", {}),
|
|
2458
|
+
)
|
|
2459
|
+
except tomllib.TOMLDecodeError as e:
|
|
2460
|
+
logger.warning(
|
|
2461
|
+
"Config file %s has invalid TOML syntax: %s. "
|
|
2462
|
+
"Ignoring config file. Fix the file or delete it to reset.",
|
|
2463
|
+
config_path,
|
|
2464
|
+
e,
|
|
2465
|
+
)
|
|
2466
|
+
config = cls()
|
|
2467
|
+
except (PermissionError, OSError) as e:
|
|
2468
|
+
logger.warning("Could not read config file %s: %s", config_path, e)
|
|
2469
|
+
config = cls()
|
|
2470
|
+
except (AttributeError, TypeError) as e:
|
|
2471
|
+
# Syntactically valid TOML can still have the wrong shape — a scalar
|
|
2472
|
+
# `[models]`, a non-table `providers` — which surfaces here as an
|
|
2473
|
+
# AttributeError from `.get(...)` or a TypeError from the dataclass
|
|
2474
|
+
# constructor. Treat it like any other unreadable config rather than
|
|
2475
|
+
# letting it crash callers (e.g. the /auth modal on Ctrl+R) that
|
|
2476
|
+
# assume load() is total and never raises.
|
|
2477
|
+
logger.warning(
|
|
2478
|
+
"Config file %s is structurally invalid: %s. "
|
|
2479
|
+
"Ignoring config file. Fix the file or delete it to reset.",
|
|
2480
|
+
config_path,
|
|
2481
|
+
e,
|
|
2482
|
+
)
|
|
2483
|
+
config = cls()
|
|
2484
|
+
|
|
2485
|
+
# Validate config consistency
|
|
2486
|
+
config._validate()
|
|
2487
|
+
|
|
2488
|
+
if is_default:
|
|
2489
|
+
_default_config_cache = config
|
|
2490
|
+
|
|
2491
|
+
return config
|
|
2492
|
+
|
|
2493
|
+
def _validate(self) -> None:
|
|
2494
|
+
"""Validate internal consistency of the config.
|
|
2495
|
+
|
|
2496
|
+
Issues warnings for invalid configurations but does not raise exceptions,
|
|
2497
|
+
allowing the app to continue with potentially degraded functionality.
|
|
2498
|
+
"""
|
|
2499
|
+
# Warn if default_model is set but doesn't use provider:model format
|
|
2500
|
+
if self.default_model and ":" not in self.default_model:
|
|
2501
|
+
logger.warning(
|
|
2502
|
+
"default_model '%s' should use provider:model format "
|
|
2503
|
+
"(e.g., 'anthropic:claude-sonnet-4-5')",
|
|
2504
|
+
self.default_model,
|
|
2505
|
+
)
|
|
2506
|
+
|
|
2507
|
+
# Warn if recent_model is set but doesn't use provider:model format
|
|
2508
|
+
if self.recent_model and ":" not in self.recent_model:
|
|
2509
|
+
logger.warning(
|
|
2510
|
+
"recent_model '%s' should use provider:model format "
|
|
2511
|
+
"(e.g., 'anthropic:claude-sonnet-4-5')",
|
|
2512
|
+
self.recent_model,
|
|
2513
|
+
)
|
|
2514
|
+
|
|
2515
|
+
# Validate enabled field type and class_path format / params references
|
|
2516
|
+
for name, provider in self.providers.items():
|
|
2517
|
+
# `enabled` originates from untyped TOML; cast to `object` so the
|
|
2518
|
+
# runtime non-bool validation below stays reachable (the TypedDict
|
|
2519
|
+
# types it as `bool`, which would otherwise mark this branch dead).
|
|
2520
|
+
enabled = cast("object", provider.get("enabled"))
|
|
2521
|
+
if enabled is not None and not isinstance(enabled, bool):
|
|
2522
|
+
logger.warning(
|
|
2523
|
+
"Provider '%s' has non-boolean 'enabled' value %r "
|
|
2524
|
+
"(expected true/false). Provider will remain visible.",
|
|
2525
|
+
name,
|
|
2526
|
+
enabled,
|
|
2527
|
+
)
|
|
2528
|
+
|
|
2529
|
+
# `display_name`/`api_key_url` also originate from untyped TOML; cast
|
|
2530
|
+
# to `object` so the runtime non-string checks stay reachable (the
|
|
2531
|
+
# TypedDict types them as `str`).
|
|
2532
|
+
display_name = cast("object", provider.get("display_name"))
|
|
2533
|
+
if display_name is not None and not isinstance(display_name, str):
|
|
2534
|
+
logger.warning(
|
|
2535
|
+
"Provider '%s' has non-string 'display_name' value %r "
|
|
2536
|
+
"(expected a string). Falling back to the default label.",
|
|
2537
|
+
name,
|
|
2538
|
+
display_name,
|
|
2539
|
+
)
|
|
2540
|
+
|
|
2541
|
+
short_name = cast("object", provider.get("short_name"))
|
|
2542
|
+
if short_name is not None and not isinstance(short_name, str):
|
|
2543
|
+
logger.warning(
|
|
2544
|
+
"Provider '%s' has non-string 'short_name' value %r "
|
|
2545
|
+
"(expected a string). Falling back to the display name.",
|
|
2546
|
+
name,
|
|
2547
|
+
short_name,
|
|
2548
|
+
)
|
|
2549
|
+
|
|
2550
|
+
api_key_url = cast("object", provider.get("api_key_url"))
|
|
2551
|
+
if api_key_url is not None and not isinstance(api_key_url, str):
|
|
2552
|
+
logger.warning(
|
|
2553
|
+
"Provider '%s' has non-string 'api_key_url' value %r "
|
|
2554
|
+
"(expected a string). Ignoring it.",
|
|
2555
|
+
name,
|
|
2556
|
+
api_key_url,
|
|
2557
|
+
)
|
|
2558
|
+
|
|
2559
|
+
class_path = provider.get("class_path")
|
|
2560
|
+
if class_path and ":" not in class_path:
|
|
2561
|
+
logger.warning(
|
|
2562
|
+
"Provider '%s' has invalid class_path '%s': "
|
|
2563
|
+
"must be in module.path:ClassName format "
|
|
2564
|
+
"(e.g., 'my_package.models:MyChatModel')",
|
|
2565
|
+
name,
|
|
2566
|
+
class_path,
|
|
2567
|
+
)
|
|
2568
|
+
|
|
2569
|
+
models = set(provider.get("models", []))
|
|
2570
|
+
|
|
2571
|
+
params = provider.get("params", {})
|
|
2572
|
+
for key, value in params.items():
|
|
2573
|
+
if isinstance(value, dict) and key not in models:
|
|
2574
|
+
logger.warning(
|
|
2575
|
+
"Provider '%s' has params for '%s' "
|
|
2576
|
+
"which is not in its models list",
|
|
2577
|
+
name,
|
|
2578
|
+
key,
|
|
2579
|
+
)
|
|
2580
|
+
|
|
2581
|
+
def is_provider_enabled(self, provider_name: str) -> bool:
|
|
2582
|
+
"""Check whether a provider should appear in the model switcher.
|
|
2583
|
+
|
|
2584
|
+
A provider is disabled when its config explicitly sets
|
|
2585
|
+
`enabled = false`. Providers not present in the config file are
|
|
2586
|
+
always considered enabled.
|
|
2587
|
+
|
|
2588
|
+
Args:
|
|
2589
|
+
provider_name: The provider to check.
|
|
2590
|
+
|
|
2591
|
+
Returns:
|
|
2592
|
+
`False` if the provider is explicitly disabled, `True` otherwise.
|
|
2593
|
+
"""
|
|
2594
|
+
provider = self.providers.get(provider_name)
|
|
2595
|
+
if not provider:
|
|
2596
|
+
return True
|
|
2597
|
+
return provider.get("enabled") is not False
|
|
2598
|
+
|
|
2599
|
+
def get_all_models(self) -> list[tuple[str, str]]:
|
|
2600
|
+
"""Get all models as `(model_name, provider_name)` tuples.
|
|
2601
|
+
|
|
2602
|
+
Returns raw config data — does not filter by `is_provider_enabled`.
|
|
2603
|
+
For the filtered set shown in the model switcher, use
|
|
2604
|
+
`get_available_models()`.
|
|
2605
|
+
|
|
2606
|
+
Returns:
|
|
2607
|
+
List of tuples containing `(model_name, provider_name)`.
|
|
2608
|
+
"""
|
|
2609
|
+
return [
|
|
2610
|
+
(model, provider_name)
|
|
2611
|
+
for provider_name, provider_config in self.providers.items()
|
|
2612
|
+
for model in provider_config.get("models", [])
|
|
2613
|
+
]
|
|
2614
|
+
|
|
2615
|
+
def get_provider_for_model(self, model_name: str) -> str | None:
|
|
2616
|
+
"""Find the provider that contains this model.
|
|
2617
|
+
|
|
2618
|
+
Returns raw config data — does not filter by `is_provider_enabled`.
|
|
2619
|
+
|
|
2620
|
+
Args:
|
|
2621
|
+
model_name: The model identifier to look up.
|
|
2622
|
+
|
|
2623
|
+
Returns:
|
|
2624
|
+
Provider name if found, None otherwise.
|
|
2625
|
+
"""
|
|
2626
|
+
for provider_name, provider_config in self.providers.items():
|
|
2627
|
+
if model_name in provider_config.get("models", []):
|
|
2628
|
+
return provider_name
|
|
2629
|
+
return None
|
|
2630
|
+
|
|
2631
|
+
def has_credentials(self, provider_name: str) -> bool | None:
|
|
2632
|
+
"""Check if credentials are available for a provider.
|
|
2633
|
+
|
|
2634
|
+
This is the config-file-driven credential check, supporting custom
|
|
2635
|
+
providers (e.g., local Ollama with no key required). For the hardcoded
|
|
2636
|
+
`PROVIDER_API_KEY_ENV`-based check used in the hot-swap path, see the
|
|
2637
|
+
module-level `has_provider_credentials()`.
|
|
2638
|
+
|
|
2639
|
+
Args:
|
|
2640
|
+
provider_name: The provider to check.
|
|
2641
|
+
|
|
2642
|
+
Returns:
|
|
2643
|
+
True if credentials are confirmed available, False if confirmed
|
|
2644
|
+
missing, or None if no `api_key_env` is configured and
|
|
2645
|
+
credential status cannot be determined.
|
|
2646
|
+
"""
|
|
2647
|
+
provider = self.providers.get(provider_name)
|
|
2648
|
+
if not provider:
|
|
2649
|
+
return False
|
|
2650
|
+
env_var = provider.get("api_key_env")
|
|
2651
|
+
if not env_var:
|
|
2652
|
+
return None # No key configured — can't verify
|
|
2653
|
+
return bool(resolve_env_var(env_var))
|
|
2654
|
+
|
|
2655
|
+
def get_base_url(self, provider_name: str) -> str | None:
|
|
2656
|
+
"""Get the configured base URL for a provider.
|
|
2657
|
+
|
|
2658
|
+
Resolution order (first match wins):
|
|
2659
|
+
|
|
2660
|
+
1. `base_url` in the provider's `config.toml` section.
|
|
2661
|
+
2. The provider's base-URL env vars via `resolve_env_var`, in provider
|
|
2662
|
+
precedence order, so `DEEPAGENTS_CODE_{VAR}` beats the plain `{VAR}`
|
|
2663
|
+
for each name — mirroring how API keys resolve. This also surfaces
|
|
2664
|
+
the value `apply_stored_credentials` bridged in from a `/auth`
|
|
2665
|
+
credential, and the gateway-provisioned URL in the default
|
|
2666
|
+
(no-override) case.
|
|
2667
|
+
3. The endpoint stored with a `/auth` credential. This is the source
|
|
2668
|
+
for providers that have no base-URL env var (e.g. an OpenAI-
|
|
2669
|
+
compatible provider like Litellm): step 2 has no name to read, so
|
|
2670
|
+
the stored endpoint is taken directly. It then reaches the model as
|
|
2671
|
+
the `base_url` constructor kwarg via
|
|
2672
|
+
`_get_provider_kwargs`, the same path a `config.toml` literal uses.
|
|
2673
|
+
For providers that *do* have an env var, the stored endpoint already
|
|
2674
|
+
arrives via step 2 (it was bridged onto the env var), so this step
|
|
2675
|
+
is a redundant — and consistent — fallback.
|
|
2676
|
+
|
|
2677
|
+
This function only *resolves* the endpoint; whether it takes effect is a
|
|
2678
|
+
separate contract owned by the provider's LangChain class. The value is
|
|
2679
|
+
delivered as the `base_url` kwarg (see `_get_provider_kwargs`), which the
|
|
2680
|
+
OpenAI/Anthropic-compatible classes accept via a Pydantic `base_url`
|
|
2681
|
+
alias. A class that names the field differently may silently
|
|
2682
|
+
ignore `base_url` — Pydantic models default to `extra="ignore"` — so for
|
|
2683
|
+
those the endpoint must be set via `params`.
|
|
2684
|
+
|
|
2685
|
+
A corrupt credential store is treated as "no stored endpoint" rather than
|
|
2686
|
+
propagating, so endpoint resolution never newly raises.
|
|
2687
|
+
|
|
2688
|
+
Args:
|
|
2689
|
+
provider_name: The provider to get base URL for.
|
|
2690
|
+
|
|
2691
|
+
Returns:
|
|
2692
|
+
Base URL if configured, None otherwise.
|
|
2693
|
+
"""
|
|
2694
|
+
provider = self.providers.get(provider_name)
|
|
2695
|
+
config_url = provider.get("base_url") if provider else None
|
|
2696
|
+
if config_url:
|
|
2697
|
+
return config_url
|
|
2698
|
+
config_env = provider.get("base_url_env") if provider else None
|
|
2699
|
+
env_vars = (
|
|
2700
|
+
(config_env,)
|
|
2701
|
+
if config_env
|
|
2702
|
+
else PROVIDER_BASE_URL_ENV.get(provider_name, ())
|
|
2703
|
+
)
|
|
2704
|
+
for env_var in env_vars:
|
|
2705
|
+
resolved = resolve_env_var(env_var)
|
|
2706
|
+
if resolved:
|
|
2707
|
+
return resolved
|
|
2708
|
+
try:
|
|
2709
|
+
return auth_store.get_stored_base_url(provider_name)
|
|
2710
|
+
except RuntimeError:
|
|
2711
|
+
return None
|
|
2712
|
+
|
|
2713
|
+
def get_api_key_env(self, provider_name: str) -> str | None:
|
|
2714
|
+
"""Get the environment variable name for a provider's API key.
|
|
2715
|
+
|
|
2716
|
+
Args:
|
|
2717
|
+
provider_name: The provider to get API key env var for.
|
|
2718
|
+
|
|
2719
|
+
Returns:
|
|
2720
|
+
Environment variable name if configured, None otherwise.
|
|
2721
|
+
"""
|
|
2722
|
+
provider = self.providers.get(provider_name)
|
|
2723
|
+
return provider.get("api_key_env") if provider else None
|
|
2724
|
+
|
|
2725
|
+
def get_provider_display_name(self, provider_name: str) -> str | None:
|
|
2726
|
+
"""Get the configured display name for a provider.
|
|
2727
|
+
|
|
2728
|
+
Args:
|
|
2729
|
+
provider_name: The provider to look up.
|
|
2730
|
+
|
|
2731
|
+
Returns:
|
|
2732
|
+
Human-readable display name if configured, None otherwise.
|
|
2733
|
+
"""
|
|
2734
|
+
provider = self.providers.get(provider_name)
|
|
2735
|
+
name = provider.get("display_name") if provider else None
|
|
2736
|
+
return name if isinstance(name, str) else None
|
|
2737
|
+
|
|
2738
|
+
def get_provider_short_name(self, provider_name: str) -> str | None:
|
|
2739
|
+
"""Get the configured compact brand name for a provider.
|
|
2740
|
+
|
|
2741
|
+
Args:
|
|
2742
|
+
provider_name: The provider to look up.
|
|
2743
|
+
|
|
2744
|
+
Returns:
|
|
2745
|
+
Compact brand name if configured, None otherwise.
|
|
2746
|
+
"""
|
|
2747
|
+
provider = self.providers.get(provider_name)
|
|
2748
|
+
name = provider.get("short_name") if provider else None
|
|
2749
|
+
return name if isinstance(name, str) else None
|
|
2750
|
+
|
|
2751
|
+
def get_provider_api_key_url(self, provider_name: str) -> str | None:
|
|
2752
|
+
"""Get the configured API-key management URL for a provider.
|
|
2753
|
+
|
|
2754
|
+
Args:
|
|
2755
|
+
provider_name: The provider to look up.
|
|
2756
|
+
|
|
2757
|
+
Returns:
|
|
2758
|
+
API-key management URL if configured, None otherwise.
|
|
2759
|
+
"""
|
|
2760
|
+
provider = self.providers.get(provider_name)
|
|
2761
|
+
url = provider.get("api_key_url") if provider else None
|
|
2762
|
+
return url if isinstance(url, str) else None
|
|
2763
|
+
|
|
2764
|
+
def get_base_url_env(self, provider_name: str) -> str | None:
|
|
2765
|
+
"""Get the environment variable name for a provider's base URL.
|
|
2766
|
+
|
|
2767
|
+
Args:
|
|
2768
|
+
provider_name: The provider to get the base-URL env var for.
|
|
2769
|
+
|
|
2770
|
+
Returns:
|
|
2771
|
+
Environment variable name if configured, None otherwise.
|
|
2772
|
+
"""
|
|
2773
|
+
provider = self.providers.get(provider_name)
|
|
2774
|
+
return provider.get("base_url_env") if provider else None
|
|
2775
|
+
|
|
2776
|
+
def get_class_path(self, provider_name: str) -> str | None:
|
|
2777
|
+
"""Get the custom class path for a provider.
|
|
2778
|
+
|
|
2779
|
+
Args:
|
|
2780
|
+
provider_name: The provider to look up.
|
|
2781
|
+
|
|
2782
|
+
Returns:
|
|
2783
|
+
Class path in `module.path:ClassName` format, or None.
|
|
2784
|
+
"""
|
|
2785
|
+
provider = self.providers.get(provider_name)
|
|
2786
|
+
return provider.get("class_path") if provider else None
|
|
2787
|
+
|
|
2788
|
+
def get_kwargs(
|
|
2789
|
+
self, provider_name: str, *, model_name: str | None = None
|
|
2790
|
+
) -> dict[str, Any]:
|
|
2791
|
+
"""Get extra constructor kwargs for a provider.
|
|
2792
|
+
|
|
2793
|
+
Reads the `params` table from the provider config. Flat keys are
|
|
2794
|
+
provider-wide defaults; model-keyed sub-tables are per-model
|
|
2795
|
+
overrides that shallow-merge on top (model wins on conflict).
|
|
2796
|
+
|
|
2797
|
+
Args:
|
|
2798
|
+
provider_name: The provider to look up.
|
|
2799
|
+
model_name: Optional model name for per-model overrides.
|
|
2800
|
+
|
|
2801
|
+
Returns:
|
|
2802
|
+
Dictionary of extra kwargs (empty if none configured).
|
|
2803
|
+
"""
|
|
2804
|
+
provider = self.providers.get(provider_name)
|
|
2805
|
+
if not provider:
|
|
2806
|
+
return {}
|
|
2807
|
+
params = provider.get("params", {})
|
|
2808
|
+
result = {k: v for k, v in params.items() if not isinstance(v, dict)}
|
|
2809
|
+
if model_name:
|
|
2810
|
+
overrides = params.get(model_name)
|
|
2811
|
+
if isinstance(overrides, dict):
|
|
2812
|
+
result.update(overrides)
|
|
2813
|
+
return result
|
|
2814
|
+
|
|
2815
|
+
def get_profile_overrides(
|
|
2816
|
+
self, provider_name: str, *, model_name: str | None = None
|
|
2817
|
+
) -> dict[str, Any]:
|
|
2818
|
+
"""Get profile overrides for a provider.
|
|
2819
|
+
|
|
2820
|
+
Reads the `profile` table from the provider config. Flat keys are
|
|
2821
|
+
provider-wide defaults; model-keyed sub-tables are per-model overrides
|
|
2822
|
+
that shallow-merge on top (model wins on conflict).
|
|
2823
|
+
|
|
2824
|
+
Args:
|
|
2825
|
+
provider_name: The provider to look up.
|
|
2826
|
+
model_name: Optional model name for per-model overrides.
|
|
2827
|
+
|
|
2828
|
+
Returns:
|
|
2829
|
+
Dictionary of profile overrides (empty if none configured).
|
|
2830
|
+
"""
|
|
2831
|
+
provider = self.providers.get(provider_name)
|
|
2832
|
+
if not provider:
|
|
2833
|
+
return {}
|
|
2834
|
+
profile = provider.get("profile", {})
|
|
2835
|
+
result = {k: v for k, v in profile.items() if not isinstance(v, dict)}
|
|
2836
|
+
if model_name:
|
|
2837
|
+
overrides = profile.get(model_name)
|
|
2838
|
+
if isinstance(overrides, dict):
|
|
2839
|
+
result.update(overrides)
|
|
2840
|
+
return result
|
|
2841
|
+
|
|
2842
|
+
|
|
2843
|
+
def _save_toml_field(
|
|
2844
|
+
section: str,
|
|
2845
|
+
field: str,
|
|
2846
|
+
value: str,
|
|
2847
|
+
config_path: Path | None = None,
|
|
2848
|
+
) -> bool:
|
|
2849
|
+
"""Read-modify-write a `[section].<field>` key in the config file.
|
|
2850
|
+
|
|
2851
|
+
Args:
|
|
2852
|
+
section: TOML table name (e.g., `'models'`, `'agents'`).
|
|
2853
|
+
field: Key within the table (e.g., `'default'`, `'recent'`).
|
|
2854
|
+
value: String value to persist.
|
|
2855
|
+
config_path: Path to config file.
|
|
2856
|
+
|
|
2857
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
2858
|
+
|
|
2859
|
+
Returns:
|
|
2860
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
2861
|
+
"""
|
|
2862
|
+
if config_path is None:
|
|
2863
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
2864
|
+
|
|
2865
|
+
try:
|
|
2866
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2867
|
+
|
|
2868
|
+
# Read existing config or start fresh
|
|
2869
|
+
if config_path.exists():
|
|
2870
|
+
with config_path.open("rb") as f:
|
|
2871
|
+
data = tomllib.load(f)
|
|
2872
|
+
else:
|
|
2873
|
+
data = {}
|
|
2874
|
+
|
|
2875
|
+
if section not in data:
|
|
2876
|
+
data[section] = {}
|
|
2877
|
+
data[section][field] = value
|
|
2878
|
+
|
|
2879
|
+
# Write to temp file then rename to prevent corruption if write is interrupted
|
|
2880
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
2881
|
+
try:
|
|
2882
|
+
with os.fdopen(fd, "wb") as f:
|
|
2883
|
+
tomli_w.dump(data, f)
|
|
2884
|
+
Path(tmp_path).replace(config_path)
|
|
2885
|
+
except BaseException:
|
|
2886
|
+
# Clean up temp file on any failure
|
|
2887
|
+
with contextlib.suppress(OSError):
|
|
2888
|
+
Path(tmp_path).unlink()
|
|
2889
|
+
raise
|
|
2890
|
+
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
2891
|
+
# `TypeError` covers `tomli_w.dump` rejecting a non-serializable
|
|
2892
|
+
# payload; `ValueError` covers things like `os.fdopen` on a
|
|
2893
|
+
# closed fd. Folding them in keeps the `bool` contract intact for
|
|
2894
|
+
# the UI branches that toggle on the return value.
|
|
2895
|
+
logger.exception("Could not save %s.%s preference", section, field)
|
|
2896
|
+
return False
|
|
2897
|
+
else:
|
|
2898
|
+
# Invalidate config cache so the next load() picks up the change.
|
|
2899
|
+
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
2900
|
+
_default_config_cache = None
|
|
2901
|
+
return True
|
|
2902
|
+
|
|
2903
|
+
|
|
2904
|
+
def _save_model_field(
|
|
2905
|
+
field: str, model_spec: str, config_path: Path | None = None
|
|
2906
|
+
) -> bool:
|
|
2907
|
+
"""Read-modify-write a `[models].<field>` key in the config file.
|
|
2908
|
+
|
|
2909
|
+
Thin wrapper around `_save_toml_field` for the `[models]` section.
|
|
2910
|
+
|
|
2911
|
+
Args:
|
|
2912
|
+
field: Key name under the `[models]` table (e.g., `'default'` or `'recent'`).
|
|
2913
|
+
model_spec: The model to save in `provider:model` format.
|
|
2914
|
+
config_path: Path to config file.
|
|
2915
|
+
|
|
2916
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
2917
|
+
|
|
2918
|
+
Returns:
|
|
2919
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
2920
|
+
"""
|
|
2921
|
+
return _save_toml_field("models", field, model_spec, config_path)
|
|
2922
|
+
|
|
2923
|
+
|
|
2924
|
+
def save_default_model(model_spec: str, config_path: Path | None = None) -> bool:
|
|
2925
|
+
"""Update the default model in config file.
|
|
2926
|
+
|
|
2927
|
+
Reads existing config (if any), updates `[models].default`, and writes
|
|
2928
|
+
back using proper TOML serialization.
|
|
2929
|
+
|
|
2930
|
+
Args:
|
|
2931
|
+
model_spec: The model to set as default in `provider:model` format.
|
|
2932
|
+
config_path: Path to config file.
|
|
2933
|
+
|
|
2934
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
2935
|
+
|
|
2936
|
+
Returns:
|
|
2937
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
2938
|
+
|
|
2939
|
+
Note:
|
|
2940
|
+
This function does not preserve comments in the config file.
|
|
2941
|
+
"""
|
|
2942
|
+
return _save_model_field("default", model_spec, config_path)
|
|
2943
|
+
|
|
2944
|
+
|
|
2945
|
+
def clear_default_model(config_path: Path | None = None) -> bool:
|
|
2946
|
+
"""Remove the default model from the config file.
|
|
2947
|
+
|
|
2948
|
+
Deletes the `[models].default` key so that future launches fall back to
|
|
2949
|
+
`[models].recent` or environment auto-detection.
|
|
2950
|
+
|
|
2951
|
+
Args:
|
|
2952
|
+
config_path: Path to config file.
|
|
2953
|
+
|
|
2954
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
2955
|
+
|
|
2956
|
+
Returns:
|
|
2957
|
+
True if the key was removed (or was already absent), False on I/O error.
|
|
2958
|
+
"""
|
|
2959
|
+
if config_path is None:
|
|
2960
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
2961
|
+
|
|
2962
|
+
if not config_path.exists():
|
|
2963
|
+
return True # Nothing to clear
|
|
2964
|
+
|
|
2965
|
+
try:
|
|
2966
|
+
with config_path.open("rb") as f:
|
|
2967
|
+
data = tomllib.load(f)
|
|
2968
|
+
|
|
2969
|
+
models_section = data.get("models")
|
|
2970
|
+
if not isinstance(models_section, dict) or "default" not in models_section:
|
|
2971
|
+
return True # Already absent
|
|
2972
|
+
|
|
2973
|
+
del models_section["default"]
|
|
2974
|
+
|
|
2975
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
2976
|
+
try:
|
|
2977
|
+
with os.fdopen(fd, "wb") as f:
|
|
2978
|
+
tomli_w.dump(data, f)
|
|
2979
|
+
Path(tmp_path).replace(config_path)
|
|
2980
|
+
except BaseException:
|
|
2981
|
+
with contextlib.suppress(OSError):
|
|
2982
|
+
Path(tmp_path).unlink()
|
|
2983
|
+
raise
|
|
2984
|
+
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
2985
|
+
# See `_save_toml_field` for why `TypeError` / `ValueError` are
|
|
2986
|
+
# folded into the bool return contract.
|
|
2987
|
+
logger.exception("Could not clear default model preference")
|
|
2988
|
+
return False
|
|
2989
|
+
else:
|
|
2990
|
+
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
2991
|
+
_default_config_cache = None
|
|
2992
|
+
return True
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def is_warning_suppressed(key: str, config_path: Path | None = None) -> bool:
|
|
2996
|
+
"""Check if a warning key is suppressed in the config file.
|
|
2997
|
+
|
|
2998
|
+
Reads the `[warnings].suppress` list from `config.toml` and checks
|
|
2999
|
+
whether `key` is present.
|
|
3000
|
+
|
|
3001
|
+
Args:
|
|
3002
|
+
key: Warning identifier to check (e.g., `'ripgrep'`).
|
|
3003
|
+
config_path: Path to config file.
|
|
3004
|
+
|
|
3005
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3006
|
+
|
|
3007
|
+
Returns:
|
|
3008
|
+
`True` if the warning is suppressed, `False` otherwise (including
|
|
3009
|
+
when the file is missing, unreadable, or has no
|
|
3010
|
+
`[warnings]` section).
|
|
3011
|
+
"""
|
|
3012
|
+
if config_path is None:
|
|
3013
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3014
|
+
|
|
3015
|
+
try:
|
|
3016
|
+
if not config_path.exists():
|
|
3017
|
+
return False
|
|
3018
|
+
with config_path.open("rb") as f:
|
|
3019
|
+
data = tomllib.load(f)
|
|
3020
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3021
|
+
logger.debug(
|
|
3022
|
+
"Could not read config file %s for warning suppression check",
|
|
3023
|
+
config_path,
|
|
3024
|
+
exc_info=True,
|
|
3025
|
+
)
|
|
3026
|
+
return False
|
|
3027
|
+
|
|
3028
|
+
suppress_list = data.get("warnings", {}).get("suppress", [])
|
|
3029
|
+
if not isinstance(suppress_list, list):
|
|
3030
|
+
logger.debug(
|
|
3031
|
+
"[warnings].suppress in %s should be a list, got %s",
|
|
3032
|
+
config_path,
|
|
3033
|
+
type(suppress_list).__name__,
|
|
3034
|
+
)
|
|
3035
|
+
return False
|
|
3036
|
+
return key in suppress_list
|
|
3037
|
+
|
|
3038
|
+
|
|
3039
|
+
def suppress_warning(key: str, config_path: Path | None = None) -> bool:
|
|
3040
|
+
"""Add a warning key to the suppression list in the config file.
|
|
3041
|
+
|
|
3042
|
+
Reads existing config (if any), adds `key` to `[warnings].suppress`,
|
|
3043
|
+
and writes back using atomic temp-file rename. Deduplicates entries.
|
|
3044
|
+
|
|
3045
|
+
Args:
|
|
3046
|
+
key: Warning identifier to suppress (e.g., `'ripgrep'`).
|
|
3047
|
+
config_path: Path to config file.
|
|
3048
|
+
|
|
3049
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3050
|
+
|
|
3051
|
+
Returns:
|
|
3052
|
+
`True` if save succeeded, `False` if it failed due to I/O errors.
|
|
3053
|
+
"""
|
|
3054
|
+
if config_path is None:
|
|
3055
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3056
|
+
|
|
3057
|
+
try:
|
|
3058
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3059
|
+
|
|
3060
|
+
if config_path.exists():
|
|
3061
|
+
with config_path.open("rb") as f:
|
|
3062
|
+
data = tomllib.load(f)
|
|
3063
|
+
else:
|
|
3064
|
+
data = {}
|
|
3065
|
+
|
|
3066
|
+
if "warnings" not in data:
|
|
3067
|
+
data["warnings"] = {}
|
|
3068
|
+
suppress_list = data["warnings"].get("suppress", [])
|
|
3069
|
+
if not isinstance(suppress_list, list):
|
|
3070
|
+
logger.debug(
|
|
3071
|
+
"[warnings].suppress in %s should be a list, got %s",
|
|
3072
|
+
config_path,
|
|
3073
|
+
type(suppress_list).__name__,
|
|
3074
|
+
)
|
|
3075
|
+
suppress_list = []
|
|
3076
|
+
if key not in suppress_list:
|
|
3077
|
+
suppress_list.append(key)
|
|
3078
|
+
data["warnings"]["suppress"] = suppress_list
|
|
3079
|
+
|
|
3080
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3081
|
+
try:
|
|
3082
|
+
with os.fdopen(fd, "wb") as f:
|
|
3083
|
+
tomli_w.dump(data, f)
|
|
3084
|
+
Path(tmp_path).replace(config_path)
|
|
3085
|
+
except BaseException:
|
|
3086
|
+
with contextlib.suppress(OSError):
|
|
3087
|
+
Path(tmp_path).unlink()
|
|
3088
|
+
raise
|
|
3089
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3090
|
+
logger.exception("Could not save warning suppression for '%s'", key)
|
|
3091
|
+
return False
|
|
3092
|
+
return True
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def unsuppress_warning(key: str, config_path: Path | None = None) -> bool:
|
|
3096
|
+
"""Remove a warning key from the suppression list in the config file.
|
|
3097
|
+
|
|
3098
|
+
Reads existing config (if any), removes `key` from `[warnings].suppress`,
|
|
3099
|
+
and writes back using atomic temp-file rename. No-op if the key is not
|
|
3100
|
+
present or the file does not exist.
|
|
3101
|
+
|
|
3102
|
+
Args:
|
|
3103
|
+
key: Warning identifier to unsuppress (e.g., `'ripgrep'`).
|
|
3104
|
+
config_path: Path to config file.
|
|
3105
|
+
|
|
3106
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3107
|
+
|
|
3108
|
+
Returns:
|
|
3109
|
+
`True` if save succeeded, `False` if it failed due to I/O errors.
|
|
3110
|
+
"""
|
|
3111
|
+
if config_path is None:
|
|
3112
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3113
|
+
|
|
3114
|
+
try:
|
|
3115
|
+
if not config_path.exists():
|
|
3116
|
+
return True # nothing to remove
|
|
3117
|
+
|
|
3118
|
+
with config_path.open("rb") as f:
|
|
3119
|
+
data = tomllib.load(f)
|
|
3120
|
+
|
|
3121
|
+
suppress_list = data.get("warnings", {}).get("suppress", [])
|
|
3122
|
+
if not isinstance(suppress_list, list):
|
|
3123
|
+
logger.debug(
|
|
3124
|
+
"[warnings].suppress in %s should be a list, got %s",
|
|
3125
|
+
config_path,
|
|
3126
|
+
type(suppress_list).__name__,
|
|
3127
|
+
)
|
|
3128
|
+
return True # treat as nothing to remove
|
|
3129
|
+
if key not in suppress_list:
|
|
3130
|
+
return True # already unsuppressed
|
|
3131
|
+
|
|
3132
|
+
suppress_list.remove(key)
|
|
3133
|
+
data.setdefault("warnings", {})["suppress"] = suppress_list
|
|
3134
|
+
|
|
3135
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3136
|
+
try:
|
|
3137
|
+
with os.fdopen(fd, "wb") as f:
|
|
3138
|
+
tomli_w.dump(data, f)
|
|
3139
|
+
Path(tmp_path).replace(config_path)
|
|
3140
|
+
except BaseException:
|
|
3141
|
+
with contextlib.suppress(OSError):
|
|
3142
|
+
Path(tmp_path).unlink()
|
|
3143
|
+
raise
|
|
3144
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3145
|
+
logger.exception("Could not remove warning suppression for '%s'", key)
|
|
3146
|
+
return False
|
|
3147
|
+
return True
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
@dataclass(frozen=True)
|
|
3151
|
+
class McpServerTrustLists:
|
|
3152
|
+
"""User-level allow/deny lists for project MCP servers, by server name.
|
|
3153
|
+
|
|
3154
|
+
Sourced only from the user's own configuration — the home `config.toml`, the
|
|
3155
|
+
global `~/.deepagents/.env`, and shell-exported env — never from a repo, so a
|
|
3156
|
+
committed `.mcp.json` cannot self-approve. A committed *project* `.env` is
|
|
3157
|
+
specifically prevented from setting the env forms of these lists (see
|
|
3158
|
+
`config._PROJECT_DOTENV_DENIED_ENV_KEYS`). See `load_mcp_server_trust_lists`.
|
|
3159
|
+
|
|
3160
|
+
The "reject wins" invariant — a name in both lists is only rejected — is
|
|
3161
|
+
enforced in `__post_init__`, so every instance is disjoint no matter how it
|
|
3162
|
+
was constructed; callers need not pre-subtract.
|
|
3163
|
+
"""
|
|
3164
|
+
|
|
3165
|
+
enabled: frozenset[str]
|
|
3166
|
+
"""Server names pre-approved to load from an untrusted project config."""
|
|
3167
|
+
|
|
3168
|
+
disabled: frozenset[str]
|
|
3169
|
+
"""Server names always rejected; reject wins over `enabled` and over trust."""
|
|
3170
|
+
|
|
3171
|
+
read_error: str | None = field(default=None, compare=False)
|
|
3172
|
+
"""Non-`None` when the user's `config.toml` existed but its trust policy
|
|
3173
|
+
could not be fully read: the file was unreadable/unparseable, its `[mcp]`
|
|
3174
|
+
value was not a table, or its `disabled_project_servers` was a wrong type
|
|
3175
|
+
that could not be interpreted as a deny list. Callers must treat this as
|
|
3176
|
+
fail-closed (do not grant whole-config project trust) and surface it, rather
|
|
3177
|
+
than proceeding with a deny list that may not have loaded — use `load_failed`
|
|
3178
|
+
for that check. Note the resolved `enabled`/`disabled` sets are not
|
|
3179
|
+
necessarily empty here: names from a still-readable source (the env vars)
|
|
3180
|
+
continue to apply. Excluded from equality so a failed load still compares
|
|
3181
|
+
equal to empty lists for tests that only care about the resolved names."""
|
|
3182
|
+
|
|
3183
|
+
def __post_init__(self) -> None:
|
|
3184
|
+
"""Enforce reject precedence by removing disabled names from enabled.
|
|
3185
|
+
|
|
3186
|
+
A rejected name must never survive in `enabled`, whatever the caller
|
|
3187
|
+
passed, so a future allow-first consumer can't be tricked into loading
|
|
3188
|
+
a denied server. Frozen dataclass, so assign via `object.__setattr__`.
|
|
3189
|
+
"""
|
|
3190
|
+
if self.enabled & self.disabled:
|
|
3191
|
+
object.__setattr__(self, "enabled", self.enabled - self.disabled)
|
|
3192
|
+
|
|
3193
|
+
@property
|
|
3194
|
+
def load_failed(self) -> bool:
|
|
3195
|
+
"""Whether the user's trust policy failed to load (see `read_error`).
|
|
3196
|
+
|
|
3197
|
+
Callers gating on trust MUST check this and fail closed: a failed load
|
|
3198
|
+
means a configured deny may be missing, so whole-config project trust
|
|
3199
|
+
must not be honored. Named so the fail-closed contract is discoverable
|
|
3200
|
+
rather than resting on every caller remembering the `read_error`
|
|
3201
|
+
sentinel.
|
|
3202
|
+
"""
|
|
3203
|
+
return self.read_error is not None
|
|
3204
|
+
|
|
3205
|
+
|
|
3206
|
+
def _parse_csv_env(name: str) -> list[str] | None:
|
|
3207
|
+
"""Parse a comma-separated env var into a list of trimmed, non-empty names.
|
|
3208
|
+
|
|
3209
|
+
Returns:
|
|
3210
|
+
The parsed list when the variable is set (possibly empty after
|
|
3211
|
+
trimming), or `None` when the variable is unset so callers can
|
|
3212
|
+
distinguish "unset, fall back to TOML" from "set but empty".
|
|
3213
|
+
"""
|
|
3214
|
+
raw = os.environ.get(name)
|
|
3215
|
+
if raw is None:
|
|
3216
|
+
return None
|
|
3217
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
3218
|
+
|
|
3219
|
+
|
|
3220
|
+
def _toml_str_list(
|
|
3221
|
+
value: object, *, key: str, config_path: Path
|
|
3222
|
+
) -> tuple[list[str], bool]:
|
|
3223
|
+
"""Coerce a raw TOML value into a list of trimmed, non-empty server names.
|
|
3224
|
+
|
|
3225
|
+
A bare string is *split on commas* (e.g. `disabled_project_servers = "a, b"`
|
|
3226
|
+
yields `["a", "b"]`), so a scalar written in the TOML parses identically to
|
|
3227
|
+
the comma-separated env form in `_parse_csv_env` — the two forms can never
|
|
3228
|
+
silently diverge into one bogus `"a, b"` token that matches no server. Non-
|
|
3229
|
+
string list elements are dropped (with a log) while the surrounding valid
|
|
3230
|
+
names survive. A genuinely wrong type (number, table, bool) cannot be
|
|
3231
|
+
interpreted as names at all: it yields an empty list *and* flags `malformed`,
|
|
3232
|
+
so a caller enforcing a deny list can fail closed rather than silently drop
|
|
3233
|
+
the rejection.
|
|
3234
|
+
|
|
3235
|
+
Args:
|
|
3236
|
+
value: The raw value read from the `[mcp]` table (or `None` when the
|
|
3237
|
+
key is absent).
|
|
3238
|
+
key: The TOML key name, used only for log context.
|
|
3239
|
+
config_path: The config file the value came from, for log context.
|
|
3240
|
+
|
|
3241
|
+
Returns:
|
|
3242
|
+
`(names, malformed)`. `names` are the trimmed, non-empty server names.
|
|
3243
|
+
`malformed` is `True` only when `value` is present but neither a
|
|
3244
|
+
string nor a list (so it could not be read as names); it is `False`
|
|
3245
|
+
for an absent value, a string, or any list — even one whose non-
|
|
3246
|
+
string elements were dropped.
|
|
3247
|
+
"""
|
|
3248
|
+
if value is None:
|
|
3249
|
+
return [], False
|
|
3250
|
+
if isinstance(value, str):
|
|
3251
|
+
# Split on commas so a bare string parses exactly like the env form; a
|
|
3252
|
+
# single name with no comma still yields a one-element list.
|
|
3253
|
+
return [item.strip() for item in value.split(",") if item.strip()], False
|
|
3254
|
+
if not isinstance(value, list):
|
|
3255
|
+
logger.warning(
|
|
3256
|
+
"[mcp].%s in %s should be a list of strings, got %s; ignoring it",
|
|
3257
|
+
key,
|
|
3258
|
+
config_path,
|
|
3259
|
+
type(value).__name__,
|
|
3260
|
+
)
|
|
3261
|
+
return [], True
|
|
3262
|
+
result: list[str] = []
|
|
3263
|
+
discarded = 0
|
|
3264
|
+
for item in value:
|
|
3265
|
+
if isinstance(item, str) and item.strip():
|
|
3266
|
+
result.append(item.strip())
|
|
3267
|
+
else:
|
|
3268
|
+
discarded += 1
|
|
3269
|
+
if discarded:
|
|
3270
|
+
logger.warning(
|
|
3271
|
+
"[mcp].%s in %s: ignored %d non-string or empty entr%s",
|
|
3272
|
+
key,
|
|
3273
|
+
config_path,
|
|
3274
|
+
discarded,
|
|
3275
|
+
"y" if discarded == 1 else "ies",
|
|
3276
|
+
)
|
|
3277
|
+
return result, False
|
|
3278
|
+
|
|
3279
|
+
|
|
3280
|
+
def load_mcp_server_trust_lists(
|
|
3281
|
+
config_path: Path | None = None,
|
|
3282
|
+
) -> McpServerTrustLists:
|
|
3283
|
+
"""Load per-server project MCP allow/deny lists from user-level config.
|
|
3284
|
+
|
|
3285
|
+
Security boundary: this reads the `[mcp]` table only from the user-level
|
|
3286
|
+
`config.toml` (`DEFAULT_CONFIG_PATH`, i.e. `~/.deepagents/config.toml`) and
|
|
3287
|
+
the `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` /
|
|
3288
|
+
`DEEPAGENTS_CODE_DISABLED_PROJECT_MCP_SERVERS` process env vars — never from
|
|
3289
|
+
a project's `.mcp.json` or any repo-committed file. There is no
|
|
3290
|
+
project-level `config.toml` discovery, so an attacker who commits a
|
|
3291
|
+
malicious `.mcp.json` plus an in-repo config cannot pre-approve their own
|
|
3292
|
+
servers; the approval must live in the user's home config. This mirrors
|
|
3293
|
+
Claude Code's "untrusted folder → only non-checked-in settings" rule.
|
|
3294
|
+
|
|
3295
|
+
Source resolution differs by list, matching each one's security direction:
|
|
3296
|
+
|
|
3297
|
+
- `enabled` (permissive): the env var, when set, *replaces* the TOML list
|
|
3298
|
+
(env-beats-config, as elsewhere). Clearing it via an empty env value is
|
|
3299
|
+
fail-closed — it only ever pre-approves fewer servers.
|
|
3300
|
+
- `disabled` (restrictive): the env var *unions* with the TOML list — denies
|
|
3301
|
+
accumulate and a lower-effort source can never silently empty a deny
|
|
3302
|
+
entry set in the other, which would be a fail-open. There is
|
|
3303
|
+
deliberately no way to *remove* a configured deny via env.
|
|
3304
|
+
|
|
3305
|
+
Rejection wins: a name appearing in both the enabled and disabled result is
|
|
3306
|
+
reported only in `disabled`.
|
|
3307
|
+
|
|
3308
|
+
Args:
|
|
3309
|
+
config_path: Config file to read. Defaults to `DEFAULT_CONFIG_PATH`;
|
|
3310
|
+
callers should not point this at a project path — doing so would
|
|
3311
|
+
defeat the boundary above.
|
|
3312
|
+
|
|
3313
|
+
Returns:
|
|
3314
|
+
The resolved `McpServerTrustLists`. A missing file yields empty lists
|
|
3315
|
+
(the normal "unset" case). `read_error` is set (so callers can fail
|
|
3316
|
+
closed instead of treating a broken config as "nothing denied") when
|
|
3317
|
+
the file exists but cannot be read/parsed, when `[mcp]` is not a
|
|
3318
|
+
table, or when `disabled_project_servers` is a wrong type that cannot
|
|
3319
|
+
be read as a deny list; env-sourced names still apply in that case.
|
|
3320
|
+
"""
|
|
3321
|
+
if config_path is None:
|
|
3322
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3323
|
+
|
|
3324
|
+
toml_enabled: list[str] = []
|
|
3325
|
+
toml_disabled: list[str] = []
|
|
3326
|
+
read_error: str | None = None
|
|
3327
|
+
try:
|
|
3328
|
+
if config_path.exists():
|
|
3329
|
+
with config_path.open("rb") as f:
|
|
3330
|
+
data = tomllib.load(f)
|
|
3331
|
+
mcp_section = data.get("mcp", {})
|
|
3332
|
+
if isinstance(mcp_section, dict):
|
|
3333
|
+
# A wrong-typed `enabled` value degrades to an empty allowlist:
|
|
3334
|
+
# approving nothing extra is already fail-closed, so the
|
|
3335
|
+
# `malformed` flag is intentionally ignored here.
|
|
3336
|
+
toml_enabled, _ = _toml_str_list(
|
|
3337
|
+
mcp_section.get("enabled_project_servers"),
|
|
3338
|
+
key="enabled_project_servers",
|
|
3339
|
+
config_path=config_path,
|
|
3340
|
+
)
|
|
3341
|
+
toml_disabled, disabled_malformed = _toml_str_list(
|
|
3342
|
+
mcp_section.get("disabled_project_servers"),
|
|
3343
|
+
key="disabled_project_servers",
|
|
3344
|
+
config_path=config_path,
|
|
3345
|
+
)
|
|
3346
|
+
if disabled_malformed:
|
|
3347
|
+
# A wrong-typed deny list cannot be read, so proceeding as
|
|
3348
|
+
# if nothing were denied would be a fail-open. Surface it and
|
|
3349
|
+
# fail closed, mirroring the unreadable-file path below.
|
|
3350
|
+
read_error = (
|
|
3351
|
+
f"[mcp].disabled_project_servers in {config_path} must be "
|
|
3352
|
+
"a list of strings; refusing to proceed with an "
|
|
3353
|
+
"unenforced deny list"
|
|
3354
|
+
)
|
|
3355
|
+
else:
|
|
3356
|
+
# An `[mcp]` value that is not a table means the deny list is
|
|
3357
|
+
# unreadable too; fail closed rather than leave it unenforced.
|
|
3358
|
+
read_error = (
|
|
3359
|
+
f"[mcp] in {config_path} must be a table, got "
|
|
3360
|
+
f"{type(mcp_section).__name__}"
|
|
3361
|
+
)
|
|
3362
|
+
logger.warning(
|
|
3363
|
+
"[mcp] in %s should be a table, got %s; treating project "
|
|
3364
|
+
"configs as untrusted",
|
|
3365
|
+
config_path,
|
|
3366
|
+
type(mcp_section).__name__,
|
|
3367
|
+
)
|
|
3368
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
3369
|
+
# The file exists but is unreadable/unparseable. Record it so callers
|
|
3370
|
+
# fail closed rather than silently proceeding with an empty deny list.
|
|
3371
|
+
read_error = f"Could not read MCP trust lists from {config_path}: {exc}"
|
|
3372
|
+
logger.warning(
|
|
3373
|
+
"Could not read %s for MCP server trust lists; treating project "
|
|
3374
|
+
"configs as untrusted",
|
|
3375
|
+
config_path,
|
|
3376
|
+
exc_info=True,
|
|
3377
|
+
)
|
|
3378
|
+
|
|
3379
|
+
env_enabled = _parse_csv_env(_env_vars.ENABLED_PROJECT_MCP_SERVERS)
|
|
3380
|
+
env_disabled = _parse_csv_env(_env_vars.DISABLED_PROJECT_MCP_SERVERS)
|
|
3381
|
+
|
|
3382
|
+
# Enabled: env replaces TOML. Disabled: env unions with TOML (denies
|
|
3383
|
+
# accumulate; env can add a deny but never clear a configured one).
|
|
3384
|
+
enabled = frozenset(env_enabled if env_enabled is not None else toml_enabled)
|
|
3385
|
+
disabled = frozenset(toml_disabled) | frozenset(env_disabled or ())
|
|
3386
|
+
# Reject precedence (a name in both lists ends up only in `disabled`) is
|
|
3387
|
+
# enforced by `McpServerTrustLists.__post_init__`, so no subtraction here.
|
|
3388
|
+
return McpServerTrustLists(
|
|
3389
|
+
enabled=enabled, disabled=disabled, read_error=read_error
|
|
3390
|
+
)
|
|
3391
|
+
|
|
3392
|
+
|
|
3393
|
+
THREAD_COLUMN_DEFAULTS: dict[str, bool] = {
|
|
3394
|
+
"thread_id": False,
|
|
3395
|
+
"messages": True,
|
|
3396
|
+
"created_at": True,
|
|
3397
|
+
"updated_at": True,
|
|
3398
|
+
"git_branch": False,
|
|
3399
|
+
"cwd": False,
|
|
3400
|
+
"initial_prompt": True,
|
|
3401
|
+
"agent_name": False,
|
|
3402
|
+
}
|
|
3403
|
+
"""Default visibility for thread selector columns."""
|
|
3404
|
+
|
|
3405
|
+
|
|
3406
|
+
class ThreadConfig(NamedTuple):
|
|
3407
|
+
"""Coalesced thread-selector configuration read from a single TOML parse."""
|
|
3408
|
+
|
|
3409
|
+
columns: dict[str, bool]
|
|
3410
|
+
"""Column visibility settings."""
|
|
3411
|
+
|
|
3412
|
+
relative_time: bool
|
|
3413
|
+
"""Whether to display timestamps as relative time."""
|
|
3414
|
+
|
|
3415
|
+
sort_order: str
|
|
3416
|
+
"""`'updated_at'` or `'created_at'`."""
|
|
3417
|
+
|
|
3418
|
+
scope: str
|
|
3419
|
+
"""`'cwd'` (current working directory) or `'all'` (all directories)."""
|
|
3420
|
+
|
|
3421
|
+
|
|
3422
|
+
_thread_config_cache: ThreadConfig | None = None
|
|
3423
|
+
|
|
3424
|
+
|
|
3425
|
+
def load_thread_config(config_path: Path | None = None) -> ThreadConfig:
|
|
3426
|
+
"""Load all thread-selector settings from one config file read.
|
|
3427
|
+
|
|
3428
|
+
Returns a cached result when reading the default config path. The
|
|
3429
|
+
prewarm worker calls this at startup so subsequent opens of the
|
|
3430
|
+
`/threads` modal avoid disk I/O entirely.
|
|
3431
|
+
|
|
3432
|
+
Args:
|
|
3433
|
+
config_path: Path to config file.
|
|
3434
|
+
|
|
3435
|
+
Returns:
|
|
3436
|
+
Coalesced thread configuration.
|
|
3437
|
+
"""
|
|
3438
|
+
global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
3439
|
+
|
|
3440
|
+
if config_path is None:
|
|
3441
|
+
if _thread_config_cache is not None:
|
|
3442
|
+
return _thread_config_cache
|
|
3443
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3444
|
+
use_default = config_path == DEFAULT_CONFIG_PATH
|
|
3445
|
+
|
|
3446
|
+
columns = dict(THREAD_COLUMN_DEFAULTS)
|
|
3447
|
+
relative_time = True
|
|
3448
|
+
sort_order = "updated_at"
|
|
3449
|
+
scope = "cwd"
|
|
3450
|
+
|
|
3451
|
+
try:
|
|
3452
|
+
if not config_path.exists():
|
|
3453
|
+
result = ThreadConfig(columns, relative_time, sort_order, scope)
|
|
3454
|
+
if use_default:
|
|
3455
|
+
_thread_config_cache = result
|
|
3456
|
+
return result
|
|
3457
|
+
with config_path.open("rb") as f:
|
|
3458
|
+
data = tomllib.load(f)
|
|
3459
|
+
threads_section = data.get("threads", {})
|
|
3460
|
+
|
|
3461
|
+
# columns
|
|
3462
|
+
raw_columns = threads_section.get("columns", {})
|
|
3463
|
+
if isinstance(raw_columns, dict):
|
|
3464
|
+
for key in columns:
|
|
3465
|
+
if key in raw_columns and isinstance(raw_columns[key], bool):
|
|
3466
|
+
columns[key] = raw_columns[key]
|
|
3467
|
+
|
|
3468
|
+
# relative_time
|
|
3469
|
+
rt_value = threads_section.get("relative_time")
|
|
3470
|
+
if isinstance(rt_value, bool):
|
|
3471
|
+
relative_time = rt_value
|
|
3472
|
+
|
|
3473
|
+
# sort_order
|
|
3474
|
+
so_value = threads_section.get("sort_order")
|
|
3475
|
+
if so_value in {"updated_at", "created_at"}:
|
|
3476
|
+
sort_order = so_value
|
|
3477
|
+
|
|
3478
|
+
# scope
|
|
3479
|
+
scope_value = threads_section.get("scope")
|
|
3480
|
+
if scope_value in {"cwd", "all"}:
|
|
3481
|
+
scope = scope_value
|
|
3482
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3483
|
+
logger.warning("Could not read thread config; using defaults", exc_info=True)
|
|
3484
|
+
# Do not cache on error — allow retry on next call in case the
|
|
3485
|
+
# file is fixed or permissions are restored.
|
|
3486
|
+
return ThreadConfig(columns, relative_time, sort_order, scope)
|
|
3487
|
+
|
|
3488
|
+
result = ThreadConfig(columns, relative_time, sort_order, scope)
|
|
3489
|
+
if use_default:
|
|
3490
|
+
_thread_config_cache = result
|
|
3491
|
+
return result
|
|
3492
|
+
|
|
3493
|
+
|
|
3494
|
+
def invalidate_thread_config_cache() -> None:
|
|
3495
|
+
"""Clear the cached `ThreadConfig` so the next load re-reads disk."""
|
|
3496
|
+
global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
3497
|
+
_thread_config_cache = None
|
|
3498
|
+
|
|
3499
|
+
|
|
3500
|
+
def load_thread_columns(config_path: Path | None = None) -> dict[str, bool]:
|
|
3501
|
+
"""Load thread column visibility from config file.
|
|
3502
|
+
|
|
3503
|
+
Args:
|
|
3504
|
+
config_path: Path to config file.
|
|
3505
|
+
|
|
3506
|
+
Returns:
|
|
3507
|
+
Dict mapping column names to visibility booleans.
|
|
3508
|
+
"""
|
|
3509
|
+
if config_path is None:
|
|
3510
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3511
|
+
|
|
3512
|
+
result = dict(THREAD_COLUMN_DEFAULTS)
|
|
3513
|
+
try:
|
|
3514
|
+
if not config_path.exists():
|
|
3515
|
+
return result
|
|
3516
|
+
with config_path.open("rb") as f:
|
|
3517
|
+
data = tomllib.load(f)
|
|
3518
|
+
columns = data.get("threads", {}).get("columns", {})
|
|
3519
|
+
if isinstance(columns, dict):
|
|
3520
|
+
for key in result:
|
|
3521
|
+
if key in columns and isinstance(columns[key], bool):
|
|
3522
|
+
result[key] = columns[key]
|
|
3523
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3524
|
+
logger.debug("Could not read thread column config", exc_info=True)
|
|
3525
|
+
return result
|
|
3526
|
+
|
|
3527
|
+
|
|
3528
|
+
def save_thread_columns(
|
|
3529
|
+
columns: dict[str, bool], config_path: Path | None = None
|
|
3530
|
+
) -> bool:
|
|
3531
|
+
"""Save thread column visibility to config file.
|
|
3532
|
+
|
|
3533
|
+
Args:
|
|
3534
|
+
columns: Dict mapping column names to visibility booleans.
|
|
3535
|
+
config_path: Path to config file.
|
|
3536
|
+
|
|
3537
|
+
Returns:
|
|
3538
|
+
True if save succeeded, False on I/O error.
|
|
3539
|
+
"""
|
|
3540
|
+
if config_path is None:
|
|
3541
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3542
|
+
|
|
3543
|
+
try:
|
|
3544
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3545
|
+
|
|
3546
|
+
if config_path.exists():
|
|
3547
|
+
with config_path.open("rb") as f:
|
|
3548
|
+
data = tomllib.load(f)
|
|
3549
|
+
else:
|
|
3550
|
+
data = {}
|
|
3551
|
+
|
|
3552
|
+
if "threads" not in data:
|
|
3553
|
+
data["threads"] = {}
|
|
3554
|
+
data["threads"]["columns"] = columns
|
|
3555
|
+
|
|
3556
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3557
|
+
try:
|
|
3558
|
+
with os.fdopen(fd, "wb") as f:
|
|
3559
|
+
tomli_w.dump(data, f)
|
|
3560
|
+
Path(tmp_path).replace(config_path)
|
|
3561
|
+
except BaseException:
|
|
3562
|
+
with contextlib.suppress(OSError):
|
|
3563
|
+
Path(tmp_path).unlink()
|
|
3564
|
+
raise
|
|
3565
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3566
|
+
logger.exception("Could not save thread column preferences")
|
|
3567
|
+
return False
|
|
3568
|
+
invalidate_thread_config_cache()
|
|
3569
|
+
return True
|
|
3570
|
+
|
|
3571
|
+
|
|
3572
|
+
def load_thread_relative_time(config_path: Path | None = None) -> bool:
|
|
3573
|
+
"""Load the relative-time display preference for thread timestamps.
|
|
3574
|
+
|
|
3575
|
+
Args:
|
|
3576
|
+
config_path: Path to config file.
|
|
3577
|
+
|
|
3578
|
+
Returns:
|
|
3579
|
+
True if timestamps should display as relative time.
|
|
3580
|
+
"""
|
|
3581
|
+
if config_path is None:
|
|
3582
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3583
|
+
try:
|
|
3584
|
+
if not config_path.exists():
|
|
3585
|
+
return True
|
|
3586
|
+
with config_path.open("rb") as f:
|
|
3587
|
+
data = tomllib.load(f)
|
|
3588
|
+
value = data.get("threads", {}).get("relative_time")
|
|
3589
|
+
if isinstance(value, bool):
|
|
3590
|
+
return value
|
|
3591
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3592
|
+
logger.debug("Could not read thread relative_time config", exc_info=True)
|
|
3593
|
+
return True
|
|
3594
|
+
|
|
3595
|
+
|
|
3596
|
+
def save_thread_relative_time(enabled: bool, config_path: Path | None = None) -> bool:
|
|
3597
|
+
"""Save the relative-time display preference for thread timestamps.
|
|
3598
|
+
|
|
3599
|
+
Args:
|
|
3600
|
+
enabled: Whether to display relative timestamps.
|
|
3601
|
+
config_path: Path to config file.
|
|
3602
|
+
|
|
3603
|
+
Returns:
|
|
3604
|
+
True if save succeeded, False on I/O error.
|
|
3605
|
+
"""
|
|
3606
|
+
if config_path is None:
|
|
3607
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3608
|
+
try:
|
|
3609
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3610
|
+
if config_path.exists():
|
|
3611
|
+
with config_path.open("rb") as f:
|
|
3612
|
+
data = tomllib.load(f)
|
|
3613
|
+
else:
|
|
3614
|
+
data = {}
|
|
3615
|
+
if "threads" not in data:
|
|
3616
|
+
data["threads"] = {}
|
|
3617
|
+
data["threads"]["relative_time"] = enabled
|
|
3618
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3619
|
+
try:
|
|
3620
|
+
with os.fdopen(fd, "wb") as f:
|
|
3621
|
+
tomli_w.dump(data, f)
|
|
3622
|
+
Path(tmp_path).replace(config_path)
|
|
3623
|
+
except BaseException:
|
|
3624
|
+
with contextlib.suppress(OSError):
|
|
3625
|
+
Path(tmp_path).unlink()
|
|
3626
|
+
raise
|
|
3627
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3628
|
+
logger.exception("Could not save thread relative_time preference")
|
|
3629
|
+
return False
|
|
3630
|
+
invalidate_thread_config_cache()
|
|
3631
|
+
return True
|
|
3632
|
+
|
|
3633
|
+
|
|
3634
|
+
def load_thread_sort_order(config_path: Path | None = None) -> str:
|
|
3635
|
+
"""Load the sort order preference for the thread selector.
|
|
3636
|
+
|
|
3637
|
+
Args:
|
|
3638
|
+
config_path: Path to config file.
|
|
3639
|
+
|
|
3640
|
+
Returns:
|
|
3641
|
+
`"updated_at"` or `"created_at"`.
|
|
3642
|
+
"""
|
|
3643
|
+
if config_path is None:
|
|
3644
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3645
|
+
try:
|
|
3646
|
+
if not config_path.exists():
|
|
3647
|
+
return "updated_at"
|
|
3648
|
+
with config_path.open("rb") as f:
|
|
3649
|
+
data = tomllib.load(f)
|
|
3650
|
+
value = data.get("threads", {}).get("sort_order")
|
|
3651
|
+
if value in {"updated_at", "created_at"}:
|
|
3652
|
+
return value
|
|
3653
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3654
|
+
logger.debug("Could not read thread sort_order config", exc_info=True)
|
|
3655
|
+
return "updated_at"
|
|
3656
|
+
|
|
3657
|
+
|
|
3658
|
+
STARTUP_MODE_MANUAL = "manual"
|
|
3659
|
+
"""Startup approval mode that keeps human-in-the-loop approvals enabled."""
|
|
3660
|
+
|
|
3661
|
+
STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto"
|
|
3662
|
+
"""Startup approval mode that auto-approves gated tool calls at launch."""
|
|
3663
|
+
|
|
3664
|
+
VALID_STARTUP_MODES = frozenset({STARTUP_MODE_MANUAL, STARTUP_MODE_DANGEROUSLY_AUTO})
|
|
3665
|
+
"""Accepted values for the `[startup].mode` config option."""
|
|
3666
|
+
|
|
3667
|
+
DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL
|
|
3668
|
+
"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid."""
|
|
3669
|
+
|
|
3670
|
+
|
|
3671
|
+
def load_startup_mode(config_path: Path | None = None) -> str:
|
|
3672
|
+
"""Load the default startup approval mode from config.toml.
|
|
3673
|
+
|
|
3674
|
+
Reads `[startup].mode`, which controls whether the interactive TUI launches
|
|
3675
|
+
with human-in-the-loop approvals enabled (`manual`) or auto-approved
|
|
3676
|
+
(`dangerously-auto`). The explicit `-y`/`--auto-approve` flag overrides this.
|
|
3677
|
+
|
|
3678
|
+
Args:
|
|
3679
|
+
config_path: Path to config file.
|
|
3680
|
+
|
|
3681
|
+
Returns:
|
|
3682
|
+
`"manual"` or `"dangerously-auto"`; falls back to `"manual"` when unset,
|
|
3683
|
+
unreadable, or invalid.
|
|
3684
|
+
"""
|
|
3685
|
+
if config_path is None:
|
|
3686
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3687
|
+
try:
|
|
3688
|
+
if not config_path.exists():
|
|
3689
|
+
return DEFAULT_STARTUP_MODE
|
|
3690
|
+
with config_path.open("rb") as f:
|
|
3691
|
+
data = tomllib.load(f)
|
|
3692
|
+
startup = data.get("startup")
|
|
3693
|
+
value = startup.get("mode") if isinstance(startup, dict) else None
|
|
3694
|
+
# `value` may be any TOML type; guard against non-strings (e.g. an
|
|
3695
|
+
# array or table) before the frozenset membership test, which would
|
|
3696
|
+
# otherwise raise `TypeError: unhashable type` and crash startup.
|
|
3697
|
+
if isinstance(value, str) and value in VALID_STARTUP_MODES:
|
|
3698
|
+
return value
|
|
3699
|
+
if value is not None:
|
|
3700
|
+
logger.warning(
|
|
3701
|
+
"Ignoring [startup].mode=%r (expected 'manual' or 'dangerously-auto')",
|
|
3702
|
+
value,
|
|
3703
|
+
)
|
|
3704
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3705
|
+
logger.debug("Could not read startup mode config", exc_info=True)
|
|
3706
|
+
return DEFAULT_STARTUP_MODE
|
|
3707
|
+
|
|
3708
|
+
|
|
3709
|
+
def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool:
|
|
3710
|
+
"""Save the sort order preference for the thread selector.
|
|
3711
|
+
|
|
3712
|
+
Args:
|
|
3713
|
+
sort_order: `"updated_at"` or `"created_at"`.
|
|
3714
|
+
config_path: Path to config file.
|
|
3715
|
+
|
|
3716
|
+
Returns:
|
|
3717
|
+
True if save succeeded, False on I/O error.
|
|
3718
|
+
|
|
3719
|
+
Raises:
|
|
3720
|
+
ValueError: If `sort_order` is not a recognised value.
|
|
3721
|
+
"""
|
|
3722
|
+
if sort_order not in {"updated_at", "created_at"}:
|
|
3723
|
+
msg = (
|
|
3724
|
+
f"Invalid sort_order {sort_order!r}; expected 'updated_at' or 'created_at'"
|
|
3725
|
+
)
|
|
3726
|
+
raise ValueError(msg)
|
|
3727
|
+
if config_path is None:
|
|
3728
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3729
|
+
try:
|
|
3730
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3731
|
+
if config_path.exists():
|
|
3732
|
+
with config_path.open("rb") as f:
|
|
3733
|
+
data = tomllib.load(f)
|
|
3734
|
+
else:
|
|
3735
|
+
data = {}
|
|
3736
|
+
if "threads" not in data:
|
|
3737
|
+
data["threads"] = {}
|
|
3738
|
+
data["threads"]["sort_order"] = sort_order
|
|
3739
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3740
|
+
try:
|
|
3741
|
+
with os.fdopen(fd, "wb") as f:
|
|
3742
|
+
tomli_w.dump(data, f)
|
|
3743
|
+
Path(tmp_path).replace(config_path)
|
|
3744
|
+
except Exception:
|
|
3745
|
+
with contextlib.suppress(OSError):
|
|
3746
|
+
Path(tmp_path).unlink()
|
|
3747
|
+
raise
|
|
3748
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
3749
|
+
logger.exception("Could not save thread sort_order preference")
|
|
3750
|
+
return False
|
|
3751
|
+
invalidate_thread_config_cache()
|
|
3752
|
+
return True
|
|
3753
|
+
|
|
3754
|
+
|
|
3755
|
+
def save_thread_scope(scope: str, config_path: Path | None = None) -> bool:
|
|
3756
|
+
"""Save the directory-scope preference for the thread selector.
|
|
3757
|
+
|
|
3758
|
+
Args:
|
|
3759
|
+
scope: `"cwd"` (current working directory) or `"all"` (all directories).
|
|
3760
|
+
config_path: Path to config file.
|
|
3761
|
+
|
|
3762
|
+
Returns:
|
|
3763
|
+
True if save succeeded, False on I/O error.
|
|
3764
|
+
|
|
3765
|
+
Raises:
|
|
3766
|
+
ValueError: If `scope` is not a recognised value.
|
|
3767
|
+
"""
|
|
3768
|
+
if scope not in {"cwd", "all"}:
|
|
3769
|
+
msg = f"Invalid scope {scope!r}; expected 'cwd' or 'all'"
|
|
3770
|
+
raise ValueError(msg)
|
|
3771
|
+
if config_path is None:
|
|
3772
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3773
|
+
try:
|
|
3774
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3775
|
+
if config_path.exists():
|
|
3776
|
+
with config_path.open("rb") as f:
|
|
3777
|
+
data = tomllib.load(f)
|
|
3778
|
+
else:
|
|
3779
|
+
data = {}
|
|
3780
|
+
if "threads" not in data:
|
|
3781
|
+
data["threads"] = {}
|
|
3782
|
+
data["threads"]["scope"] = scope
|
|
3783
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3784
|
+
try:
|
|
3785
|
+
with os.fdopen(fd, "wb") as f:
|
|
3786
|
+
tomli_w.dump(data, f)
|
|
3787
|
+
Path(tmp_path).replace(config_path)
|
|
3788
|
+
except BaseException:
|
|
3789
|
+
# Clean up temp file on any failure, including interrupts.
|
|
3790
|
+
with contextlib.suppress(OSError):
|
|
3791
|
+
Path(tmp_path).unlink()
|
|
3792
|
+
raise
|
|
3793
|
+
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
3794
|
+
# `TypeError`/`ValueError` cover `tomli_w.dump` rejecting a payload
|
|
3795
|
+
# from a pre-existing config that does not round-trip; folding them in
|
|
3796
|
+
# keeps the `bool` contract intact for `_persist_scope`'s failure toast.
|
|
3797
|
+
logger.exception("Could not save thread scope preference")
|
|
3798
|
+
return False
|
|
3799
|
+
invalidate_thread_config_cache()
|
|
3800
|
+
return True
|
|
3801
|
+
|
|
3802
|
+
|
|
3803
|
+
def save_recent_model(model_spec: str, config_path: Path | None = None) -> bool:
|
|
3804
|
+
"""Update the recently used model in config file.
|
|
3805
|
+
|
|
3806
|
+
Writes to `[models].recent` instead of `[models].default`, so that `/model`
|
|
3807
|
+
switches do not overwrite the user's intentional default.
|
|
3808
|
+
|
|
3809
|
+
Args:
|
|
3810
|
+
model_spec: The model to save in `provider:model` format.
|
|
3811
|
+
config_path: Path to config file.
|
|
3812
|
+
|
|
3813
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3814
|
+
|
|
3815
|
+
Returns:
|
|
3816
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
3817
|
+
|
|
3818
|
+
Note:
|
|
3819
|
+
This function does not preserve comments in the config file.
|
|
3820
|
+
"""
|
|
3821
|
+
return _save_model_field("recent", model_spec, config_path)
|
|
3822
|
+
|
|
3823
|
+
|
|
3824
|
+
def _recent_models_path(state_dir: Path | None = None) -> Path:
|
|
3825
|
+
"""Resolve the JSON file path for the recent-models MRU cache.
|
|
3826
|
+
|
|
3827
|
+
Args:
|
|
3828
|
+
state_dir: Override for the state directory (test hook).
|
|
3829
|
+
|
|
3830
|
+
Returns:
|
|
3831
|
+
Absolute path to `recent_models.json` under the chosen state dir.
|
|
3832
|
+
"""
|
|
3833
|
+
return (state_dir or DEFAULT_STATE_DIR) / RECENT_MODELS_FILENAME
|
|
3834
|
+
|
|
3835
|
+
|
|
3836
|
+
def load_recent_models(state_dir: Path | None = None) -> list[str]:
|
|
3837
|
+
"""Read the most-recent-first list of `provider:model` specs.
|
|
3838
|
+
|
|
3839
|
+
Missing or malformed files yield an empty list rather than raising; the
|
|
3840
|
+
recent section is a non-essential UI affordance and must not block the
|
|
3841
|
+
selector from rendering.
|
|
3842
|
+
|
|
3843
|
+
Args:
|
|
3844
|
+
state_dir: Override for the state directory (test hook).
|
|
3845
|
+
|
|
3846
|
+
Returns:
|
|
3847
|
+
Ordered list of recent `provider:model` specs, most recent first.
|
|
3848
|
+
Capped at `RECENT_MODELS_LIMIT` and de-duplicated.
|
|
3849
|
+
"""
|
|
3850
|
+
path = _recent_models_path(state_dir)
|
|
3851
|
+
if not path.exists():
|
|
3852
|
+
return []
|
|
3853
|
+
try:
|
|
3854
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
3855
|
+
except (OSError, json.JSONDecodeError):
|
|
3856
|
+
logger.warning("Could not read recent models cache at %s", path, exc_info=True)
|
|
3857
|
+
return []
|
|
3858
|
+
raw = data.get("models") if isinstance(data, dict) else None
|
|
3859
|
+
if not isinstance(raw, list):
|
|
3860
|
+
return []
|
|
3861
|
+
seen: set[str] = set()
|
|
3862
|
+
out: list[str] = []
|
|
3863
|
+
for entry in raw:
|
|
3864
|
+
if not isinstance(entry, str) or ":" not in entry or entry in seen:
|
|
3865
|
+
continue
|
|
3866
|
+
seen.add(entry)
|
|
3867
|
+
out.append(entry)
|
|
3868
|
+
if len(out) >= RECENT_MODELS_LIMIT:
|
|
3869
|
+
break
|
|
3870
|
+
return out
|
|
3871
|
+
|
|
3872
|
+
|
|
3873
|
+
def touch_recent_model(model_spec: str, state_dir: Path | None = None) -> bool:
|
|
3874
|
+
"""Promote `model_spec` to the front of the recent-models MRU list.
|
|
3875
|
+
|
|
3876
|
+
Existing entries for the same spec are moved (not duplicated); the list
|
|
3877
|
+
is capped at `RECENT_MODELS_LIMIT`. Best-effort: returns `False` on I/O
|
|
3878
|
+
error so callers can degrade silently — recents are a nice-to-have, not
|
|
3879
|
+
a correctness requirement.
|
|
3880
|
+
|
|
3881
|
+
Args:
|
|
3882
|
+
model_spec: The `provider:model` string just selected.
|
|
3883
|
+
state_dir: Override for the state directory (test hook).
|
|
3884
|
+
|
|
3885
|
+
Returns:
|
|
3886
|
+
`True` on success, `False` on I/O error or invalid spec.
|
|
3887
|
+
"""
|
|
3888
|
+
if not model_spec or ":" not in model_spec:
|
|
3889
|
+
return False
|
|
3890
|
+
existing = load_recent_models(state_dir)
|
|
3891
|
+
deduped = [entry for entry in existing if entry != model_spec]
|
|
3892
|
+
new_list = [model_spec, *deduped][:RECENT_MODELS_LIMIT]
|
|
3893
|
+
path = _recent_models_path(state_dir)
|
|
3894
|
+
try:
|
|
3895
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3896
|
+
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
3897
|
+
try:
|
|
3898
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
3899
|
+
json.dump({"models": new_list}, f)
|
|
3900
|
+
Path(tmp_path).replace(path)
|
|
3901
|
+
except BaseException:
|
|
3902
|
+
with contextlib.suppress(OSError):
|
|
3903
|
+
Path(tmp_path).unlink()
|
|
3904
|
+
raise
|
|
3905
|
+
except OSError:
|
|
3906
|
+
logger.warning(
|
|
3907
|
+
"Could not update recent models cache at %s", path, exc_info=True
|
|
3908
|
+
)
|
|
3909
|
+
return False
|
|
3910
|
+
return True
|
|
3911
|
+
|
|
3912
|
+
|
|
3913
|
+
def save_recent_agent(agent_name: str, config_path: Path | None = None) -> bool:
|
|
3914
|
+
"""Update the recently used agent in config file.
|
|
3915
|
+
|
|
3916
|
+
Writes to `[agents].recent` so a later bare `deepagents` launch (no
|
|
3917
|
+
`-a`) can bring the user back to their last agent instead of the
|
|
3918
|
+
default.
|
|
3919
|
+
|
|
3920
|
+
Args:
|
|
3921
|
+
agent_name: The agent directory name (e.g., `'coder'`).
|
|
3922
|
+
config_path: Path to config file.
|
|
3923
|
+
|
|
3924
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3925
|
+
|
|
3926
|
+
Returns:
|
|
3927
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
3928
|
+
"""
|
|
3929
|
+
return _save_toml_field("agents", "recent", agent_name, config_path)
|
|
3930
|
+
|
|
3931
|
+
|
|
3932
|
+
def load_recent_agent(config_path: Path | None = None) -> str | None:
|
|
3933
|
+
"""Read `[agents].recent` from the config file.
|
|
3934
|
+
|
|
3935
|
+
Args:
|
|
3936
|
+
config_path: Path to config file.
|
|
3937
|
+
|
|
3938
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3939
|
+
|
|
3940
|
+
Returns:
|
|
3941
|
+
The saved agent name, or `None` if the file or key is missing or
|
|
3942
|
+
the file is unreadable.
|
|
3943
|
+
"""
|
|
3944
|
+
return _load_agents_field("recent", config_path)
|
|
3945
|
+
|
|
3946
|
+
|
|
3947
|
+
def save_default_agent(agent_name: str, config_path: Path | None = None) -> bool:
|
|
3948
|
+
"""Update the default agent in config file.
|
|
3949
|
+
|
|
3950
|
+
Writes to `[agents].default`. This is the user's intentional sticky
|
|
3951
|
+
default — set via `Ctrl+S` in the `/agents` picker — and takes
|
|
3952
|
+
precedence over `[agents].recent` on bare-launch resolution.
|
|
3953
|
+
|
|
3954
|
+
Args:
|
|
3955
|
+
agent_name: The agent directory name (e.g., `'coder'`).
|
|
3956
|
+
config_path: Path to config file.
|
|
3957
|
+
|
|
3958
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3959
|
+
|
|
3960
|
+
Returns:
|
|
3961
|
+
True if save succeeded, False if it failed due to I/O errors.
|
|
3962
|
+
"""
|
|
3963
|
+
return _save_toml_field("agents", "default", agent_name, config_path)
|
|
3964
|
+
|
|
3965
|
+
|
|
3966
|
+
def clear_default_agent(config_path: Path | None = None) -> bool:
|
|
3967
|
+
"""Remove the default agent from the config file.
|
|
3968
|
+
|
|
3969
|
+
Deletes the `[agents].default` key so that future launches fall back
|
|
3970
|
+
to `[agents].recent` and then `DEFAULT_AGENT_NAME`.
|
|
3971
|
+
|
|
3972
|
+
Args:
|
|
3973
|
+
config_path: Path to config file.
|
|
3974
|
+
|
|
3975
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
3976
|
+
|
|
3977
|
+
Returns:
|
|
3978
|
+
True if the key was removed (or was already absent), False on I/O error.
|
|
3979
|
+
"""
|
|
3980
|
+
if config_path is None:
|
|
3981
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
3982
|
+
|
|
3983
|
+
if not config_path.exists():
|
|
3984
|
+
return True
|
|
3985
|
+
|
|
3986
|
+
try:
|
|
3987
|
+
with config_path.open("rb") as f:
|
|
3988
|
+
data = tomllib.load(f)
|
|
3989
|
+
|
|
3990
|
+
agents_section = data.get("agents")
|
|
3991
|
+
if not isinstance(agents_section, dict) or "default" not in agents_section:
|
|
3992
|
+
return True
|
|
3993
|
+
|
|
3994
|
+
del agents_section["default"]
|
|
3995
|
+
|
|
3996
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
3997
|
+
try:
|
|
3998
|
+
with os.fdopen(fd, "wb") as f:
|
|
3999
|
+
tomli_w.dump(data, f)
|
|
4000
|
+
Path(tmp_path).replace(config_path)
|
|
4001
|
+
except BaseException:
|
|
4002
|
+
with contextlib.suppress(OSError):
|
|
4003
|
+
Path(tmp_path).unlink()
|
|
4004
|
+
raise
|
|
4005
|
+
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
4006
|
+
# See `_save_toml_field` for why `TypeError` / `ValueError` are
|
|
4007
|
+
# folded into the bool return contract.
|
|
4008
|
+
logger.exception("Could not clear default agent preference")
|
|
4009
|
+
return False
|
|
4010
|
+
else:
|
|
4011
|
+
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
4012
|
+
_default_config_cache = None
|
|
4013
|
+
return True
|
|
4014
|
+
|
|
4015
|
+
|
|
4016
|
+
def load_default_agent(config_path: Path | None = None) -> str | None:
|
|
4017
|
+
"""Read `[agents].default` from the config file.
|
|
4018
|
+
|
|
4019
|
+
Args:
|
|
4020
|
+
config_path: Path to config file.
|
|
4021
|
+
|
|
4022
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
4023
|
+
|
|
4024
|
+
Returns:
|
|
4025
|
+
The saved agent name, or `None` if the file or key is missing or
|
|
4026
|
+
the file is unreadable.
|
|
4027
|
+
"""
|
|
4028
|
+
return _load_agents_field("default", config_path)
|
|
4029
|
+
|
|
4030
|
+
|
|
4031
|
+
def _load_agents_field(field: str, config_path: Path | None = None) -> str | None:
|
|
4032
|
+
"""Read `[agents].<field>` from the config file.
|
|
4033
|
+
|
|
4034
|
+
Args:
|
|
4035
|
+
field: Key under the `[agents]` table (e.g., `'recent'`, `'default'`).
|
|
4036
|
+
config_path: Path to config file.
|
|
4037
|
+
|
|
4038
|
+
Defaults to `~/.deepagents/config.toml`.
|
|
4039
|
+
|
|
4040
|
+
Returns:
|
|
4041
|
+
The trimmed string value, or `None` if the file, section, or key
|
|
4042
|
+
is missing or the file is unreadable.
|
|
4043
|
+
"""
|
|
4044
|
+
if config_path is None:
|
|
4045
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
4046
|
+
if not config_path.exists():
|
|
4047
|
+
return None
|
|
4048
|
+
try:
|
|
4049
|
+
with config_path.open("rb") as f:
|
|
4050
|
+
data = tomllib.load(f)
|
|
4051
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
4052
|
+
logger.warning("Could not read agents.%s from config", field, exc_info=True)
|
|
4053
|
+
return None
|
|
4054
|
+
agents_section = data.get("agents", {})
|
|
4055
|
+
value = agents_section.get(field)
|
|
4056
|
+
if isinstance(value, str) and value.strip():
|
|
4057
|
+
return value.strip()
|
|
4058
|
+
return None
|
|
4059
|
+
|
|
4060
|
+
|
|
4061
|
+
def _provider_api_key_env(provider_id: str) -> str:
|
|
4062
|
+
normalized = provider_id.upper().replace("-", "_")
|
|
4063
|
+
return f"{normalized}_API_KEY"
|
|
4064
|
+
|
|
4065
|
+
|
|
4066
|
+
def save_custom_provider(
|
|
4067
|
+
provider_id: str,
|
|
4068
|
+
display_name: str,
|
|
4069
|
+
base_url: str,
|
|
4070
|
+
models: list[str] | None = None,
|
|
4071
|
+
class_path: str = "langchain_openai:ChatOpenAI",
|
|
4072
|
+
api_key_env: str | None = None,
|
|
4073
|
+
api_key: str | None = None,
|
|
4074
|
+
default_model: str | None = None,
|
|
4075
|
+
max_input_tokens: int | None = None,
|
|
4076
|
+
config_path: Path | None = None,
|
|
4077
|
+
) -> bool:
|
|
4078
|
+
"""Save a custom OpenAI-compatible provider to the config file.
|
|
4079
|
+
|
|
4080
|
+
Args:
|
|
4081
|
+
provider_id: Short identifier for the provider.
|
|
4082
|
+
display_name: Human-readable name for the provider shown in the UI.
|
|
4083
|
+
base_url: Base URL for the provider's OpenAI-compatible API endpoint.
|
|
4084
|
+
models: Model IDs supported by this provider.
|
|
4085
|
+
class_path: Fully-qualified model class path.
|
|
4086
|
+
api_key_env: Environment variable name for the provider API key.
|
|
4087
|
+
api_key: API key to store for the provider.
|
|
4088
|
+
default_model: Default model ID for this provider.
|
|
4089
|
+
max_input_tokens: Optional context-window size (tokens) written to the
|
|
4090
|
+
provider `profile` table. The API does not return this — declaring
|
|
4091
|
+
it here is what lets the per-call info line render `ctx=used/limit~
|
|
4092
|
+
(pct%)` for this provider's models.
|
|
4093
|
+
config_path: Path to config file.
|
|
4094
|
+
|
|
4095
|
+
Returns:
|
|
4096
|
+
`True` if save succeeded, `False` if it failed due to I/O errors.
|
|
4097
|
+
"""
|
|
4098
|
+
if config_path is None:
|
|
4099
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
4100
|
+
|
|
4101
|
+
model_list = list(models or [])
|
|
4102
|
+
if default_model and default_model not in model_list:
|
|
4103
|
+
model_list.append(default_model)
|
|
4104
|
+
if not api_key_env and class_path == "langchain_openai:ChatOpenAI":
|
|
4105
|
+
api_key_env = "OPENAI_API_KEY"
|
|
4106
|
+
elif api_key and not api_key_env:
|
|
4107
|
+
api_key_env = _provider_api_key_env(provider_id)
|
|
4108
|
+
|
|
4109
|
+
try:
|
|
4110
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4111
|
+
|
|
4112
|
+
if config_path.exists():
|
|
4113
|
+
with config_path.open("rb") as f:
|
|
4114
|
+
data = tomllib.load(f)
|
|
4115
|
+
else:
|
|
4116
|
+
data = {}
|
|
4117
|
+
|
|
4118
|
+
models_section = data.setdefault("models", {})
|
|
4119
|
+
providers_section = models_section.setdefault("providers", {})
|
|
4120
|
+
|
|
4121
|
+
provider_config: ProviderConfig = {
|
|
4122
|
+
"display_name": display_name,
|
|
4123
|
+
"base_url": base_url,
|
|
4124
|
+
"class_path": class_path,
|
|
4125
|
+
"models": model_list,
|
|
4126
|
+
}
|
|
4127
|
+
if api_key_env:
|
|
4128
|
+
provider_config["api_key_env"] = api_key_env
|
|
4129
|
+
if default_model:
|
|
4130
|
+
provider_config["default_model"] = default_model
|
|
4131
|
+
if isinstance(max_input_tokens, int) and max_input_tokens > 0:
|
|
4132
|
+
provider_config["profile"] = {"max_input_tokens": max_input_tokens}
|
|
4133
|
+
providers_section[provider_id] = provider_config
|
|
4134
|
+
|
|
4135
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
4136
|
+
try:
|
|
4137
|
+
with os.fdopen(fd, "wb") as f:
|
|
4138
|
+
tomli_w.dump(data, f)
|
|
4139
|
+
Path(tmp_path).replace(config_path)
|
|
4140
|
+
except BaseException:
|
|
4141
|
+
with contextlib.suppress(OSError):
|
|
4142
|
+
Path(tmp_path).unlink()
|
|
4143
|
+
raise
|
|
4144
|
+
except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
|
|
4145
|
+
logger.exception("Could not save custom provider %s", provider_id)
|
|
4146
|
+
return False
|
|
4147
|
+
|
|
4148
|
+
if api_key:
|
|
4149
|
+
try:
|
|
4150
|
+
auth_store.set_stored_key(provider_id, api_key, base_url=base_url)
|
|
4151
|
+
except RuntimeError:
|
|
4152
|
+
logger.exception("Could not save API key for provider %s", provider_id)
|
|
4153
|
+
return False
|
|
4154
|
+
|
|
4155
|
+
clear_caches()
|
|
4156
|
+
return True
|