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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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