zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1997 @@
|
|
|
1
|
+
"""TUI screens for managing stored model-provider credentials.
|
|
2
|
+
|
|
3
|
+
`AuthPromptScreen` accepts an API key for a single provider, persists it via
|
|
4
|
+
`auth_store`, and is the sole place that deletes existing credentials (after
|
|
5
|
+
a `DeleteCredentialConfirmScreen` confirmation). `AuthManagerScreen` lists
|
|
6
|
+
known providers and routes the user into the prompt; it does not delete
|
|
7
|
+
directly. Both are reachable via the `/auth` slash command.
|
|
8
|
+
|
|
9
|
+
Security notes:
|
|
10
|
+
|
|
11
|
+
- Inputs are rendered with `password=True` so the key is never echoed to
|
|
12
|
+
the terminal.
|
|
13
|
+
- This module never logs the key value, never includes it in `notify()`
|
|
14
|
+
payloads, and never round-trips it through Rich markup. Callers that
|
|
15
|
+
introduce new logging here must do the same.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from functools import partial
|
|
24
|
+
from typing import TYPE_CHECKING, ClassVar, NamedTuple
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
from textual.binding import Binding, BindingType
|
|
28
|
+
from textual.color import Color as TColor
|
|
29
|
+
from textual.containers import Vertical
|
|
30
|
+
from textual.content import Content
|
|
31
|
+
from textual.message import Message
|
|
32
|
+
from textual.screen import ModalScreen
|
|
33
|
+
from textual.style import Style as TStyle
|
|
34
|
+
from textual.widgets import Input, OptionList, RadioButton, RadioSet, Static
|
|
35
|
+
from textual.widgets.option_list import Option, OptionDoesNotExist
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from textual.app import ComposeResult
|
|
39
|
+
from textual.events import Click, MouseMove
|
|
40
|
+
|
|
41
|
+
from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
|
|
42
|
+
|
|
43
|
+
from deepagents_code import auth_store, theme
|
|
44
|
+
from deepagents_code.auth_display import format_auth_badge
|
|
45
|
+
from deepagents_code.config import (
|
|
46
|
+
LANGSMITH_EU_ENDPOINT,
|
|
47
|
+
LANGSMITH_US_ENDPOINT,
|
|
48
|
+
apply_stored_langsmith_auth,
|
|
49
|
+
get_glyphs,
|
|
50
|
+
is_ascii_mode,
|
|
51
|
+
is_http_url,
|
|
52
|
+
normalize_langsmith_endpoint,
|
|
53
|
+
)
|
|
54
|
+
from deepagents_code.model_config import (
|
|
55
|
+
CODEX_PROVIDER,
|
|
56
|
+
PROVIDER_API_KEY_ENV,
|
|
57
|
+
PROVIDERS_DOCS_URL as _PROVIDERS_DOCS_URL,
|
|
58
|
+
SERVICE_API_KEY_ENV,
|
|
59
|
+
ModelConfig,
|
|
60
|
+
ProviderAuthSource,
|
|
61
|
+
ProviderAuthState,
|
|
62
|
+
ProviderAuthStatus,
|
|
63
|
+
clear_caches,
|
|
64
|
+
get_available_models,
|
|
65
|
+
get_base_url_env_var,
|
|
66
|
+
get_base_url_env_vars,
|
|
67
|
+
get_credential_env_var,
|
|
68
|
+
get_default_base_url_env,
|
|
69
|
+
get_provider_auth_status,
|
|
70
|
+
get_service_auth_status,
|
|
71
|
+
is_langsmith,
|
|
72
|
+
is_service,
|
|
73
|
+
resolved_env_var_name,
|
|
74
|
+
)
|
|
75
|
+
from deepagents_code.tui.widgets._links import open_style_link
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
CONFIGURATION_DOCS_URL = (
|
|
81
|
+
"https://docs.langchain.com/oss/python/deepagents/code/configuration"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Region(StrEnum):
|
|
86
|
+
"""The closed set of LangSmith region selections in the `/auth` prompt."""
|
|
87
|
+
|
|
88
|
+
US = "us"
|
|
89
|
+
EU = "eu"
|
|
90
|
+
CUSTOM = "custom"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _RegionSpec(NamedTuple):
|
|
94
|
+
"""One region row: its enum, radio widget id, SaaS endpoint, and label.
|
|
95
|
+
|
|
96
|
+
The single source of truth for the region <-> radio-id <-> endpoint mapping,
|
|
97
|
+
so `_REGION_BY_RADIO_ID`, `compose`'s radio buttons, `_region_for_endpoint`,
|
|
98
|
+
and `_resolve_langsmith_endpoint` can't drift apart. `endpoint` is the
|
|
99
|
+
canonical URL stored for a fixed-SaaS region (`""` for the US default, which
|
|
100
|
+
clears the stored endpoint); `Region.CUSTOM` carries `""` here because the
|
|
101
|
+
user supplies its endpoint at prompt time, so it is handled explicitly.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
region: Region
|
|
105
|
+
radio_id: str
|
|
106
|
+
endpoint: str
|
|
107
|
+
label: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_REGION_SPECS: tuple[_RegionSpec, ...] = (
|
|
111
|
+
_RegionSpec(Region.US, "auth-region-us", "", "United States (default)"),
|
|
112
|
+
_RegionSpec(Region.EU, "auth-region-eu", LANGSMITH_EU_ENDPOINT, "Europe"),
|
|
113
|
+
_RegionSpec(
|
|
114
|
+
Region.CUSTOM, "auth-region-custom", "", "Custom (self-hosted / proxy)"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
_REGION_BY_RADIO_ID: dict[str, Region] = {
|
|
119
|
+
spec.radio_id: spec.region for spec in _REGION_SPECS
|
|
120
|
+
}
|
|
121
|
+
"""Map each region radio's widget id to its region, avoiding string-munging."""
|
|
122
|
+
|
|
123
|
+
_ENDPOINT_BY_REGION: dict[Region, str] = {
|
|
124
|
+
spec.region: spec.endpoint for spec in _REGION_SPECS
|
|
125
|
+
}
|
|
126
|
+
"""Canonical endpoint stored for each fixed-SaaS region (Custom is dynamic)."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ResolvedEndpoint(NamedTuple):
|
|
130
|
+
"""Outcome of resolving the region selector to an endpoint to persist.
|
|
131
|
+
|
|
132
|
+
`endpoint` is the canonical URL to store (empty for the US SaaS default);
|
|
133
|
+
`error` is a user-facing message when a Custom URL is missing or malformed,
|
|
134
|
+
in which case `endpoint` is empty and nothing should be saved.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
endpoint: str
|
|
138
|
+
error: str | None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _region_for_endpoint(base_url: str) -> Region:
|
|
142
|
+
"""Map a stored LangSmith endpoint to its `/auth` region selection.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
base_url: The stored endpoint, or an empty string for the SaaS default.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
`Region.US` (blank, or the canonical US SaaS URL that the CLI
|
|
149
|
+
`--base-url us` shorthand stores), `Region.EU` (the EU SaaS URL), or
|
|
150
|
+
`Region.CUSTOM` (any other endpoint, e.g. self-hosted).
|
|
151
|
+
"""
|
|
152
|
+
if not base_url or base_url == LANGSMITH_US_ENDPOINT:
|
|
153
|
+
return Region.US
|
|
154
|
+
for spec in _REGION_SPECS:
|
|
155
|
+
if spec.endpoint and base_url == spec.endpoint:
|
|
156
|
+
return spec.region
|
|
157
|
+
return Region.CUSTOM
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
|
161
|
+
"anthropic": "Anthropic",
|
|
162
|
+
"azure_openai": "Azure OpenAI",
|
|
163
|
+
"baseten": "Baseten",
|
|
164
|
+
"cohere": "Cohere",
|
|
165
|
+
"deepseek": "DeepSeek",
|
|
166
|
+
"fireworks": "Fireworks",
|
|
167
|
+
"google_genai": "Google Gemini",
|
|
168
|
+
"google_vertexai": "Google Vertex AI",
|
|
169
|
+
"groq": "Groq",
|
|
170
|
+
"huggingface": "Hugging Face",
|
|
171
|
+
"ibm": "IBM watsonx",
|
|
172
|
+
"langsmith": "LangSmith (tracing)",
|
|
173
|
+
"litellm": "LiteLLM",
|
|
174
|
+
"meta": "Meta",
|
|
175
|
+
"mistralai": "Mistral AI",
|
|
176
|
+
"nvidia": "NVIDIA",
|
|
177
|
+
"openai": "OpenAI",
|
|
178
|
+
"openai_codex": "OpenAI Codex (ChatGPT login)",
|
|
179
|
+
"openrouter": "OpenRouter",
|
|
180
|
+
"perplexity": "Perplexity",
|
|
181
|
+
"together": "Together AI",
|
|
182
|
+
"xai": "xAI",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
PROVIDER_SHORT_NAMES: dict[str, str] = {
|
|
187
|
+
# Only providers whose `PROVIDER_DISPLAY_NAMES` label is too verbose for a
|
|
188
|
+
# compact tag need an entry here; everything else falls back to the display
|
|
189
|
+
# name, which is already short.
|
|
190
|
+
"openai_codex": "OpenAI Codex",
|
|
191
|
+
}
|
|
192
|
+
"""Compact brand labels for space-constrained UI (e.g. the `/model` Recent tag).
|
|
193
|
+
|
|
194
|
+
Sparse companion to `PROVIDER_DISPLAY_NAMES`: an entry exists only when the full
|
|
195
|
+
display name carries a parenthetical qualifier that reads badly inside a tag
|
|
196
|
+
(e.g. `"OpenAI Codex (ChatGPT login)"`). Resolved via `provider_short_name`.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
PROVIDER_API_KEY_URLS: dict[str, str] = {
|
|
201
|
+
"anthropic": "https://platform.claude.com/login?returnTo=%2Fsettings%2Fkeys",
|
|
202
|
+
"baseten": "https://docs.baseten.co/organization/api-keys",
|
|
203
|
+
"cohere": "https://dashboard.cohere.com/welcome/login?redirect_uri=%2Fapi-keys",
|
|
204
|
+
"deepseek": "https://platform.deepseek.com/api_keys",
|
|
205
|
+
"fireworks": "https://app.fireworks.ai/settings/users/api-keys",
|
|
206
|
+
"google_genai": "https://aistudio.google.com/api-keys",
|
|
207
|
+
"groq": "https://console.groq.com/keys",
|
|
208
|
+
"huggingface": "https://huggingface.co/login?next=%2Fsettings%2Ftokens",
|
|
209
|
+
"ibm": "https://cloud.ibm.com/iam/apikeys",
|
|
210
|
+
"langsmith": "https://smith.langchain.com/settings",
|
|
211
|
+
"litellm": "https://docs.litellm.ai/docs/proxy/virtual_keys",
|
|
212
|
+
"meta": "https://dev.meta.ai/api-keys/",
|
|
213
|
+
"mistralai": "https://console.mistral.ai/api-keys",
|
|
214
|
+
"nvidia": "https://build.nvidia.com/settings/api-keys",
|
|
215
|
+
"openai": "https://platform.openai.com/api-keys",
|
|
216
|
+
"openrouter": "https://openrouter.ai/workspaces/default/keys",
|
|
217
|
+
"perplexity": "https://www.perplexity.ai/settings/api",
|
|
218
|
+
"tavily": "https://app.tavily.com",
|
|
219
|
+
"together": "https://api.together.ai/settings/api-keys",
|
|
220
|
+
"xai": "https://console.x.ai/team/default/api-keys",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_safe_acquisition_url(url: str) -> bool:
|
|
225
|
+
"""Return whether `url` is safe to render as a clickable link.
|
|
226
|
+
|
|
227
|
+
Built-in links are trusted, but `api_key_url` can come from user-owned
|
|
228
|
+
config; restricting to `http`/`https` keeps a malformed or
|
|
229
|
+
`javascript:`-scheme value from becoming a live hyperlink.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
url: Candidate link target.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
`True` if the URL uses an `http` or `https` scheme.
|
|
236
|
+
"""
|
|
237
|
+
return urlsplit(url).scheme in {"http", "https"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def provider_display_name(provider: str, config: ModelConfig | None = None) -> str:
|
|
241
|
+
"""Return a human-readable provider label.
|
|
242
|
+
|
|
243
|
+
Shared by the auth UI and the model selector so a provider is labeled
|
|
244
|
+
identically in both. (The install prompt reuses the underlying
|
|
245
|
+
`PROVIDER_DISPLAY_NAMES` map directly rather than this function, to avoid an
|
|
246
|
+
event-loop config read, so a user-configured `display_name` won't surface
|
|
247
|
+
there.)
|
|
248
|
+
|
|
249
|
+
Resolution order: a configured `display_name`, then the built-in
|
|
250
|
+
`PROVIDER_DISPLAY_NAMES` map, then a title-cased form of the provider key.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
provider: Provider config key.
|
|
254
|
+
config: Parsed model config, if already loaded by the caller.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Configured display name, built-in display name, or title-cased provider key.
|
|
258
|
+
"""
|
|
259
|
+
model_config = config or ModelConfig.load()
|
|
260
|
+
return model_config.get_provider_display_name(
|
|
261
|
+
provider
|
|
262
|
+
) or PROVIDER_DISPLAY_NAMES.get(provider, provider.replace("_", " ").title())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def provider_short_name(provider: str, config: ModelConfig | None = None) -> str:
|
|
266
|
+
"""Return a compact brand label for a provider.
|
|
267
|
+
|
|
268
|
+
For space-constrained UI (e.g. the `/model` Recent tag). Resolution order:
|
|
269
|
+
a configured `short_name`, then the built-in `PROVIDER_SHORT_NAMES` map,
|
|
270
|
+
then the full `provider_display_name` (which is already short for providers
|
|
271
|
+
without a parenthetical qualifier).
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
provider: Provider config key.
|
|
275
|
+
config: Parsed model config, if already loaded by the caller.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Compact brand label, falling back to the display name when none is set.
|
|
279
|
+
"""
|
|
280
|
+
model_config = config or ModelConfig.load()
|
|
281
|
+
return (
|
|
282
|
+
model_config.get_provider_short_name(provider)
|
|
283
|
+
or PROVIDER_SHORT_NAMES.get(provider)
|
|
284
|
+
or provider_display_name(provider, model_config)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _auth_status_for(provider: str) -> ProviderAuthStatus:
|
|
289
|
+
"""Resolve the credential readiness of a provider or non-model service.
|
|
290
|
+
|
|
291
|
+
Routes services (e.g. Tavily) and model providers to their respective
|
|
292
|
+
status helpers. Each call reads the credential file, so callers that need
|
|
293
|
+
the status more than once should resolve it here a single time and reuse
|
|
294
|
+
the result.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
provider: Provider or service config key.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The auth status used for both ordering and badge rendering.
|
|
301
|
+
"""
|
|
302
|
+
if is_service(provider):
|
|
303
|
+
return get_service_auth_status(provider)
|
|
304
|
+
return get_provider_auth_status(provider)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class AuthResult(StrEnum):
|
|
308
|
+
"""Outcome of an `AuthPromptScreen` interaction.
|
|
309
|
+
|
|
310
|
+
The three outcomes need to stay distinguishable because callers in the
|
|
311
|
+
recovery path retry the original failing operation only on `SAVED` —
|
|
312
|
+
retrying after `DELETED` would loop into the same missing-credentials
|
|
313
|
+
error indefinitely.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
SAVED = "saved"
|
|
317
|
+
"""A key was persisted, or a reload (Ctrl+R) made the provider's credential
|
|
318
|
+
resolvable. Either way the caller should retry the original operation."""
|
|
319
|
+
|
|
320
|
+
DELETED = "deleted"
|
|
321
|
+
"""User cleared the existing stored key. No retry should follow."""
|
|
322
|
+
|
|
323
|
+
CANCELLED = "cancelled"
|
|
324
|
+
"""User dismissed the prompt without saving."""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class AuthConfirmScreen(ModalScreen[bool]):
|
|
328
|
+
"""Confirm before launching an authentication flow for a model.
|
|
329
|
+
|
|
330
|
+
A provider-agnostic gate shown when a selected model needs credentials
|
|
331
|
+
that aren't detected, and starting the auth flow is disruptive enough
|
|
332
|
+
that the user should opt in first (e.g. an OAuth flow that launches a
|
|
333
|
+
browser and a multi-minute loopback wait). The caller supplies all copy
|
|
334
|
+
so the screen carries no provider assumptions; currently only the
|
|
335
|
+
`openai_codex` model-switcher path uses it.
|
|
336
|
+
|
|
337
|
+
Dismissal values:
|
|
338
|
+
|
|
339
|
+
- `True`: proceed to the auth flow.
|
|
340
|
+
- `False`: go back without authenticating (also the outcome of Esc).
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
344
|
+
Binding("enter", "confirm", "Continue", show=False, priority=True),
|
|
345
|
+
Binding("escape", "cancel", "Back", show=False, priority=True),
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
CSS = """
|
|
349
|
+
AuthConfirmScreen {
|
|
350
|
+
align: center middle;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
AuthConfirmScreen > Vertical {
|
|
354
|
+
width: 64;
|
|
355
|
+
max-width: 90%;
|
|
356
|
+
height: auto;
|
|
357
|
+
background: $surface;
|
|
358
|
+
border: solid $primary;
|
|
359
|
+
padding: 1 2;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
AuthConfirmScreen .auth-confirm-title {
|
|
363
|
+
text-style: bold;
|
|
364
|
+
color: $primary;
|
|
365
|
+
text-align: center;
|
|
366
|
+
margin-bottom: 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
AuthConfirmScreen .auth-confirm-body {
|
|
370
|
+
height: auto;
|
|
371
|
+
color: $text;
|
|
372
|
+
margin-bottom: 1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
AuthConfirmScreen .auth-confirm-help {
|
|
376
|
+
height: auto;
|
|
377
|
+
color: $text-muted;
|
|
378
|
+
text-style: italic;
|
|
379
|
+
text-align: center;
|
|
380
|
+
}
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
title: str,
|
|
387
|
+
body: str | Content,
|
|
388
|
+
help_text: str = "Enter to continue, Esc to go back",
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Initialize the prompt.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
title: Heading shown at the top of the dialog.
|
|
394
|
+
body: Explanatory copy. Pass a `Content` for inline styling, or a
|
|
395
|
+
plain string for unstyled text.
|
|
396
|
+
help_text: Key-hint line shown at the bottom.
|
|
397
|
+
"""
|
|
398
|
+
super().__init__()
|
|
399
|
+
self._title = title
|
|
400
|
+
self._body = body
|
|
401
|
+
self._help_text = help_text
|
|
402
|
+
|
|
403
|
+
def compose(self) -> ComposeResult:
|
|
404
|
+
"""Compose the confirmation dialog.
|
|
405
|
+
|
|
406
|
+
Yields:
|
|
407
|
+
Title, body, and key-hint widgets parented inside a `Vertical`.
|
|
408
|
+
"""
|
|
409
|
+
with Vertical():
|
|
410
|
+
yield Static(self._title, classes="auth-confirm-title", markup=False)
|
|
411
|
+
yield Static(self._body, classes="auth-confirm-body", markup=False)
|
|
412
|
+
yield Static(self._help_text, classes="auth-confirm-help", markup=False)
|
|
413
|
+
|
|
414
|
+
def on_mount(self) -> None:
|
|
415
|
+
"""Apply ASCII border when needed."""
|
|
416
|
+
if is_ascii_mode():
|
|
417
|
+
container = self.query_one(Vertical)
|
|
418
|
+
colors = theme.get_theme_colors(self)
|
|
419
|
+
container.styles.border = ("ascii", colors.success)
|
|
420
|
+
|
|
421
|
+
def action_confirm(self) -> None:
|
|
422
|
+
"""Dismiss with `True` to proceed to the auth flow."""
|
|
423
|
+
self.dismiss(True)
|
|
424
|
+
|
|
425
|
+
def action_cancel(self) -> None:
|
|
426
|
+
"""Dismiss with `False` to go back without authenticating.
|
|
427
|
+
|
|
428
|
+
The method name must stay `cancel`: the app owns a priority `escape`
|
|
429
|
+
binding that, for an active `ModalScreen`, dispatches to
|
|
430
|
+
`action_cancel` if present and otherwise falls through to
|
|
431
|
+
`dismiss(None)`. Renaming this would silently regress Esc to a
|
|
432
|
+
`None` dismiss instead of an explicit "go back".
|
|
433
|
+
"""
|
|
434
|
+
self.dismiss(False)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class DeleteCredentialConfirmScreen(ModalScreen[bool]):
|
|
438
|
+
"""Confirmation overlay shown before clearing a stored credential.
|
|
439
|
+
|
|
440
|
+
Patterned on `DeleteThreadConfirmScreen` so the destructive prompt feels
|
|
441
|
+
consistent across the app. Always dismisses with `True` on confirm or
|
|
442
|
+
`False` on cancel; the caller does the actual delete.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
446
|
+
Binding("enter", "confirm", "Confirm", show=False, priority=True),
|
|
447
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
CSS = """
|
|
451
|
+
DeleteCredentialConfirmScreen {
|
|
452
|
+
align: center middle;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
DeleteCredentialConfirmScreen > Vertical {
|
|
456
|
+
width: 56;
|
|
457
|
+
height: auto;
|
|
458
|
+
background: $surface;
|
|
459
|
+
border: solid red;
|
|
460
|
+
padding: 1 2;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
DeleteCredentialConfirmScreen .auth-confirm-text {
|
|
464
|
+
text-align: center;
|
|
465
|
+
margin-bottom: 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
DeleteCredentialConfirmScreen .auth-confirm-help {
|
|
469
|
+
text-align: center;
|
|
470
|
+
color: $text-muted;
|
|
471
|
+
text-style: italic;
|
|
472
|
+
}
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
def __init__(self, provider: str) -> None:
|
|
476
|
+
"""Initialize the confirmation modal.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
provider: Provider whose stored credential is about to be cleared.
|
|
480
|
+
"""
|
|
481
|
+
super().__init__()
|
|
482
|
+
self._provider = provider
|
|
483
|
+
|
|
484
|
+
def compose(self) -> ComposeResult:
|
|
485
|
+
"""Compose the confirmation dialog.
|
|
486
|
+
|
|
487
|
+
Yields:
|
|
488
|
+
Widgets for the delete confirmation prompt.
|
|
489
|
+
"""
|
|
490
|
+
with Vertical():
|
|
491
|
+
yield Static(
|
|
492
|
+
Content.from_markup(
|
|
493
|
+
"Delete stored API key for [bold]$provider[/bold]?",
|
|
494
|
+
provider=self._provider,
|
|
495
|
+
),
|
|
496
|
+
classes="auth-confirm-text",
|
|
497
|
+
)
|
|
498
|
+
yield Static(
|
|
499
|
+
"Enter to confirm, Esc to cancel",
|
|
500
|
+
classes="auth-confirm-help",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def action_confirm(self) -> None:
|
|
504
|
+
"""Confirm deletion."""
|
|
505
|
+
self.dismiss(True)
|
|
506
|
+
|
|
507
|
+
def action_cancel(self) -> None:
|
|
508
|
+
"""Cancel deletion."""
|
|
509
|
+
self.dismiss(False)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class AuthPromptScreen(ModalScreen[AuthResult]):
|
|
513
|
+
"""Modal that captures and persists an API key for one provider.
|
|
514
|
+
|
|
515
|
+
Dismissal values are members of `AuthResult` so callers in the recovery
|
|
516
|
+
path can distinguish "user just saved a key — retry the failed
|
|
517
|
+
operation" from "user just cleared their key — don't retry, that would
|
|
518
|
+
loop into the same error" from "user cancelled — leave state alone".
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
AUTO_FOCUS = "#auth-prompt-input"
|
|
522
|
+
|
|
523
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
524
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
525
|
+
Binding("f2", "toggle_advanced", "Advanced", show=False, priority=True),
|
|
526
|
+
Binding("ctrl+r", "reload_env", "Reload", show=False, priority=True),
|
|
527
|
+
Binding("ctrl+d", "delete_stored", "Delete stored", show=False, priority=True),
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
CSS = """
|
|
531
|
+
AuthPromptScreen {
|
|
532
|
+
align: center middle;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
AuthPromptScreen > Vertical {
|
|
536
|
+
width: 72;
|
|
537
|
+
max-width: 90%;
|
|
538
|
+
height: auto;
|
|
539
|
+
background: $surface;
|
|
540
|
+
border: solid $primary;
|
|
541
|
+
padding: 1 2;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
AuthPromptScreen .auth-prompt-title {
|
|
545
|
+
text-style: bold;
|
|
546
|
+
color: $primary;
|
|
547
|
+
text-align: center;
|
|
548
|
+
margin-bottom: 1;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
AuthPromptScreen .auth-prompt-copy {
|
|
552
|
+
height: auto;
|
|
553
|
+
color: $text;
|
|
554
|
+
margin-bottom: 1;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
AuthPromptScreen .auth-prompt-status {
|
|
558
|
+
height: auto;
|
|
559
|
+
color: $text;
|
|
560
|
+
background: $background;
|
|
561
|
+
padding: 0 1;
|
|
562
|
+
margin-bottom: 1;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
AuthPromptScreen .auth-prompt-instructions {
|
|
566
|
+
height: auto;
|
|
567
|
+
color: $text;
|
|
568
|
+
margin-bottom: 1;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
AuthPromptScreen .auth-prompt-advanced-toggle {
|
|
572
|
+
height: auto;
|
|
573
|
+
color: $text-muted;
|
|
574
|
+
margin-bottom: 1;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
AuthPromptScreen #auth-prompt-base-url-label {
|
|
578
|
+
text-align: center;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
AuthPromptScreen .auth-prompt-meta {
|
|
582
|
+
height: auto;
|
|
583
|
+
color: $text-muted;
|
|
584
|
+
margin-bottom: 1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
AuthPromptScreen #auth-prompt-project-hint {
|
|
588
|
+
margin-top: 1;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
AuthPromptScreen #auth-prompt-region {
|
|
592
|
+
height: auto;
|
|
593
|
+
width: 100%;
|
|
594
|
+
margin-bottom: 1;
|
|
595
|
+
border: solid $primary-lighten-2;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
AuthPromptScreen #auth-prompt-region:focus-within {
|
|
599
|
+
border: solid $primary;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
AuthPromptScreen #auth-prompt-input,
|
|
603
|
+
AuthPromptScreen #auth-prompt-base-url {
|
|
604
|
+
margin-bottom: 1;
|
|
605
|
+
border: solid $primary-lighten-2;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
AuthPromptScreen #auth-prompt-input:focus,
|
|
609
|
+
AuthPromptScreen #auth-prompt-base-url:focus {
|
|
610
|
+
border: solid $primary;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
AuthPromptScreen .auth-prompt-error {
|
|
614
|
+
height: auto;
|
|
615
|
+
color: $error;
|
|
616
|
+
margin-bottom: 1;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
AuthPromptScreen .auth-prompt-help {
|
|
620
|
+
height: auto;
|
|
621
|
+
color: $text-muted;
|
|
622
|
+
text-style: italic;
|
|
623
|
+
text-align: center;
|
|
624
|
+
}
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
def __init__(
|
|
628
|
+
self,
|
|
629
|
+
provider: str,
|
|
630
|
+
env_var: str | None,
|
|
631
|
+
*,
|
|
632
|
+
reason: str | None = None,
|
|
633
|
+
allow_empty_submit: bool = False,
|
|
634
|
+
input_placeholder: str | None = None,
|
|
635
|
+
submit_label: str | None = None,
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Initialize the prompt for `provider`.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
provider: Provider name (e.g., `"anthropic"`).
|
|
641
|
+
env_var: Canonical env var the SDK reads, shown as helper text.
|
|
642
|
+
May be `None` for providers that don't use one of the
|
|
643
|
+
hardcoded env-var bindings (rare; the prompt still works).
|
|
644
|
+
reason: Optional context, e.g.,
|
|
645
|
+
`"Required to use anthropic:claude-opus-4-8"`.
|
|
646
|
+
allow_empty_submit: Whether pressing Enter on an empty key dismisses
|
|
647
|
+
with `AuthResult.CANCELLED` instead of showing a validation error.
|
|
648
|
+
input_placeholder: Optional placeholder override for the key input.
|
|
649
|
+
submit_label: Optional help-label override for the Enter action.
|
|
650
|
+
"""
|
|
651
|
+
super().__init__()
|
|
652
|
+
self._provider = provider
|
|
653
|
+
self._env_var = env_var
|
|
654
|
+
self._reason = reason
|
|
655
|
+
self._allow_empty_submit = allow_empty_submit
|
|
656
|
+
self._input_placeholder = input_placeholder
|
|
657
|
+
self._submit_label = submit_label
|
|
658
|
+
# LangSmith is configured as a tracing service: it carries an optional
|
|
659
|
+
# project name and an endpoint chosen from a region selector (US/EU SaaS
|
|
660
|
+
# or a custom self-hosted URL), and saving a key turns tracing on.
|
|
661
|
+
self._is_langsmith = is_langsmith(provider)
|
|
662
|
+
# Probe the store once here so compose-time helpers read the cached
|
|
663
|
+
# `self._config` instead of each reloading it. See
|
|
664
|
+
# `_probe_credential_state` for the crash-safety rationale that lets
|
|
665
|
+
# this run at construction (before the modal mounts) without a guard.
|
|
666
|
+
self._probe_credential_state()
|
|
667
|
+
self._advanced_visible = bool(self._existing_base_url or self._existing_project)
|
|
668
|
+
self._refresh_endpoint_env_notice()
|
|
669
|
+
|
|
670
|
+
def _probe_credential_state(self) -> None:
|
|
671
|
+
"""Resolve the current credential source, store, base URL, and project.
|
|
672
|
+
|
|
673
|
+
Never let an unreadable auth.json crash the screen: Textual would
|
|
674
|
+
propagate the exception before the modal mounts (at construction) or
|
|
675
|
+
while it is live (on Ctrl+R reload). `auth_store` funnels all store
|
|
676
|
+
corruption into RuntimeError, so catch that, treat unreadable store
|
|
677
|
+
metadata as "no existing key", and surface a one-line warning at
|
|
678
|
+
compose time. The resolved auth status is kept: `get_provider_auth_status`
|
|
679
|
+
already falls back to environment credentials when the store can't be
|
|
680
|
+
read. (A malformed config.toml can't crash us here — `ModelConfig.load()`
|
|
681
|
+
returns an empty config instead of raising.)
|
|
682
|
+
"""
|
|
683
|
+
try:
|
|
684
|
+
self._config = ModelConfig.load()
|
|
685
|
+
self._auth_status = _auth_status_for(self._provider)
|
|
686
|
+
except RuntimeError as exc:
|
|
687
|
+
logger.warning(
|
|
688
|
+
"Could not resolve credentials for %s: %s", self._provider, exc
|
|
689
|
+
)
|
|
690
|
+
self._config = ModelConfig()
|
|
691
|
+
self._auth_status = ProviderAuthStatus(
|
|
692
|
+
state=ProviderAuthState.MISSING, provider=self._provider
|
|
693
|
+
)
|
|
694
|
+
self._set_missing_store_metadata(exc)
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
self._has_existing = auth_store.get_stored_key(self._provider) is not None
|
|
699
|
+
self._existing_base_url = (
|
|
700
|
+
auth_store.get_stored_base_url(self._provider) or ""
|
|
701
|
+
)
|
|
702
|
+
self._existing_project = auth_store.get_stored_project(self._provider) or ""
|
|
703
|
+
self._region: Region = _region_for_endpoint(self._existing_base_url)
|
|
704
|
+
self._store_warning: str | None = None
|
|
705
|
+
except RuntimeError as exc:
|
|
706
|
+
logger.warning(
|
|
707
|
+
"Could not read stored credentials for %s: %s", self._provider, exc
|
|
708
|
+
)
|
|
709
|
+
self._set_missing_store_metadata(exc)
|
|
710
|
+
|
|
711
|
+
def _set_missing_store_metadata(self, exc: RuntimeError) -> None:
|
|
712
|
+
"""Clear optional stored metadata after an auth-store read failure."""
|
|
713
|
+
self._has_existing = False
|
|
714
|
+
self._existing_base_url = ""
|
|
715
|
+
self._existing_project = ""
|
|
716
|
+
self._region = Region.US
|
|
717
|
+
self._store_warning = (
|
|
718
|
+
f"Credential file is unreadable ({exc}). Saving here will overwrite it."
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def _refresh_endpoint_env_notice(self) -> None:
|
|
722
|
+
"""Recompute the LangSmith endpoint-precedence notice from the environment.
|
|
723
|
+
|
|
724
|
+
Surface when an environment endpoint is set: at startup an existing
|
|
725
|
+
`LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT` takes precedence over the
|
|
726
|
+
stored region, so without this note the radio could show one region
|
|
727
|
+
while traces route somewhere else. (The note fires on presence, not on
|
|
728
|
+
divergence — it may show even when the env value matches the stored
|
|
729
|
+
region.) Saving here applies the selection (the save path replaces the
|
|
730
|
+
env value), so word the note around that.
|
|
731
|
+
|
|
732
|
+
Called at construction and again on Ctrl+R reload, since the reload can
|
|
733
|
+
add or remove one of those variables and the recompose must reflect it.
|
|
734
|
+
"""
|
|
735
|
+
self._endpoint_env_notice: str | None = None
|
|
736
|
+
if self._is_langsmith and any(
|
|
737
|
+
os.environ.get(var) for var in ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT")
|
|
738
|
+
):
|
|
739
|
+
self._endpoint_env_notice = (
|
|
740
|
+
"An endpoint is set in your environment "
|
|
741
|
+
"(LANGSMITH_ENDPOINT/LANGCHAIN_ENDPOINT) and takes precedence at "
|
|
742
|
+
"startup; saving a region here applies your selection instead."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
def compose(self) -> ComposeResult:
|
|
746
|
+
"""Compose the prompt.
|
|
747
|
+
|
|
748
|
+
Yields:
|
|
749
|
+
Widgets that make up the auth prompt modal.
|
|
750
|
+
"""
|
|
751
|
+
glyphs = get_glyphs()
|
|
752
|
+
provider_label = provider_display_name(self._provider, self._config)
|
|
753
|
+
with Vertical():
|
|
754
|
+
# Tag the title with `(stored)` so the user knows a replacement
|
|
755
|
+
# (or the `Ctrl+D delete` affordance shown in the help line) is
|
|
756
|
+
# what's about to happen — both are gated on `_has_existing`.
|
|
757
|
+
resolved_from_env = self._auth_status.source is ProviderAuthSource.ENV
|
|
758
|
+
scoped_env_var = None
|
|
759
|
+
if self._auth_status.env_var:
|
|
760
|
+
candidate = resolved_env_var_name(self._auth_status.env_var)
|
|
761
|
+
if candidate.startswith("DEEPAGENTS_CODE_") and os.environ.get(
|
|
762
|
+
candidate
|
|
763
|
+
):
|
|
764
|
+
scoped_env_var = candidate
|
|
765
|
+
active_env_var = scoped_env_var or (
|
|
766
|
+
resolved_env_var_name(self._auth_status.env_var)
|
|
767
|
+
if resolved_from_env and self._auth_status.env_var
|
|
768
|
+
else None
|
|
769
|
+
)
|
|
770
|
+
if active_env_var and active_env_var.startswith("DEEPAGENTS_CODE_"):
|
|
771
|
+
title_prefix = f"{glyphs.warning} "
|
|
772
|
+
else:
|
|
773
|
+
title_prefix = f"{glyphs.checkmark} " if resolved_from_env else ""
|
|
774
|
+
if self._has_existing:
|
|
775
|
+
title = Content.assemble(
|
|
776
|
+
title_prefix,
|
|
777
|
+
Content.from_markup(
|
|
778
|
+
"Replace key for [bold]$provider[/bold] [dim](stored)[/dim]",
|
|
779
|
+
provider=provider_label,
|
|
780
|
+
),
|
|
781
|
+
)
|
|
782
|
+
elif resolved_from_env:
|
|
783
|
+
title = Content.assemble(
|
|
784
|
+
title_prefix,
|
|
785
|
+
Content.from_markup(
|
|
786
|
+
"Replace key for [bold]$provider[/bold]",
|
|
787
|
+
provider=provider_label,
|
|
788
|
+
),
|
|
789
|
+
)
|
|
790
|
+
else:
|
|
791
|
+
title = Content.assemble(
|
|
792
|
+
title_prefix,
|
|
793
|
+
Content.from_markup(
|
|
794
|
+
"API key for [bold]$provider[/bold]",
|
|
795
|
+
provider=provider_label,
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
yield Static(title, classes="auth-prompt-title")
|
|
799
|
+
if active_env_var:
|
|
800
|
+
env_var = active_env_var
|
|
801
|
+
is_scoped_env = env_var.startswith("DEEPAGENTS_CODE_")
|
|
802
|
+
env_status_style = "$warning" if is_scoped_env else "$success"
|
|
803
|
+
env_note = (
|
|
804
|
+
"This scoped env var takes priority. A saved key will be used "
|
|
805
|
+
f"only when {env_var} is unset."
|
|
806
|
+
if is_scoped_env
|
|
807
|
+
else ("Paste a key below to use a different key for dcode.")
|
|
808
|
+
)
|
|
809
|
+
yield Static(
|
|
810
|
+
Content.assemble(
|
|
811
|
+
(
|
|
812
|
+
"Current key is set from environment variable ",
|
|
813
|
+
env_status_style,
|
|
814
|
+
),
|
|
815
|
+
(env_var, f"bold {env_status_style}"),
|
|
816
|
+
(".", env_status_style),
|
|
817
|
+
"\n",
|
|
818
|
+
(env_note, "italic $text-muted"),
|
|
819
|
+
),
|
|
820
|
+
classes="auth-prompt-status",
|
|
821
|
+
id="auth-prompt-env-status",
|
|
822
|
+
)
|
|
823
|
+
if self._reason:
|
|
824
|
+
yield Static(
|
|
825
|
+
Content.from_markup("$reason", reason=self._reason),
|
|
826
|
+
classes="auth-prompt-copy",
|
|
827
|
+
)
|
|
828
|
+
yield Static(
|
|
829
|
+
self._build_key_instructions(),
|
|
830
|
+
classes="auth-prompt-instructions",
|
|
831
|
+
id="auth-prompt-key-instructions",
|
|
832
|
+
)
|
|
833
|
+
if self._store_warning:
|
|
834
|
+
yield Static(
|
|
835
|
+
Content.from_markup("$msg", msg=self._store_warning),
|
|
836
|
+
classes="auth-prompt-error",
|
|
837
|
+
)
|
|
838
|
+
yield Input(
|
|
839
|
+
placeholder=self._input_placeholder
|
|
840
|
+
or (
|
|
841
|
+
"Paste a new key to replace the stored one"
|
|
842
|
+
if self._has_existing
|
|
843
|
+
else "Paste your API key"
|
|
844
|
+
),
|
|
845
|
+
password=True,
|
|
846
|
+
id="auth-prompt-input",
|
|
847
|
+
)
|
|
848
|
+
storage_note: Content | None
|
|
849
|
+
if self._is_langsmith:
|
|
850
|
+
storage_note = Content.from_markup(
|
|
851
|
+
"dcode stores the above key locally and turns on "
|
|
852
|
+
"LangSmith tracing. To pause tracing without removing the key, "
|
|
853
|
+
"set [bold]DEEPAGENTS_CODE_LANGSMITH_TRACING=false[/bold]."
|
|
854
|
+
)
|
|
855
|
+
elif is_service(self._provider):
|
|
856
|
+
# Services (e.g. Tavily) skip the storage note: the title and
|
|
857
|
+
# reason copy already say what the key is for, so it only adds
|
|
858
|
+
# redundant copy here.
|
|
859
|
+
storage_note = None
|
|
860
|
+
else:
|
|
861
|
+
storage_note = Content.from_markup(
|
|
862
|
+
"dcode stores the above key locally and uses it "
|
|
863
|
+
"when you select [bold]$provider[/bold] models.",
|
|
864
|
+
provider=provider_label,
|
|
865
|
+
)
|
|
866
|
+
if storage_note is not None:
|
|
867
|
+
yield Static(
|
|
868
|
+
storage_note,
|
|
869
|
+
classes="auth-prompt-meta",
|
|
870
|
+
id="auth-prompt-storage-note",
|
|
871
|
+
)
|
|
872
|
+
yield Static(
|
|
873
|
+
self._build_advanced_toggle_label(),
|
|
874
|
+
classes="auth-prompt-advanced-toggle",
|
|
875
|
+
id="auth-prompt-advanced-toggle",
|
|
876
|
+
)
|
|
877
|
+
if self._env_var:
|
|
878
|
+
key_meta = Static(
|
|
879
|
+
Content.assemble(
|
|
880
|
+
"Alternatively, environment variables can be used in place "
|
|
881
|
+
"of the key stored above. Set ",
|
|
882
|
+
(f"DEEPAGENTS_CODE_{self._env_var}", TStyle(bold=True)),
|
|
883
|
+
" for a dcode-only key; it has the highest priority. Set ",
|
|
884
|
+
(self._env_var, TStyle(bold=True)),
|
|
885
|
+
" to share a key with other provider SDK tools; it is used "
|
|
886
|
+
"only when no scoped or stored key exists. After setting one "
|
|
887
|
+
"in a .env file, press ",
|
|
888
|
+
("Ctrl+R", TStyle(bold=True)),
|
|
889
|
+
" to reload without restarting. A variable exported in a "
|
|
890
|
+
"separate shell after launch is invisible to this process; "
|
|
891
|
+
"it needs a full relaunch. ",
|
|
892
|
+
(
|
|
893
|
+
"Configuration docs",
|
|
894
|
+
self._link_style(CONFIGURATION_DOCS_URL),
|
|
895
|
+
),
|
|
896
|
+
".",
|
|
897
|
+
),
|
|
898
|
+
classes="auth-prompt-meta",
|
|
899
|
+
id="auth-prompt-key-meta",
|
|
900
|
+
)
|
|
901
|
+
key_meta.display = self._advanced_visible
|
|
902
|
+
yield key_meta
|
|
903
|
+
if self._is_langsmith:
|
|
904
|
+
if self._endpoint_env_notice:
|
|
905
|
+
yield Static(
|
|
906
|
+
Content.from_markup("$msg", msg=self._endpoint_env_notice),
|
|
907
|
+
classes="auth-prompt-meta",
|
|
908
|
+
id="auth-prompt-endpoint-env-notice",
|
|
909
|
+
)
|
|
910
|
+
region_label = Static(
|
|
911
|
+
Content.from_markup("[bold]LangSmith region[/bold]"),
|
|
912
|
+
classes="auth-prompt-meta",
|
|
913
|
+
id="auth-prompt-region-label",
|
|
914
|
+
)
|
|
915
|
+
region_label.display = self._advanced_visible
|
|
916
|
+
yield region_label
|
|
917
|
+
region_set = RadioSet(
|
|
918
|
+
*(
|
|
919
|
+
RadioButton(
|
|
920
|
+
spec.label,
|
|
921
|
+
value=self._region == spec.region,
|
|
922
|
+
id=spec.radio_id,
|
|
923
|
+
)
|
|
924
|
+
for spec in _REGION_SPECS
|
|
925
|
+
),
|
|
926
|
+
id="auth-prompt-region",
|
|
927
|
+
)
|
|
928
|
+
region_set.display = self._advanced_visible
|
|
929
|
+
yield region_set
|
|
930
|
+
custom_visible = (
|
|
931
|
+
self._advanced_visible and self._region == Region.CUSTOM
|
|
932
|
+
)
|
|
933
|
+
base_url_input = Input(
|
|
934
|
+
value=(
|
|
935
|
+
self._existing_base_url if self._region == Region.CUSTOM else ""
|
|
936
|
+
),
|
|
937
|
+
placeholder="https://my-langsmith.example.com",
|
|
938
|
+
id="auth-prompt-base-url",
|
|
939
|
+
)
|
|
940
|
+
base_url_input.display = custom_visible
|
|
941
|
+
yield base_url_input
|
|
942
|
+
base_url_hint_widget = Static(
|
|
943
|
+
Content.from_markup(
|
|
944
|
+
"Point tracing at a self-hosted or proxied LangSmith. "
|
|
945
|
+
"Sets [bold]LANGSMITH_ENDPOINT[/bold]; must be an "
|
|
946
|
+
"http(s) URL."
|
|
947
|
+
),
|
|
948
|
+
classes="auth-prompt-meta",
|
|
949
|
+
id="auth-prompt-base-url-hint",
|
|
950
|
+
)
|
|
951
|
+
base_url_hint_widget.display = custom_visible
|
|
952
|
+
yield base_url_hint_widget
|
|
953
|
+
project_label = Static(
|
|
954
|
+
Content.from_markup("[bold]Project name[/bold]"),
|
|
955
|
+
classes="auth-prompt-meta",
|
|
956
|
+
id="auth-prompt-project-label",
|
|
957
|
+
)
|
|
958
|
+
project_label.display = self._advanced_visible
|
|
959
|
+
yield project_label
|
|
960
|
+
project_input = Input(
|
|
961
|
+
value=self._existing_project,
|
|
962
|
+
placeholder="LANGSMITH_PROJECT (default: deepagents-code)",
|
|
963
|
+
id="auth-prompt-project",
|
|
964
|
+
)
|
|
965
|
+
project_input.display = self._advanced_visible
|
|
966
|
+
yield project_input
|
|
967
|
+
project_hint_widget = Static(
|
|
968
|
+
Content.from_markup(
|
|
969
|
+
"Route agent traces to this LangSmith project. "
|
|
970
|
+
"Leave blank to use the default [bold]deepagents-code[/bold]."
|
|
971
|
+
),
|
|
972
|
+
classes="auth-prompt-meta",
|
|
973
|
+
id="auth-prompt-project-hint",
|
|
974
|
+
)
|
|
975
|
+
project_hint_widget.display = self._advanced_visible
|
|
976
|
+
yield project_hint_widget
|
|
977
|
+
else:
|
|
978
|
+
base_url_label = Static(
|
|
979
|
+
Content.from_markup("[bold]Base URL override[/bold]"),
|
|
980
|
+
classes="auth-prompt-meta",
|
|
981
|
+
id="auth-prompt-base-url-label",
|
|
982
|
+
)
|
|
983
|
+
base_url_label.display = self._advanced_visible
|
|
984
|
+
yield base_url_label
|
|
985
|
+
base_url_input = Input(
|
|
986
|
+
value=self._existing_base_url,
|
|
987
|
+
placeholder="Base URL",
|
|
988
|
+
id="auth-prompt-base-url",
|
|
989
|
+
)
|
|
990
|
+
base_url_input.display = self._advanced_visible
|
|
991
|
+
yield base_url_input
|
|
992
|
+
base_url_hint_widget = Static(
|
|
993
|
+
self._build_base_url_hint(),
|
|
994
|
+
classes="auth-prompt-meta",
|
|
995
|
+
id="auth-prompt-base-url-hint",
|
|
996
|
+
)
|
|
997
|
+
base_url_hint_widget.display = self._advanced_visible
|
|
998
|
+
yield base_url_hint_widget
|
|
999
|
+
yield Static("", classes="auth-prompt-error", id="auth-prompt-error")
|
|
1000
|
+
save_label = self._submit_label or (
|
|
1001
|
+
"Enter replace" if self._has_existing else "Enter save"
|
|
1002
|
+
)
|
|
1003
|
+
help_parts = [
|
|
1004
|
+
f"{save_label} {glyphs.bullet} Esc cancel",
|
|
1005
|
+
"F2 advanced",
|
|
1006
|
+
"Ctrl+R reload",
|
|
1007
|
+
]
|
|
1008
|
+
if self._has_existing:
|
|
1009
|
+
help_parts.append("Ctrl+D delete stored")
|
|
1010
|
+
yield Static(
|
|
1011
|
+
f" {glyphs.bullet} ".join(help_parts),
|
|
1012
|
+
classes="auth-prompt-help",
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
def _link_style(self, url: str) -> TStyle:
|
|
1016
|
+
"""Return a theme-aware style for inline modal links.
|
|
1017
|
+
|
|
1018
|
+
Args:
|
|
1019
|
+
url: Link target.
|
|
1020
|
+
|
|
1021
|
+
Returns:
|
|
1022
|
+
Textual style that opens `url` when clicked.
|
|
1023
|
+
"""
|
|
1024
|
+
colors = theme.get_theme_colors(self)
|
|
1025
|
+
if self.app.theme in {"ansi-dark", "ansi-light"}:
|
|
1026
|
+
return TStyle(bold=True, underline=True, link=url)
|
|
1027
|
+
return TStyle(foreground=TColor.parse(colors.primary), underline=True, link=url)
|
|
1028
|
+
|
|
1029
|
+
def _build_key_instructions(self) -> Content:
|
|
1030
|
+
"""Build provider-specific API-key acquisition guidance.
|
|
1031
|
+
|
|
1032
|
+
Returns:
|
|
1033
|
+
Content shown before the API-key input. May append muted notices: a
|
|
1034
|
+
provider-specific caveat (e.g. Anthropic subscription plans are
|
|
1035
|
+
unsupported) and/or a warning that a user-configured `api_key_url`
|
|
1036
|
+
was rejected for using an unsupported URL scheme.
|
|
1037
|
+
"""
|
|
1038
|
+
config = self._config
|
|
1039
|
+
configured_url = config.get_provider_api_key_url(self._provider)
|
|
1040
|
+
rejected_url = False
|
|
1041
|
+
if configured_url and not _is_safe_acquisition_url(configured_url):
|
|
1042
|
+
logger.warning(
|
|
1043
|
+
"Ignoring api_key_url for %s: unsupported URL scheme", self._provider
|
|
1044
|
+
)
|
|
1045
|
+
configured_url = None
|
|
1046
|
+
rejected_url = True
|
|
1047
|
+
url = configured_url or PROVIDER_API_KEY_URLS.get(
|
|
1048
|
+
self._provider, _PROVIDERS_DOCS_URL
|
|
1049
|
+
)
|
|
1050
|
+
provider = provider_display_name(self._provider, config)
|
|
1051
|
+
label = (
|
|
1052
|
+
f"{provider} key page"
|
|
1053
|
+
if configured_url or self._provider in PROVIDER_API_KEY_URLS
|
|
1054
|
+
else f"{provider} setup docs"
|
|
1055
|
+
)
|
|
1056
|
+
if self._provider == "azure_openai":
|
|
1057
|
+
instructions = Content.assemble(
|
|
1058
|
+
"Find your key in your Azure OpenAI resource's "
|
|
1059
|
+
"Keys and Endpoint page, then paste it below. ",
|
|
1060
|
+
(label, self._link_style(url)),
|
|
1061
|
+
)
|
|
1062
|
+
elif self._provider == "openai":
|
|
1063
|
+
instructions = Content.assemble(
|
|
1064
|
+
f"Sign in to {provider}, create or copy an API key, then "
|
|
1065
|
+
"paste it below. Minimum permissions needed: "
|
|
1066
|
+
"under Model capabilities, grant Write access to Responses "
|
|
1067
|
+
"(/v1/responses). For older models, you may also need "
|
|
1068
|
+
"Request access to Chat completions (/v1/chat/completions). ",
|
|
1069
|
+
(label, self._link_style(url)),
|
|
1070
|
+
)
|
|
1071
|
+
elif self._provider == "anthropic":
|
|
1072
|
+
instructions = Content.assemble(
|
|
1073
|
+
f"Sign in to {provider}, create or copy an API key, "
|
|
1074
|
+
"then paste it below. ",
|
|
1075
|
+
(label, self._link_style(url)),
|
|
1076
|
+
"\n",
|
|
1077
|
+
(
|
|
1078
|
+
(
|
|
1079
|
+
"Subscription plans (Claude Pro/Max, Claude Code) cannot "
|
|
1080
|
+
"be used for Anthropic calls in dcode. Only a "
|
|
1081
|
+
"standard API key with pay-as-you-go billing works here."
|
|
1082
|
+
),
|
|
1083
|
+
"italic $text-muted",
|
|
1084
|
+
),
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
instructions = Content.assemble(
|
|
1088
|
+
f"Sign in to {provider}, create or copy an API key, "
|
|
1089
|
+
"then paste it below. ",
|
|
1090
|
+
(label, self._link_style(url)),
|
|
1091
|
+
)
|
|
1092
|
+
if rejected_url:
|
|
1093
|
+
notice = (
|
|
1094
|
+
"Your configured api_key_url was ignored (unsupported URL "
|
|
1095
|
+
"scheme); showing the default link instead."
|
|
1096
|
+
)
|
|
1097
|
+
instructions = Content.assemble(
|
|
1098
|
+
instructions,
|
|
1099
|
+
"\n",
|
|
1100
|
+
(notice, "italic $text-muted"),
|
|
1101
|
+
)
|
|
1102
|
+
return instructions
|
|
1103
|
+
|
|
1104
|
+
def _build_advanced_toggle_label(self) -> str:
|
|
1105
|
+
"""Build the disclosure-row label for advanced settings.
|
|
1106
|
+
|
|
1107
|
+
Returns:
|
|
1108
|
+
Toggle label reflecting the current expanded state.
|
|
1109
|
+
"""
|
|
1110
|
+
glyphs = get_glyphs()
|
|
1111
|
+
marker = (
|
|
1112
|
+
glyphs.disclosure_expanded
|
|
1113
|
+
if self._advanced_visible
|
|
1114
|
+
else glyphs.disclosure_collapsed
|
|
1115
|
+
)
|
|
1116
|
+
return f"{marker} Advanced (F2)"
|
|
1117
|
+
|
|
1118
|
+
def _build_base_url_hint(self) -> Content:
|
|
1119
|
+
"""Build the optional base-URL hint shown inside Advanced.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
Content describing blank behavior and env-var precedence.
|
|
1123
|
+
"""
|
|
1124
|
+
surviving_base_url_env = get_default_base_url_env(self._provider)
|
|
1125
|
+
endpoint_envs = get_base_url_env_vars(self._provider)
|
|
1126
|
+
env_order = ", then ".join(
|
|
1127
|
+
item for env in endpoint_envs for item in (f"DEEPAGENTS_CODE_{env}", env)
|
|
1128
|
+
)
|
|
1129
|
+
if surviving_base_url_env and env_order:
|
|
1130
|
+
return Content.from_markup(
|
|
1131
|
+
"Override the provider endpoint for this stored key. "
|
|
1132
|
+
"Leave blank to use [bold]$prefixed[/bold].\n"
|
|
1133
|
+
"Env override order: [bold]$order[/bold].",
|
|
1134
|
+
prefixed=surviving_base_url_env,
|
|
1135
|
+
order=env_order,
|
|
1136
|
+
)
|
|
1137
|
+
endpoint_env = get_base_url_env_var(self._provider)
|
|
1138
|
+
if endpoint_env and env_order:
|
|
1139
|
+
return Content.from_markup(
|
|
1140
|
+
"Override the provider endpoint for this stored key. "
|
|
1141
|
+
"Leave blank to use the provider default.\n"
|
|
1142
|
+
"Env override order: [bold]$order[/bold].",
|
|
1143
|
+
order=env_order,
|
|
1144
|
+
)
|
|
1145
|
+
return Content.from_markup(
|
|
1146
|
+
"Override the provider endpoint for this stored key. "
|
|
1147
|
+
"Leave blank to use the provider default."
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
def action_toggle_advanced(self) -> None:
|
|
1151
|
+
"""Show or hide optional endpoint and env-var details.
|
|
1152
|
+
|
|
1153
|
+
Restores focus to the key input when collapsing so keyboard entry
|
|
1154
|
+
resumes on the field the user most likely wants.
|
|
1155
|
+
"""
|
|
1156
|
+
self._advanced_visible = not self._advanced_visible
|
|
1157
|
+
for selector in (
|
|
1158
|
+
"#auth-prompt-key-meta",
|
|
1159
|
+
"#auth-prompt-region-label",
|
|
1160
|
+
"#auth-prompt-region",
|
|
1161
|
+
"#auth-prompt-base-url-label",
|
|
1162
|
+
"#auth-prompt-base-url",
|
|
1163
|
+
"#auth-prompt-base-url-hint",
|
|
1164
|
+
"#auth-prompt-project-label",
|
|
1165
|
+
"#auth-prompt-project",
|
|
1166
|
+
"#auth-prompt-project-hint",
|
|
1167
|
+
):
|
|
1168
|
+
for widget in self.query(selector):
|
|
1169
|
+
widget.display = self._advanced_visible
|
|
1170
|
+
# For LangSmith the custom URL field only shows under the Custom region,
|
|
1171
|
+
# so re-apply its region-gated visibility after the blanket toggle above.
|
|
1172
|
+
if self._is_langsmith:
|
|
1173
|
+
self._refresh_langsmith_custom_visibility()
|
|
1174
|
+
self.query_one("#auth-prompt-advanced-toggle", Static).update(
|
|
1175
|
+
self._build_advanced_toggle_label()
|
|
1176
|
+
)
|
|
1177
|
+
if not self._advanced_visible:
|
|
1178
|
+
self.query_one("#auth-prompt-input", Input).focus()
|
|
1179
|
+
|
|
1180
|
+
def _refresh_langsmith_custom_visibility(self) -> None:
|
|
1181
|
+
"""Show the custom URL field only when Advanced is open and region is Custom."""
|
|
1182
|
+
custom_visible = self._advanced_visible and self._region == Region.CUSTOM
|
|
1183
|
+
for selector in ("#auth-prompt-base-url", "#auth-prompt-base-url-hint"):
|
|
1184
|
+
for widget in self.query(selector):
|
|
1185
|
+
widget.display = custom_visible
|
|
1186
|
+
|
|
1187
|
+
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
|
1188
|
+
"""Track the chosen LangSmith region and reveal the custom URL field."""
|
|
1189
|
+
event.stop()
|
|
1190
|
+
region = _REGION_BY_RADIO_ID.get(event.pressed.id or "")
|
|
1191
|
+
if region is None:
|
|
1192
|
+
# An unknown id (e.g. a renamed radio) must not silently degrade to a
|
|
1193
|
+
# region — leave the current selection unchanged rather than guess.
|
|
1194
|
+
return
|
|
1195
|
+
self._region = region
|
|
1196
|
+
self._refresh_langsmith_custom_visibility()
|
|
1197
|
+
if self._region == Region.CUSTOM:
|
|
1198
|
+
self.query_one("#auth-prompt-base-url", Input).focus()
|
|
1199
|
+
|
|
1200
|
+
def on_click(self, event: Click) -> None:
|
|
1201
|
+
"""Open style-embedded hyperlinks or toggle Advanced."""
|
|
1202
|
+
widget = event.widget
|
|
1203
|
+
if (
|
|
1204
|
+
widget is not None
|
|
1205
|
+
and widget.id == "auth-prompt-advanced-toggle"
|
|
1206
|
+
and not event.style.link
|
|
1207
|
+
):
|
|
1208
|
+
self.action_toggle_advanced()
|
|
1209
|
+
event.stop()
|
|
1210
|
+
return
|
|
1211
|
+
open_style_link(event)
|
|
1212
|
+
|
|
1213
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
1214
|
+
"""Show a pointer over links and the clickable Advanced row."""
|
|
1215
|
+
widget = event.widget
|
|
1216
|
+
self.styles.pointer = (
|
|
1217
|
+
"pointer"
|
|
1218
|
+
if event.style.link
|
|
1219
|
+
or (widget is not None and widget.id == "auth-prompt-advanced-toggle")
|
|
1220
|
+
else "default"
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
def on_leave(self) -> None:
|
|
1224
|
+
"""Reset the pointer shape when the mouse leaves the prompt."""
|
|
1225
|
+
self.styles.pointer = "default"
|
|
1226
|
+
|
|
1227
|
+
def on_mount(self) -> None:
|
|
1228
|
+
"""Apply ASCII border when needed."""
|
|
1229
|
+
if is_ascii_mode():
|
|
1230
|
+
container = self.query_one(Vertical)
|
|
1231
|
+
colors = theme.get_theme_colors(self)
|
|
1232
|
+
container.styles.border = ("ascii", colors.success)
|
|
1233
|
+
|
|
1234
|
+
def _resolve_langsmith_endpoint(self) -> ResolvedEndpoint:
|
|
1235
|
+
"""Resolve the endpoint to store from the LangSmith region selector.
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
A `ResolvedEndpoint`: the canonical endpoint to persist (empty for
|
|
1239
|
+
the US SaaS default), and a non-`None` error when a custom URL is
|
|
1240
|
+
missing or malformed (so an explicit `Custom` selection never
|
|
1241
|
+
silently routes the key to the US SaaS default, and the key is
|
|
1242
|
+
never paired with a non-http(s) endpoint).
|
|
1243
|
+
"""
|
|
1244
|
+
if self._region != Region.CUSTOM:
|
|
1245
|
+
# US and EU resolve to their fixed SaaS endpoints from the table
|
|
1246
|
+
# (US -> "" clears the stored endpoint back to the SaaS default).
|
|
1247
|
+
return ResolvedEndpoint(_ENDPOINT_BY_REGION[self._region], None)
|
|
1248
|
+
raw = self.query_one("#auth-prompt-base-url", Input).value.strip()
|
|
1249
|
+
if not raw:
|
|
1250
|
+
# `Custom` is a deliberate non-default choice; a blank field must not
|
|
1251
|
+
# silently fall back to the US SaaS default (which would reroute the
|
|
1252
|
+
# key and traces to the very endpoint a self-hosted user is avoiding).
|
|
1253
|
+
missing_url_error = (
|
|
1254
|
+
"Custom endpoint URL is required (or choose United States/Europe)."
|
|
1255
|
+
)
|
|
1256
|
+
return ResolvedEndpoint("", missing_url_error)
|
|
1257
|
+
endpoint = normalize_langsmith_endpoint(raw)
|
|
1258
|
+
if not is_http_url(endpoint):
|
|
1259
|
+
return ResolvedEndpoint("", "Custom endpoint must be an http(s) URL.")
|
|
1260
|
+
return ResolvedEndpoint(endpoint, None)
|
|
1261
|
+
|
|
1262
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
1263
|
+
"""Validate, persist, and dismiss.
|
|
1264
|
+
|
|
1265
|
+
Reads both fields regardless of which one was submitted, so pressing
|
|
1266
|
+
Enter in either the key or the secondary input (base URL, or the
|
|
1267
|
+
LangSmith project name) saves the pair.
|
|
1268
|
+
"""
|
|
1269
|
+
event.stop()
|
|
1270
|
+
cleaned = self.query_one("#auth-prompt-input", Input).value.strip()
|
|
1271
|
+
if self._is_langsmith:
|
|
1272
|
+
project = self.query_one("#auth-prompt-project", Input).value.strip()
|
|
1273
|
+
resolved = self._resolve_langsmith_endpoint()
|
|
1274
|
+
if resolved.error:
|
|
1275
|
+
self._show_error(resolved.error)
|
|
1276
|
+
return
|
|
1277
|
+
base_url = resolved.endpoint
|
|
1278
|
+
else:
|
|
1279
|
+
base_url = self.query_one("#auth-prompt-base-url", Input).value.strip()
|
|
1280
|
+
# Match the CLI `--base-url` guard: never pair the key with a
|
|
1281
|
+
# non-http(s) endpoint. Validate only a *changed* value, though: the
|
|
1282
|
+
# field is pre-filled with the stored base URL, so rotating just the
|
|
1283
|
+
# key must not be blocked by a legacy non-http(s) endpoint (the CLI
|
|
1284
|
+
# never validated base URLs before this feature). A new or edited
|
|
1285
|
+
# value must still be http(s).
|
|
1286
|
+
if (
|
|
1287
|
+
base_url
|
|
1288
|
+
and base_url != self._existing_base_url
|
|
1289
|
+
and not is_http_url(base_url)
|
|
1290
|
+
):
|
|
1291
|
+
self._show_error("Base URL must be an http(s) URL.")
|
|
1292
|
+
return
|
|
1293
|
+
project = ""
|
|
1294
|
+
if not cleaned:
|
|
1295
|
+
if self._allow_empty_submit:
|
|
1296
|
+
# Optional prompts (e.g. the Tavily onboarding step) treat an
|
|
1297
|
+
# empty submit as an intentional skip. We deliberately reuse
|
|
1298
|
+
# `CANCELLED` rather than add a `SKIPPED` outcome: every caller
|
|
1299
|
+
# that allows empty submit wants identical "did not save"
|
|
1300
|
+
# handling for skip and Escape, so the distinction would be
|
|
1301
|
+
# dead weight. Revisit if a caller ever needs to tell a
|
|
1302
|
+
# deliberate decline from an accidental dismissal.
|
|
1303
|
+
self.dismiss(AuthResult.CANCELLED)
|
|
1304
|
+
return
|
|
1305
|
+
self._show_error("API key cannot be empty.")
|
|
1306
|
+
return
|
|
1307
|
+
try:
|
|
1308
|
+
outcome = auth_store.set_stored_key(
|
|
1309
|
+
self._provider,
|
|
1310
|
+
cleaned,
|
|
1311
|
+
base_url=base_url or None,
|
|
1312
|
+
project=project or None,
|
|
1313
|
+
)
|
|
1314
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
1315
|
+
# `auth_store` exception messages never include the secret value,
|
|
1316
|
+
# but the path can include user-controlled `DEFAULT_STATE_DIR`
|
|
1317
|
+
# bytes — render via `Content.from_markup` so a `[` in the path
|
|
1318
|
+
# can't break Textual's markup pipeline.
|
|
1319
|
+
logger.warning(
|
|
1320
|
+
"Failed to persist credential for %s: %s", self._provider, exc
|
|
1321
|
+
)
|
|
1322
|
+
self._show_error("Could not save credential: $exc", exc=str(exc))
|
|
1323
|
+
return
|
|
1324
|
+
for warning in outcome.warnings:
|
|
1325
|
+
# chmod failures are security regressions the user must see —
|
|
1326
|
+
# `logger.warning` alone is invisible inside a Textual session.
|
|
1327
|
+
self.app.notify(warning, severity="warning", markup=False)
|
|
1328
|
+
if self._is_langsmith:
|
|
1329
|
+
apply_stored_langsmith_auth(replace_project=True)
|
|
1330
|
+
clear_caches()
|
|
1331
|
+
if not outcome.warnings:
|
|
1332
|
+
# Only claim a clean save when the store locked the file down. When
|
|
1333
|
+
# chmod warnings fired above, they *are* the outcome message — an
|
|
1334
|
+
# extra "success" toast on top would compete with (and visually
|
|
1335
|
+
# bury) the one signal that the key isn't secured.
|
|
1336
|
+
provider_label = provider_display_name(self._provider, self._config)
|
|
1337
|
+
# `markup=False`: a configured display name can contain markup
|
|
1338
|
+
# metacharacters (e.g. `[`) that must not be interpreted here. The
|
|
1339
|
+
# same guard applies to every interpolated toast below.
|
|
1340
|
+
self.app.notify(
|
|
1341
|
+
f"Successfully saved key for {provider_label}.",
|
|
1342
|
+
severity="information",
|
|
1343
|
+
markup=False,
|
|
1344
|
+
)
|
|
1345
|
+
self.dismiss(AuthResult.SAVED)
|
|
1346
|
+
|
|
1347
|
+
def action_cancel(self) -> None:
|
|
1348
|
+
"""Dismiss without saving."""
|
|
1349
|
+
self.dismiss(AuthResult.CANCELLED)
|
|
1350
|
+
|
|
1351
|
+
async def action_reload_env(self) -> None:
|
|
1352
|
+
"""Re-read .env/environment, re-probe credentials, and continue.
|
|
1353
|
+
|
|
1354
|
+
Runs the same environment/credential reload core as the `/reload`
|
|
1355
|
+
command (`reload_from_environment` + `clear_caches`), so a credential
|
|
1356
|
+
env var added to a `.env` file after launch is picked up without a full
|
|
1357
|
+
restart; it intentionally skips `/reload`'s theme and skill
|
|
1358
|
+
re-discovery. If the provider was blocked and is now resolvable, dismiss
|
|
1359
|
+
with SAVED so the caller retries the original operation; otherwise
|
|
1360
|
+
refresh the modal in place and toast the outcome.
|
|
1361
|
+
"""
|
|
1362
|
+
from deepagents_code.config import settings
|
|
1363
|
+
|
|
1364
|
+
try:
|
|
1365
|
+
settings.reload_from_environment()
|
|
1366
|
+
clear_caches()
|
|
1367
|
+
except (OSError, ValueError) as exc:
|
|
1368
|
+
logger.warning("Failed to reload configuration from auth prompt: %s", exc)
|
|
1369
|
+
self.app.notify(
|
|
1370
|
+
"Could not reload configuration. Check your .env file and "
|
|
1371
|
+
"environment variables for syntax errors, then try again.",
|
|
1372
|
+
severity="error",
|
|
1373
|
+
markup=False,
|
|
1374
|
+
)
|
|
1375
|
+
return
|
|
1376
|
+
|
|
1377
|
+
was_blocking = self._auth_status.blocks_start
|
|
1378
|
+
self._probe_credential_state()
|
|
1379
|
+
|
|
1380
|
+
if was_blocking and not self._auth_status.blocks_start:
|
|
1381
|
+
self.app.notify(
|
|
1382
|
+
f"Credentials detected for {self._provider}. Continuing.",
|
|
1383
|
+
markup=False,
|
|
1384
|
+
)
|
|
1385
|
+
self.dismiss(AuthResult.SAVED)
|
|
1386
|
+
return
|
|
1387
|
+
|
|
1388
|
+
# The reload may have added/removed a LangSmith endpoint env var; refresh
|
|
1389
|
+
# the precedence notice so the recompose below doesn't render a stale one.
|
|
1390
|
+
self._refresh_endpoint_env_notice()
|
|
1391
|
+
|
|
1392
|
+
# Preserve any in-progress input across the recompose so a reload never
|
|
1393
|
+
# discards values the user already started typing.
|
|
1394
|
+
before = {inp.id: inp.value for inp in self.query(Input)}
|
|
1395
|
+
await self.recompose()
|
|
1396
|
+
for inp in self.query(Input):
|
|
1397
|
+
if inp.id in before:
|
|
1398
|
+
inp.value = before[inp.id]
|
|
1399
|
+
self.query_one("#auth-prompt-input", Input).focus()
|
|
1400
|
+
|
|
1401
|
+
if was_blocking:
|
|
1402
|
+
self.app.notify(
|
|
1403
|
+
"No credentials detected. Set a key in a .env file, then press "
|
|
1404
|
+
"Ctrl+R. A variable exported in a separate shell needs a full "
|
|
1405
|
+
"relaunch to take effect.",
|
|
1406
|
+
markup=False,
|
|
1407
|
+
)
|
|
1408
|
+
else:
|
|
1409
|
+
self.app.notify("Environment reloaded.", markup=False)
|
|
1410
|
+
|
|
1411
|
+
def action_delete_stored(self) -> None:
|
|
1412
|
+
"""Open the delete-confirmation overlay, or quit when nothing is stored.
|
|
1413
|
+
|
|
1414
|
+
Ctrl+D deletes a stored credential, but its `priority` binding also
|
|
1415
|
+
intercepts the app-level Ctrl+D=quit. When there's no credential to
|
|
1416
|
+
delete, fall through to quit rather than swallowing the key (mirroring
|
|
1417
|
+
the thread selector). `app.exit()` is used instead of `dismiss()`, which
|
|
1418
|
+
would just close the modal silently and re-swallow the key.
|
|
1419
|
+
"""
|
|
1420
|
+
if not self._has_existing:
|
|
1421
|
+
self.app.exit()
|
|
1422
|
+
return
|
|
1423
|
+
self.app.push_screen(
|
|
1424
|
+
DeleteCredentialConfirmScreen(self._provider),
|
|
1425
|
+
self._on_delete_confirmed,
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
def _on_delete_confirmed(self, confirmed: bool | None) -> None:
|
|
1429
|
+
"""Handle the result of the confirmation overlay.
|
|
1430
|
+
|
|
1431
|
+
Args:
|
|
1432
|
+
confirmed: `True` if the user pressed Enter, `False` on Esc.
|
|
1433
|
+
"""
|
|
1434
|
+
if not confirmed:
|
|
1435
|
+
return
|
|
1436
|
+
try:
|
|
1437
|
+
result = auth_store.delete_stored_key(self._provider)
|
|
1438
|
+
except RuntimeError as exc:
|
|
1439
|
+
logger.warning(
|
|
1440
|
+
"Failed to delete credential for %s: %s", self._provider, exc
|
|
1441
|
+
)
|
|
1442
|
+
self._show_error("Could not delete credential: $exc", exc=str(exc))
|
|
1443
|
+
return
|
|
1444
|
+
for warning in result.warnings:
|
|
1445
|
+
# The rewritten store still holds other providers' secrets, so a
|
|
1446
|
+
# chmod failure here is the same security regression as on save —
|
|
1447
|
+
# surface it rather than dropping it on the floor.
|
|
1448
|
+
self.app.notify(warning, severity="warning", markup=False)
|
|
1449
|
+
# Toast after `clear_caches` (like the save path) so the confirmation
|
|
1450
|
+
# reflects fully-settled state rather than firing before the cache is
|
|
1451
|
+
# invalidated.
|
|
1452
|
+
clear_caches()
|
|
1453
|
+
if not result.removed:
|
|
1454
|
+
# The entry was gone — likely a concurrent delete from another
|
|
1455
|
+
# app instance. Surface that fact so "delete" UX doesn't lie when
|
|
1456
|
+
# nothing actually happened on disk.
|
|
1457
|
+
provider_label = provider_display_name(self._provider, self._config)
|
|
1458
|
+
self.app.notify(
|
|
1459
|
+
f"No stored credential for {provider_label} — already removed.",
|
|
1460
|
+
severity="information",
|
|
1461
|
+
markup=False,
|
|
1462
|
+
)
|
|
1463
|
+
elif not result.warnings:
|
|
1464
|
+
# Mirror the save path: a silent successful delete gives no
|
|
1465
|
+
# confirmation, and the toast is suppressed when warnings fired.
|
|
1466
|
+
provider_label = provider_display_name(self._provider, self._config)
|
|
1467
|
+
self.app.notify(
|
|
1468
|
+
f"Successfully removed key for {provider_label}.",
|
|
1469
|
+
severity="information",
|
|
1470
|
+
markup=False,
|
|
1471
|
+
)
|
|
1472
|
+
self.dismiss(AuthResult.DELETED)
|
|
1473
|
+
|
|
1474
|
+
def _show_error(self, template: str, /, **substitutions: str) -> None:
|
|
1475
|
+
"""Render `template` via markup substitution in the inline error slot.
|
|
1476
|
+
|
|
1477
|
+
Args:
|
|
1478
|
+
template: Markup template (e.g. `"Could not save: $exc"`).
|
|
1479
|
+
**substitutions: `$name` substitution values; Textual escapes them.
|
|
1480
|
+
"""
|
|
1481
|
+
error = self.query_one("#auth-prompt-error", Static)
|
|
1482
|
+
error.update(Content.from_markup(template, **substitutions))
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
class AuthManagerScreen(ModalScreen[None]):
|
|
1486
|
+
"""Modal that lists configured providers and lets the user manage keys.
|
|
1487
|
+
|
|
1488
|
+
Reachable via the `/auth` slash command. Always dismisses with `None`;
|
|
1489
|
+
state changes are persisted by `AuthPromptScreen` and reflected by
|
|
1490
|
+
re-rendering the option list when this screen is reopened or after a
|
|
1491
|
+
save/delete completes.
|
|
1492
|
+
|
|
1493
|
+
Well-known providers whose integration package isn't installed yet are
|
|
1494
|
+
surfaced greyed-out so they stay discoverable. Selecting one routes
|
|
1495
|
+
through an install confirmation: on confirm the screen records the extra on
|
|
1496
|
+
`pending_install_extra` and dismisses so the app can install it (mirroring
|
|
1497
|
+
the model selector's install-on-select flow) and reopen the manager.
|
|
1498
|
+
"""
|
|
1499
|
+
|
|
1500
|
+
class CredentialSaved(Message):
|
|
1501
|
+
"""Posted when a key prompt successfully persists credentials.
|
|
1502
|
+
|
|
1503
|
+
Carries the `/auth` config key that was saved so the app can react to
|
|
1504
|
+
credentials that gate spawn-time behavior — e.g. a Tavily key that
|
|
1505
|
+
enables the `web_search` tool only after the server respawns.
|
|
1506
|
+
"""
|
|
1507
|
+
|
|
1508
|
+
def __init__(self, provider: str) -> None:
|
|
1509
|
+
"""Store the saved provider/service identifier.
|
|
1510
|
+
|
|
1511
|
+
Args:
|
|
1512
|
+
provider: The `/auth` config key that was saved (a model
|
|
1513
|
+
provider name or a service key such as `"tavily"`).
|
|
1514
|
+
"""
|
|
1515
|
+
super().__init__()
|
|
1516
|
+
self.provider = provider
|
|
1517
|
+
|
|
1518
|
+
class CredentialDeleted(Message):
|
|
1519
|
+
"""Posted when a key prompt deletes stored credentials.
|
|
1520
|
+
|
|
1521
|
+
Carries the `/auth` config key that was deleted so the app can clear
|
|
1522
|
+
any in-memory state derived from the now-removed credential.
|
|
1523
|
+
"""
|
|
1524
|
+
|
|
1525
|
+
def __init__(self, provider: str) -> None:
|
|
1526
|
+
"""Store the deleted provider/service identifier.
|
|
1527
|
+
|
|
1528
|
+
Args:
|
|
1529
|
+
provider: The `/auth` config key that was deleted (a model
|
|
1530
|
+
provider name or a service key such as `"tavily"`).
|
|
1531
|
+
"""
|
|
1532
|
+
super().__init__()
|
|
1533
|
+
self.provider = provider
|
|
1534
|
+
|
|
1535
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
1536
|
+
Binding("escape", "cancel", "Close", show=False, priority=True),
|
|
1537
|
+
Binding("tab", "cursor_down", "Next", show=False, priority=True),
|
|
1538
|
+
Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
|
|
1539
|
+
]
|
|
1540
|
+
|
|
1541
|
+
CSS = """
|
|
1542
|
+
AuthManagerScreen {
|
|
1543
|
+
align: center middle;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
AuthManagerScreen > Vertical {
|
|
1547
|
+
width: 76;
|
|
1548
|
+
max-width: 90%;
|
|
1549
|
+
height: 80%;
|
|
1550
|
+
background: $surface;
|
|
1551
|
+
border: solid $primary;
|
|
1552
|
+
padding: 1 2;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
AuthManagerScreen .auth-manager-title {
|
|
1556
|
+
text-style: bold;
|
|
1557
|
+
color: $primary;
|
|
1558
|
+
text-align: center;
|
|
1559
|
+
margin-bottom: 1;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
AuthManagerScreen .auth-manager-copy {
|
|
1563
|
+
height: auto;
|
|
1564
|
+
color: $text-muted;
|
|
1565
|
+
margin-bottom: 1;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/* `1fr` + `min-height` keeps the option list from pushing the footer
|
|
1569
|
+
off-screen on short terminals: the list shrinks (and starts scrolling)
|
|
1570
|
+
before the footer is hidden. */
|
|
1571
|
+
AuthManagerScreen OptionList {
|
|
1572
|
+
height: 1fr;
|
|
1573
|
+
min-height: 3;
|
|
1574
|
+
background: $background;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
AuthManagerScreen .auth-manager-warning {
|
|
1578
|
+
height: auto;
|
|
1579
|
+
color: $warning;
|
|
1580
|
+
margin-bottom: 1;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
AuthManagerScreen .auth-manager-help {
|
|
1584
|
+
height: auto;
|
|
1585
|
+
color: $text-muted;
|
|
1586
|
+
text-style: italic;
|
|
1587
|
+
text-align: center;
|
|
1588
|
+
margin-top: 1;
|
|
1589
|
+
}
|
|
1590
|
+
"""
|
|
1591
|
+
|
|
1592
|
+
def __init__(self, *, initial_provider: str | None = None) -> None:
|
|
1593
|
+
"""Initialize the manager with an empty install-on-select registry.
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
initial_provider: Provider whose row should start highlighted —
|
|
1597
|
+
set when reopening after an install-on-select so the cursor
|
|
1598
|
+
lands on the just-installed provider ready for a key, rather
|
|
1599
|
+
than resetting to the top of the list.
|
|
1600
|
+
"""
|
|
1601
|
+
super().__init__()
|
|
1602
|
+
# Uninstalled known providers mapped to the extra that installs them,
|
|
1603
|
+
# populated each time the option list is built. Selecting one routes
|
|
1604
|
+
# to the install confirmation instead of the key prompt.
|
|
1605
|
+
self._install_extras: dict[str, str] = {}
|
|
1606
|
+
# Set when the user confirms installing a provider's extra; the app
|
|
1607
|
+
# reads these off the screen after dismissal to install then reopen
|
|
1608
|
+
# the manager with the just-installed provider highlighted.
|
|
1609
|
+
self.pending_install_extra: str | None = None
|
|
1610
|
+
self.pending_install_provider: str | None = None
|
|
1611
|
+
self._initial_provider = initial_provider
|
|
1612
|
+
|
|
1613
|
+
def compose(self) -> ComposeResult:
|
|
1614
|
+
"""Compose the manager.
|
|
1615
|
+
|
|
1616
|
+
Yields:
|
|
1617
|
+
Widgets for the manager listing.
|
|
1618
|
+
"""
|
|
1619
|
+
glyphs = get_glyphs()
|
|
1620
|
+
options, store_warning = self._build_options_with_warning()
|
|
1621
|
+
with Vertical():
|
|
1622
|
+
yield Static("Manage API keys", classes="auth-manager-title")
|
|
1623
|
+
yield Static(self._build_description(), classes="auth-manager-copy")
|
|
1624
|
+
if store_warning:
|
|
1625
|
+
# Surface auth.json corruption directly — `_build_options`
|
|
1626
|
+
# falling back silently used to make a corrupt file look
|
|
1627
|
+
# identical to "no keys stored".
|
|
1628
|
+
yield Static(
|
|
1629
|
+
Content.from_markup("$msg", msg=store_warning),
|
|
1630
|
+
classes="auth-manager-warning",
|
|
1631
|
+
)
|
|
1632
|
+
yield OptionList(*options, id="auth-manager-options")
|
|
1633
|
+
yield Static(
|
|
1634
|
+
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab "
|
|
1635
|
+
f"navigate {glyphs.bullet} Enter add/replace/delete/install "
|
|
1636
|
+
f"{glyphs.bullet} Esc close",
|
|
1637
|
+
classes="auth-manager-help",
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
def _build_description(self) -> Content:
|
|
1641
|
+
"""Build the description line with an inline docs hyperlink.
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
Description content. Themes other than the ANSI palette render
|
|
1645
|
+
the link in the primary color so it reads as clickable; ANSI
|
|
1646
|
+
users get a bold-only treatment that still reaches the
|
|
1647
|
+
terminal's link handler via `Style(link=...)`.
|
|
1648
|
+
"""
|
|
1649
|
+
colors = theme.get_theme_colors(self)
|
|
1650
|
+
ansi = self.app.theme in {"ansi-dark", "ansi-light"}
|
|
1651
|
+
link_style: str | TStyle = (
|
|
1652
|
+
TStyle(bold=True, link=_PROVIDERS_DOCS_URL)
|
|
1653
|
+
if ansi
|
|
1654
|
+
else TStyle(
|
|
1655
|
+
foreground=TColor.parse(colors.primary),
|
|
1656
|
+
link=_PROVIDERS_DOCS_URL,
|
|
1657
|
+
)
|
|
1658
|
+
)
|
|
1659
|
+
return Content.assemble(
|
|
1660
|
+
"Lists installed model providers, services like web search, and any "
|
|
1661
|
+
"providers you've configured in ~/.deepagents/config.toml. Greyed-out "
|
|
1662
|
+
"providers aren't installed yet — select one to install it. ",
|
|
1663
|
+
("Docs", link_style),
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
def on_mount(self) -> None:
|
|
1667
|
+
"""Apply ASCII border and highlight the initial provider when set."""
|
|
1668
|
+
if is_ascii_mode():
|
|
1669
|
+
container = self.query_one(Vertical)
|
|
1670
|
+
colors = theme.get_theme_colors(self)
|
|
1671
|
+
container.styles.border = ("ascii", colors.success)
|
|
1672
|
+
self._highlight_initial_provider()
|
|
1673
|
+
|
|
1674
|
+
def _highlight_initial_provider(self) -> None:
|
|
1675
|
+
"""Move the cursor to `initial_provider`'s row if it is listed.
|
|
1676
|
+
|
|
1677
|
+
Used when the manager reopens after an install-on-select so the cursor
|
|
1678
|
+
lands on the just-installed provider (ready for a key) instead of
|
|
1679
|
+
resetting to the top of the list.
|
|
1680
|
+
"""
|
|
1681
|
+
if self._initial_provider is None:
|
|
1682
|
+
return
|
|
1683
|
+
option_list = self.query_one("#auth-manager-options", OptionList)
|
|
1684
|
+
try:
|
|
1685
|
+
index = option_list.get_option_index(self._initial_provider)
|
|
1686
|
+
except OptionDoesNotExist:
|
|
1687
|
+
return
|
|
1688
|
+
option_list.highlighted = index
|
|
1689
|
+
option_list.scroll_to_highlight()
|
|
1690
|
+
|
|
1691
|
+
def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler
|
|
1692
|
+
"""Open style-embedded hyperlinks (the title `Docs` link)."""
|
|
1693
|
+
open_style_link(event)
|
|
1694
|
+
|
|
1695
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
1696
|
+
"""Show a pointer over inline docs links."""
|
|
1697
|
+
self.styles.pointer = "pointer" if event.style.link else "default"
|
|
1698
|
+
|
|
1699
|
+
def on_leave(self) -> None:
|
|
1700
|
+
"""Reset the pointer shape when the mouse leaves the manager."""
|
|
1701
|
+
self.styles.pointer = "default"
|
|
1702
|
+
|
|
1703
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1704
|
+
"""Open the prompt for the selected provider.
|
|
1705
|
+
|
|
1706
|
+
Greyed-out (uninstalled) providers route to an install confirmation
|
|
1707
|
+
instead of the key prompt, since their package must be installed before
|
|
1708
|
+
a credential is useful.
|
|
1709
|
+
"""
|
|
1710
|
+
provider = event.option.id
|
|
1711
|
+
if not provider:
|
|
1712
|
+
return
|
|
1713
|
+
extra = self._install_extras.get(provider)
|
|
1714
|
+
if extra is not None:
|
|
1715
|
+
self._prompt_install_provider(provider, extra)
|
|
1716
|
+
return
|
|
1717
|
+
if provider == CODEX_PROVIDER:
|
|
1718
|
+
# ChatGPT auth uses an OAuth browser flow, not an API key. The
|
|
1719
|
+
# selector dispatches to a dedicated modal that already knows
|
|
1720
|
+
# how to surface the authorize URL inline (so headless / SSH
|
|
1721
|
+
# users can still paste it manually) and run the loopback
|
|
1722
|
+
# callback wait on a worker.
|
|
1723
|
+
self._open_codex_screen()
|
|
1724
|
+
return
|
|
1725
|
+
if is_service(provider):
|
|
1726
|
+
# Services (e.g. Tavily web search) use a plain API key, stored the
|
|
1727
|
+
# same way as a model-provider key.
|
|
1728
|
+
self.app.push_screen(
|
|
1729
|
+
AuthPromptScreen(provider, SERVICE_API_KEY_ENV[provider]),
|
|
1730
|
+
partial(self._on_prompt_closed, provider),
|
|
1731
|
+
)
|
|
1732
|
+
return
|
|
1733
|
+
env_var = get_credential_env_var(provider)
|
|
1734
|
+
self.app.push_screen(
|
|
1735
|
+
AuthPromptScreen(provider, env_var),
|
|
1736
|
+
partial(self._on_prompt_closed, provider),
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
def _prompt_install_provider(self, provider: str, extra: str) -> None:
|
|
1740
|
+
"""Confirm installing an uninstalled provider's extra, then dismiss.
|
|
1741
|
+
|
|
1742
|
+
On confirm, record the extra on `pending_install_extra` and dismiss so
|
|
1743
|
+
the app can install it (with a server restart) and reopen the manager
|
|
1744
|
+
with the provider now installed. On cancel, stay on the manager.
|
|
1745
|
+
|
|
1746
|
+
Args:
|
|
1747
|
+
provider: The uninstalled provider the user selected.
|
|
1748
|
+
extra: The `deepagents-code` extra that installs `provider`.
|
|
1749
|
+
"""
|
|
1750
|
+
from deepagents_code.tui.widgets.install_confirm import (
|
|
1751
|
+
InstallProviderConfirmScreen,
|
|
1752
|
+
)
|
|
1753
|
+
|
|
1754
|
+
def _on_confirm(proceed: bool | None) -> None:
|
|
1755
|
+
if proceed:
|
|
1756
|
+
self.pending_install_extra = extra
|
|
1757
|
+
self.pending_install_provider = provider
|
|
1758
|
+
self.dismiss(None)
|
|
1759
|
+
else:
|
|
1760
|
+
# Declined or dismissed: clear any pending request so a reused
|
|
1761
|
+
# screen never carries a stale install request, and stay put.
|
|
1762
|
+
self.pending_install_extra = None
|
|
1763
|
+
self.pending_install_provider = None
|
|
1764
|
+
|
|
1765
|
+
self.app.push_screen(
|
|
1766
|
+
InstallProviderConfirmScreen(provider, extra),
|
|
1767
|
+
_on_confirm,
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
def _open_codex_screen(self) -> None:
|
|
1771
|
+
"""Push the ChatGPT OAuth flow modal and refresh on close.
|
|
1772
|
+
|
|
1773
|
+
When `openai_codex` is already signed in, give the user a chance to
|
|
1774
|
+
sign out before launching a fresh sign-in flow. Otherwise just run
|
|
1775
|
+
the sign-in worker.
|
|
1776
|
+
"""
|
|
1777
|
+
from deepagents_code.integrations import openai_codex
|
|
1778
|
+
from deepagents_code.tui.widgets.codex_auth import (
|
|
1779
|
+
CodexAuthScreen,
|
|
1780
|
+
CodexSignedInScreen,
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
status = openai_codex.get_status()
|
|
1784
|
+
if status.logged_in and not status.is_expired:
|
|
1785
|
+
self.app.push_screen(
|
|
1786
|
+
CodexSignedInScreen(),
|
|
1787
|
+
self._on_codex_signed_in_closed,
|
|
1788
|
+
)
|
|
1789
|
+
return
|
|
1790
|
+
self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
|
|
1791
|
+
|
|
1792
|
+
def _on_codex_closed(self, _result: bool | None) -> None:
|
|
1793
|
+
"""Refresh the option list once the codex flow dismisses."""
|
|
1794
|
+
clear_caches()
|
|
1795
|
+
self._refresh_options()
|
|
1796
|
+
|
|
1797
|
+
def _on_codex_signed_in_closed(self, action: CodexSignedInAction | None) -> None:
|
|
1798
|
+
"""Handle dismissal of the "already signed in" overlay.
|
|
1799
|
+
|
|
1800
|
+
Args:
|
|
1801
|
+
action: `SIGN_OUT` to clear the token, `REAUTH` to run the
|
|
1802
|
+
sign-in flow again, `None` to close cleanly.
|
|
1803
|
+
"""
|
|
1804
|
+
from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
|
|
1805
|
+
|
|
1806
|
+
if action is CodexSignedInAction.SIGN_OUT:
|
|
1807
|
+
from deepagents_code.integrations import openai_codex
|
|
1808
|
+
|
|
1809
|
+
removed = openai_codex.logout()
|
|
1810
|
+
if removed:
|
|
1811
|
+
self.app.notify("Signed out of ChatGPT.", markup=False)
|
|
1812
|
+
clear_caches()
|
|
1813
|
+
self._refresh_options()
|
|
1814
|
+
elif action is CodexSignedInAction.REAUTH:
|
|
1815
|
+
from deepagents_code.tui.widgets.codex_auth import CodexAuthScreen
|
|
1816
|
+
|
|
1817
|
+
self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
|
|
1818
|
+
else:
|
|
1819
|
+
self._refresh_options()
|
|
1820
|
+
|
|
1821
|
+
def action_cancel(self) -> None:
|
|
1822
|
+
"""Close the manager."""
|
|
1823
|
+
self.dismiss(None)
|
|
1824
|
+
|
|
1825
|
+
def action_cursor_down(self) -> None:
|
|
1826
|
+
"""Move the option-list cursor down."""
|
|
1827
|
+
self.query_one("#auth-manager-options", OptionList).action_cursor_down()
|
|
1828
|
+
|
|
1829
|
+
def action_cursor_up(self) -> None:
|
|
1830
|
+
"""Move the option-list cursor up."""
|
|
1831
|
+
self.query_one("#auth-manager-options", OptionList).action_cursor_up()
|
|
1832
|
+
|
|
1833
|
+
def _on_prompt_closed(self, provider: str, result: AuthResult | None) -> None:
|
|
1834
|
+
"""Refresh the option list once the prompt dismisses.
|
|
1835
|
+
|
|
1836
|
+
Args:
|
|
1837
|
+
provider: The provider/service whose prompt just closed.
|
|
1838
|
+
result: Outcome of the prompt interaction.
|
|
1839
|
+
"""
|
|
1840
|
+
self._refresh_options()
|
|
1841
|
+
if result is AuthResult.SAVED:
|
|
1842
|
+
self.post_message(self.CredentialSaved(provider))
|
|
1843
|
+
elif result is AuthResult.DELETED:
|
|
1844
|
+
self.post_message(self.CredentialDeleted(provider))
|
|
1845
|
+
|
|
1846
|
+
def _refresh_options(self) -> None:
|
|
1847
|
+
"""Rebuild option labels from current store state."""
|
|
1848
|
+
option_list = self.query_one("#auth-manager-options", OptionList)
|
|
1849
|
+
highlighted = option_list.highlighted
|
|
1850
|
+
option_list.clear_options()
|
|
1851
|
+
options, _ = self._build_options_with_warning()
|
|
1852
|
+
for option in options:
|
|
1853
|
+
option_list.add_option(option)
|
|
1854
|
+
if highlighted is not None and option_list.option_count:
|
|
1855
|
+
option_list.highlighted = min(highlighted, option_list.option_count - 1)
|
|
1856
|
+
|
|
1857
|
+
def _build_options_with_warning(self) -> tuple[list[Option], str | None]:
|
|
1858
|
+
"""Render the option list, returning a corruption warning if any.
|
|
1859
|
+
|
|
1860
|
+
Returns:
|
|
1861
|
+
`(options, warning_message)`. `warning_message` is `None` when
|
|
1862
|
+
the credential file is readable; otherwise a one-line hint
|
|
1863
|
+
telling the user the file is unreadable so a corrupt store
|
|
1864
|
+
doesn't silently look identical to "no keys stored".
|
|
1865
|
+
"""
|
|
1866
|
+
warning: str | None = None
|
|
1867
|
+
try:
|
|
1868
|
+
stored = set(auth_store.list_configured_providers())
|
|
1869
|
+
except RuntimeError as exc:
|
|
1870
|
+
logger.warning("Failed to list stored credentials: %s", exc)
|
|
1871
|
+
stored = set()
|
|
1872
|
+
warning = (
|
|
1873
|
+
f"Credential file is unreadable ({exc}). "
|
|
1874
|
+
"Saving a key here will overwrite it."
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
config = ModelConfig.load()
|
|
1878
|
+
config_providers = {
|
|
1879
|
+
name for name, cfg in config.providers.items() if cfg.get("api_key_env")
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
# Only show well-known providers whose LangChain package is actually
|
|
1883
|
+
# installed. `get_available_models` returns providers it could
|
|
1884
|
+
# successfully import profiles for, so it doubles as an install
|
|
1885
|
+
# gate. Stored and config-defined providers are always shown — even
|
|
1886
|
+
# if the package was later uninstalled — so a stale credential can
|
|
1887
|
+
# still be cleaned up and an explicitly-declared provider stays
|
|
1888
|
+
# visible.
|
|
1889
|
+
installed = set(get_available_models().keys())
|
|
1890
|
+
well_known_installed = set(PROVIDER_API_KEY_ENV) & installed
|
|
1891
|
+
# `openai_codex` is gated on `langchain-openai` being installed (we
|
|
1892
|
+
# surface it whenever `openai` was discovered) rather than on
|
|
1893
|
+
# `PROVIDER_API_KEY_ENV`, since it has no env var of its own.
|
|
1894
|
+
codex_installed = {CODEX_PROVIDER} if "openai" in installed else set()
|
|
1895
|
+
|
|
1896
|
+
shown = well_known_installed | codex_installed | stored | config_providers
|
|
1897
|
+
# Surface well-known providers whose package isn't installed yet as
|
|
1898
|
+
# greyed-out, install-on-select entries so they stay
|
|
1899
|
+
# discoverable (mirrors the model selector). Disabled providers and
|
|
1900
|
+
# ones already shown above are skipped.
|
|
1901
|
+
self._install_extras = self._uninstalled_known_providers(config, shown)
|
|
1902
|
+
|
|
1903
|
+
# Resolve each manageable entry's auth status once and reuse it for
|
|
1904
|
+
# both ordering and badge rendering. `_auth_status_for` reads the
|
|
1905
|
+
# credential file, so resolving it separately in the sort key and in
|
|
1906
|
+
# `_format_label` would read `auth.json` twice per row (and, on a
|
|
1907
|
+
# corrupt store, log the same warning twice). A single pass halves both.
|
|
1908
|
+
services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras)
|
|
1909
|
+
status_by_key = {key: _auth_status_for(key) for key in shown | services}
|
|
1910
|
+
|
|
1911
|
+
# Float entries that already have a credential configured to the top so
|
|
1912
|
+
# the keys a user is actively using are easiest to find; everything else
|
|
1913
|
+
# keeps alphabetical order (the `key` tiebreaker). Uninstalled
|
|
1914
|
+
# install-on-select entries are listed afterwards (alphabetically) since
|
|
1915
|
+
# selecting them installs a package rather than managing a key.
|
|
1916
|
+
def sort_key(key: str) -> tuple[int, str]:
|
|
1917
|
+
configured = status_by_key[key].state is ProviderAuthState.CONFIGURED
|
|
1918
|
+
return (0 if configured else 1, key)
|
|
1919
|
+
|
|
1920
|
+
manageable = sorted(status_by_key, key=sort_key)
|
|
1921
|
+
extra_providers = sorted(self._install_extras)
|
|
1922
|
+
options = [
|
|
1923
|
+
Option(self._format_label(key, status=status_by_key[key]), id=key)
|
|
1924
|
+
for key in manageable
|
|
1925
|
+
]
|
|
1926
|
+
options.extend(
|
|
1927
|
+
Option(self._format_label(provider, installed=False), id=provider)
|
|
1928
|
+
for provider in extra_providers
|
|
1929
|
+
)
|
|
1930
|
+
return options, warning
|
|
1931
|
+
|
|
1932
|
+
@staticmethod
|
|
1933
|
+
def _uninstalled_known_providers(
|
|
1934
|
+
config: ModelConfig, shown: set[str]
|
|
1935
|
+
) -> dict[str, str]:
|
|
1936
|
+
"""Map known providers missing their package to the installing extra.
|
|
1937
|
+
|
|
1938
|
+
Args:
|
|
1939
|
+
config: Loaded model config, used to skip disabled providers.
|
|
1940
|
+
shown: Providers already listed (installed/stored/config) to skip.
|
|
1941
|
+
|
|
1942
|
+
Returns:
|
|
1943
|
+
`{provider: extra}` for each well-known, enabled provider whose
|
|
1944
|
+
integration package is not installed and has a curated extra.
|
|
1945
|
+
"""
|
|
1946
|
+
from deepagents_code.config_manifest import (
|
|
1947
|
+
is_provider_package_installed,
|
|
1948
|
+
provider_install_extra,
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
uninstalled: dict[str, str] = {}
|
|
1952
|
+
for provider in PROVIDER_API_KEY_ENV:
|
|
1953
|
+
if provider in shown or not config.is_provider_enabled(provider):
|
|
1954
|
+
continue
|
|
1955
|
+
extra = provider_install_extra(provider)
|
|
1956
|
+
if extra is None or is_provider_package_installed(provider):
|
|
1957
|
+
continue
|
|
1958
|
+
uninstalled[provider] = extra
|
|
1959
|
+
return uninstalled
|
|
1960
|
+
|
|
1961
|
+
@staticmethod
|
|
1962
|
+
def _format_label(
|
|
1963
|
+
provider: str,
|
|
1964
|
+
*,
|
|
1965
|
+
installed: bool = True,
|
|
1966
|
+
status: ProviderAuthStatus | None = None,
|
|
1967
|
+
) -> Content:
|
|
1968
|
+
"""Build a `Content` label for `provider` showing its credential source.
|
|
1969
|
+
|
|
1970
|
+
Args:
|
|
1971
|
+
provider: Provider config key.
|
|
1972
|
+
installed: Whether the provider's integration package is installed.
|
|
1973
|
+
Uninstalled providers render dimmed with a `[not installed]`
|
|
1974
|
+
marker since selecting them prompts an install, not a key.
|
|
1975
|
+
status: Precomputed auth status to render. Pass this when the
|
|
1976
|
+
caller already resolved it to avoid a duplicate credential-file
|
|
1977
|
+
read; resolved on demand when omitted. Ignored for uninstalled
|
|
1978
|
+
providers, which render no badge.
|
|
1979
|
+
|
|
1980
|
+
Returns:
|
|
1981
|
+
A composed `Content` with the provider label and a status badge.
|
|
1982
|
+
"""
|
|
1983
|
+
name = provider_display_name(provider)
|
|
1984
|
+
if not installed:
|
|
1985
|
+
return Content.assemble(
|
|
1986
|
+
Content.styled(name, "dim"),
|
|
1987
|
+
" ",
|
|
1988
|
+
Content.styled("[not installed]", "dim"),
|
|
1989
|
+
)
|
|
1990
|
+
if status is None:
|
|
1991
|
+
status = _auth_status_for(provider)
|
|
1992
|
+
badge = format_auth_badge(status)
|
|
1993
|
+
return Content.assemble(
|
|
1994
|
+
Content.from_markup("$provider", provider=name),
|
|
1995
|
+
" ",
|
|
1996
|
+
badge,
|
|
1997
|
+
)
|