mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-26 18:16:58 -04:00
85e47b532b
Fixed the `/auth` manager so that after installing a provider it reopens with the just-installed provider highlighted instead of resetting to the top of the list. Made by [Open SWE](https://openswe.vercel.app/agents/d1b42eb9-0efa-0224-dcc2-c71ad53093cd) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
1550 lines
59 KiB
Python
1550 lines
59 KiB
Python
"""TUI screens for managing stored model-provider credentials.
|
|
|
|
`AuthPromptScreen` accepts an API key for a single provider, persists it via
|
|
`auth_store`, and is the sole place that deletes existing credentials (after
|
|
a `DeleteCredentialConfirmScreen` confirmation). `AuthManagerScreen` lists
|
|
known providers and routes the user into the prompt; it does not delete
|
|
directly. Both are reachable via the `/auth` slash command.
|
|
|
|
Security notes:
|
|
|
|
- Inputs are rendered with `password=True` so the key is never echoed to
|
|
the terminal.
|
|
- This module never logs the key value, never includes it in `notify()`
|
|
payloads, and never round-trips it through Rich markup. Callers that
|
|
introduce new logging here must do the same.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from enum import StrEnum
|
|
from typing import TYPE_CHECKING, ClassVar
|
|
from urllib.parse import urlsplit
|
|
|
|
from textual.binding import Binding, BindingType
|
|
from textual.color import Color as TColor
|
|
from textual.containers import Vertical
|
|
from textual.content import Content
|
|
from textual.message import Message
|
|
from textual.screen import ModalScreen
|
|
from textual.style import Style as TStyle
|
|
from textual.widgets import Input, OptionList, Static
|
|
from textual.widgets.option_list import Option, OptionDoesNotExist
|
|
|
|
if TYPE_CHECKING:
|
|
from textual.app import ComposeResult
|
|
from textual.events import Click, MouseMove
|
|
|
|
from deepagents_code.widgets.codex_auth import CodexSignedInAction
|
|
|
|
from deepagents_code import auth_store, theme
|
|
from deepagents_code.auth_display import format_auth_badge
|
|
from deepagents_code.config import (
|
|
apply_stored_langsmith_auth,
|
|
get_glyphs,
|
|
is_ascii_mode,
|
|
)
|
|
from deepagents_code.model_config import (
|
|
CODEX_PROVIDER,
|
|
PROVIDER_API_KEY_ENV,
|
|
PROVIDERS_DOCS_URL as _PROVIDERS_DOCS_URL,
|
|
SERVICE_API_KEY_ENV,
|
|
ModelConfig,
|
|
ProviderAuthSource,
|
|
ProviderAuthState,
|
|
ProviderAuthStatus,
|
|
clear_caches,
|
|
get_available_models,
|
|
get_base_url_env_var,
|
|
get_base_url_env_vars,
|
|
get_credential_env_var,
|
|
get_default_base_url_env,
|
|
get_provider_auth_status,
|
|
get_service_auth_status,
|
|
is_langsmith,
|
|
is_service,
|
|
resolved_env_var_name,
|
|
)
|
|
from deepagents_code.widgets._links import open_style_link
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
CONFIGURATION_DOCS_URL = (
|
|
"https://docs.langchain.com/oss/python/deepagents/code/configuration"
|
|
)
|
|
|
|
|
|
PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
|
"anthropic": "Anthropic",
|
|
"azure_openai": "Azure OpenAI",
|
|
"baseten": "Baseten",
|
|
"cohere": "Cohere",
|
|
"deepseek": "DeepSeek",
|
|
"fireworks": "Fireworks",
|
|
"google_genai": "Google Gemini",
|
|
"google_vertexai": "Google Vertex AI",
|
|
"groq": "Groq",
|
|
"huggingface": "Hugging Face",
|
|
"ibm": "IBM watsonx",
|
|
"langsmith": "LangSmith (tracing)",
|
|
"litellm": "LiteLLM",
|
|
"mistralai": "Mistral AI",
|
|
"nvidia": "NVIDIA",
|
|
"openai": "OpenAI",
|
|
"openai_codex": "OpenAI Codex (ChatGPT login)",
|
|
"openrouter": "OpenRouter",
|
|
"perplexity": "Perplexity",
|
|
"together": "Together AI",
|
|
"xai": "xAI",
|
|
}
|
|
|
|
|
|
PROVIDER_API_KEY_URLS: dict[str, str] = {
|
|
"anthropic": "https://platform.claude.com/login?returnTo=%2Fsettings%2Fkeys",
|
|
"baseten": "https://docs.baseten.co/organization/api-keys",
|
|
"cohere": "https://dashboard.cohere.com/welcome/login?redirect_uri=%2Fapi-keys",
|
|
"deepseek": "https://platform.deepseek.com/api_keys",
|
|
"fireworks": "https://app.fireworks.ai/settings/users/api-keys",
|
|
"google_genai": "https://aistudio.google.com/api-keys",
|
|
"groq": "https://console.groq.com/keys",
|
|
"huggingface": "https://huggingface.co/login?next=%2Fsettings%2Ftokens",
|
|
"ibm": "https://cloud.ibm.com/iam/apikeys",
|
|
"langsmith": "https://smith.langchain.com/settings",
|
|
"litellm": "https://docs.litellm.ai/docs/proxy/virtual_keys",
|
|
"mistralai": "https://console.mistral.ai/api-keys",
|
|
"nvidia": "https://build.nvidia.com/settings/api-keys",
|
|
"openai": "https://platform.openai.com/api-keys",
|
|
"openrouter": "https://openrouter.ai/workspaces/default/keys",
|
|
"perplexity": "https://www.perplexity.ai/settings/api",
|
|
"tavily": "https://app.tavily.com",
|
|
"together": "https://api.together.ai/settings/api-keys",
|
|
"xai": "https://console.x.ai/team/default/api-keys",
|
|
}
|
|
|
|
|
|
def _is_safe_acquisition_url(url: str) -> bool:
|
|
"""Return whether `url` is safe to render as a clickable link.
|
|
|
|
Built-in links are trusted, but `api_key_url` can come from user-owned
|
|
config; restricting to `http`/`https` keeps a malformed or
|
|
`javascript:`-scheme value from becoming a live hyperlink.
|
|
|
|
Args:
|
|
url: Candidate link target.
|
|
|
|
Returns:
|
|
`True` if the URL uses an `http` or `https` scheme.
|
|
"""
|
|
return urlsplit(url).scheme in {"http", "https"}
|
|
|
|
|
|
def _provider_display_name(provider: str, config: ModelConfig | None = None) -> str:
|
|
"""Return a human-readable provider label for auth UI.
|
|
|
|
Resolution order: a configured `display_name`, then the built-in
|
|
`PROVIDER_DISPLAY_NAMES` map, then a title-cased form of the provider key.
|
|
|
|
Args:
|
|
provider: Provider config key.
|
|
config: Parsed model config, if already loaded by the caller.
|
|
|
|
Returns:
|
|
Configured display name, built-in display name, or title-cased provider key.
|
|
"""
|
|
model_config = config or ModelConfig.load()
|
|
return model_config.get_provider_display_name(
|
|
provider
|
|
) or PROVIDER_DISPLAY_NAMES.get(provider, provider.replace("_", " ").title())
|
|
|
|
|
|
def _auth_status_for(provider: str) -> ProviderAuthStatus:
|
|
"""Resolve the credential readiness of a provider or non-model service.
|
|
|
|
Routes services (e.g. Tavily) and model providers to their respective
|
|
status helpers. Each call reads the credential file, so callers that need
|
|
the status more than once should resolve it here a single time and reuse
|
|
the result.
|
|
|
|
Args:
|
|
provider: Provider or service config key.
|
|
|
|
Returns:
|
|
The auth status used for both ordering and badge rendering.
|
|
"""
|
|
if is_service(provider):
|
|
return get_service_auth_status(provider)
|
|
return get_provider_auth_status(provider)
|
|
|
|
|
|
class AuthResult(StrEnum):
|
|
"""Outcome of an `AuthPromptScreen` interaction.
|
|
|
|
The three outcomes need to stay distinguishable because callers in the
|
|
recovery path retry the original failing operation only on `SAVED` —
|
|
retrying after `DELETED` would loop into the same missing-credentials
|
|
error indefinitely.
|
|
"""
|
|
|
|
SAVED = "saved"
|
|
"""User pasted a key and it was persisted."""
|
|
|
|
DELETED = "deleted"
|
|
"""User cleared the existing stored key. No retry should follow."""
|
|
|
|
CANCELLED = "cancelled"
|
|
"""User dismissed the prompt without saving."""
|
|
|
|
|
|
class AuthConfirmScreen(ModalScreen[bool]):
|
|
"""Confirm before launching an authentication flow for a model.
|
|
|
|
A provider-agnostic gate shown when a selected model needs credentials
|
|
that aren't detected, and starting the auth flow is disruptive enough
|
|
that the user should opt in first (e.g. an OAuth flow that launches a
|
|
browser and a multi-minute loopback wait). The caller supplies all copy
|
|
so the screen carries no provider assumptions; currently only the
|
|
`openai_codex` model-switcher path uses it.
|
|
|
|
Dismissal values:
|
|
|
|
- `True`: proceed to the auth flow.
|
|
- `False`: go back without authenticating (also the outcome of Esc).
|
|
"""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("enter", "confirm", "Continue", show=False, priority=True),
|
|
Binding("escape", "cancel", "Back", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
AuthConfirmScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
AuthConfirmScreen > Vertical {
|
|
width: 64;
|
|
max-width: 90%;
|
|
height: auto;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
padding: 1 2;
|
|
}
|
|
|
|
AuthConfirmScreen .auth-confirm-title {
|
|
text-style: bold;
|
|
color: $primary;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthConfirmScreen .auth-confirm-body {
|
|
height: auto;
|
|
color: $text;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthConfirmScreen .auth-confirm-help {
|
|
height: auto;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
title: str,
|
|
body: str | Content,
|
|
help_text: str = "Enter to continue, Esc to go back",
|
|
) -> None:
|
|
"""Initialize the prompt.
|
|
|
|
Args:
|
|
title: Heading shown at the top of the dialog.
|
|
body: Explanatory copy. Pass a `Content` for inline styling, or a
|
|
plain string for unstyled text.
|
|
help_text: Key-hint line shown at the bottom.
|
|
"""
|
|
super().__init__()
|
|
self._title = title
|
|
self._body = body
|
|
self._help_text = help_text
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the confirmation dialog.
|
|
|
|
Yields:
|
|
Title, body, and key-hint widgets parented inside a `Vertical`.
|
|
"""
|
|
with Vertical():
|
|
yield Static(self._title, classes="auth-confirm-title", markup=False)
|
|
yield Static(self._body, classes="auth-confirm-body", markup=False)
|
|
yield Static(self._help_text, classes="auth-confirm-help", markup=False)
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply ASCII border when needed."""
|
|
if is_ascii_mode():
|
|
container = self.query_one(Vertical)
|
|
colors = theme.get_theme_colors(self)
|
|
container.styles.border = ("ascii", colors.success)
|
|
|
|
def action_confirm(self) -> None:
|
|
"""Dismiss with `True` to proceed to the auth flow."""
|
|
self.dismiss(True)
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Dismiss with `False` to go back without authenticating.
|
|
|
|
The method name must stay `cancel`: the app owns a priority `escape`
|
|
binding that, for an active `ModalScreen`, dispatches to
|
|
`action_cancel` if present and otherwise falls through to
|
|
`dismiss(None)`. Renaming this would silently regress Esc to a
|
|
`None` dismiss instead of an explicit "go back".
|
|
"""
|
|
self.dismiss(False)
|
|
|
|
|
|
class DeleteCredentialConfirmScreen(ModalScreen[bool]):
|
|
"""Confirmation overlay shown before clearing a stored credential.
|
|
|
|
Patterned on `DeleteThreadConfirmScreen` so the destructive prompt feels
|
|
consistent across the app. Always dismisses with `True` on confirm or
|
|
`False` on cancel; the caller does the actual delete.
|
|
"""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("enter", "confirm", "Confirm", show=False, priority=True),
|
|
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
DeleteCredentialConfirmScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
DeleteCredentialConfirmScreen > Vertical {
|
|
width: 56;
|
|
height: auto;
|
|
background: $surface;
|
|
border: solid red;
|
|
padding: 1 2;
|
|
}
|
|
|
|
DeleteCredentialConfirmScreen .auth-confirm-text {
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
DeleteCredentialConfirmScreen .auth-confirm-help {
|
|
text-align: center;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, provider: str) -> None:
|
|
"""Initialize the confirmation modal.
|
|
|
|
Args:
|
|
provider: Provider whose stored credential is about to be cleared.
|
|
"""
|
|
super().__init__()
|
|
self._provider = provider
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the confirmation dialog.
|
|
|
|
Yields:
|
|
Widgets for the delete confirmation prompt.
|
|
"""
|
|
with Vertical():
|
|
yield Static(
|
|
Content.from_markup(
|
|
"Delete stored API key for [bold]$provider[/bold]?",
|
|
provider=self._provider,
|
|
),
|
|
classes="auth-confirm-text",
|
|
)
|
|
yield Static(
|
|
"Enter to confirm, Esc to cancel",
|
|
classes="auth-confirm-help",
|
|
)
|
|
|
|
def action_confirm(self) -> None:
|
|
"""Confirm deletion."""
|
|
self.dismiss(True)
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Cancel deletion."""
|
|
self.dismiss(False)
|
|
|
|
|
|
class AuthPromptScreen(ModalScreen[AuthResult]):
|
|
"""Modal that captures and persists an API key for one provider.
|
|
|
|
Dismissal values are members of `AuthResult` so callers in the recovery
|
|
path can distinguish "user just saved a key — retry the failed
|
|
operation" from "user just cleared their key — don't retry, that would
|
|
loop into the same error" from "user cancelled — leave state alone".
|
|
"""
|
|
|
|
AUTO_FOCUS = "#auth-prompt-input"
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
Binding("f2", "toggle_advanced", "Advanced", show=False, priority=True),
|
|
Binding("ctrl+d", "delete_stored", "Delete stored", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
AuthPromptScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
AuthPromptScreen > Vertical {
|
|
width: 72;
|
|
max-width: 90%;
|
|
height: auto;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
padding: 1 2;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-title {
|
|
text-style: bold;
|
|
color: $primary;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-copy {
|
|
height: auto;
|
|
color: $text;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-status {
|
|
height: auto;
|
|
color: $text;
|
|
background: $background;
|
|
padding: 0 1;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-instructions {
|
|
height: auto;
|
|
color: $text;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-advanced-toggle {
|
|
height: auto;
|
|
color: $text-muted;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen #auth-prompt-base-url-label {
|
|
text-align: center;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-meta {
|
|
height: auto;
|
|
color: $text-muted;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen #auth-prompt-input,
|
|
AuthPromptScreen #auth-prompt-base-url {
|
|
margin-bottom: 1;
|
|
border: solid $primary-lighten-2;
|
|
}
|
|
|
|
AuthPromptScreen #auth-prompt-input:focus,
|
|
AuthPromptScreen #auth-prompt-base-url:focus {
|
|
border: solid $primary;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-error {
|
|
height: auto;
|
|
color: $error;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthPromptScreen .auth-prompt-help {
|
|
height: auto;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
env_var: str | None,
|
|
*,
|
|
reason: str | None = None,
|
|
allow_empty_submit: bool = False,
|
|
input_placeholder: str | None = None,
|
|
submit_label: str | None = None,
|
|
) -> None:
|
|
"""Initialize the prompt for `provider`.
|
|
|
|
Args:
|
|
provider: Provider name (e.g., `"anthropic"`).
|
|
env_var: Canonical env var the SDK reads, shown as helper text.
|
|
May be `None` for providers that don't use one of the
|
|
hardcoded env-var bindings (rare; the prompt still works).
|
|
reason: Optional context, e.g.,
|
|
`"Required to use anthropic:claude-opus-4-8"`.
|
|
allow_empty_submit: Whether pressing Enter on an empty key dismisses
|
|
with `AuthResult.CANCELLED` instead of showing a validation error.
|
|
input_placeholder: Optional placeholder override for the key input.
|
|
submit_label: Optional help-label override for the Enter action.
|
|
"""
|
|
super().__init__()
|
|
self._provider = provider
|
|
self._env_var = env_var
|
|
self._reason = reason
|
|
self._allow_empty_submit = allow_empty_submit
|
|
self._input_placeholder = input_placeholder
|
|
self._submit_label = submit_label
|
|
# LangSmith is configured as a tracing service: it has no base-URL
|
|
# override but does carry an optional project name, and saving a key
|
|
# turns tracing on.
|
|
self._is_langsmith = is_langsmith(provider)
|
|
# Resolve the current credential source and probe the store, but never
|
|
# let a corrupt `auth.json`/config crash the screen at construction
|
|
# time — Textual would propagate the exception before the modal mounts.
|
|
# Treat unreadable as "no env source" / "no existing key" and surface a
|
|
# one-line warning at compose time. The status is consumed only
|
|
# cosmetically (title prefix, env note), so a MISSING fallback is safe.
|
|
# The config is loaded once here so compose-time helpers can read the
|
|
# cached instance instead of reloading outside this crash-safety guard.
|
|
try:
|
|
self._config = ModelConfig.load()
|
|
self._auth_status = _auth_status_for(provider)
|
|
self._has_existing = auth_store.get_stored_key(provider) is not None
|
|
self._existing_base_url = auth_store.get_stored_base_url(provider) or ""
|
|
self._existing_project = auth_store.get_stored_project(provider) or ""
|
|
self._advanced_visible = bool(
|
|
self._existing_base_url or self._existing_project
|
|
)
|
|
self._store_warning: str | None = None
|
|
except RuntimeError as exc:
|
|
logger.warning(
|
|
"Could not read stored credentials for %s: %s", provider, exc
|
|
)
|
|
self._config = ModelConfig()
|
|
self._auth_status = ProviderAuthStatus(
|
|
state=ProviderAuthState.MISSING, provider=provider
|
|
)
|
|
self._has_existing = False
|
|
self._existing_base_url = ""
|
|
self._existing_project = ""
|
|
self._advanced_visible = False
|
|
self._store_warning = (
|
|
f"Credential file is unreadable ({exc}). Saving here will overwrite it."
|
|
)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the prompt.
|
|
|
|
Yields:
|
|
Widgets that make up the auth prompt modal.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
provider_label = _provider_display_name(self._provider, self._config)
|
|
with Vertical():
|
|
# Tag the title with `(stored)` so the user knows a replacement
|
|
# (or the `Ctrl+D delete` affordance shown in the help line) is
|
|
# what's about to happen — both are gated on `_has_existing`.
|
|
resolved_from_env = self._auth_status.source is ProviderAuthSource.ENV
|
|
scoped_env_var = None
|
|
if self._auth_status.env_var:
|
|
candidate = resolved_env_var_name(self._auth_status.env_var)
|
|
if candidate.startswith("DEEPAGENTS_CODE_") and os.environ.get(
|
|
candidate
|
|
):
|
|
scoped_env_var = candidate
|
|
active_env_var = scoped_env_var or (
|
|
resolved_env_var_name(self._auth_status.env_var)
|
|
if resolved_from_env and self._auth_status.env_var
|
|
else None
|
|
)
|
|
if active_env_var and active_env_var.startswith("DEEPAGENTS_CODE_"):
|
|
title_prefix = f"{glyphs.warning} "
|
|
else:
|
|
title_prefix = f"{glyphs.checkmark} " if resolved_from_env else ""
|
|
if self._has_existing:
|
|
title = Content.assemble(
|
|
title_prefix,
|
|
Content.from_markup(
|
|
"Replace key for [bold]$provider[/bold] [dim](stored)[/dim]",
|
|
provider=provider_label,
|
|
),
|
|
)
|
|
elif resolved_from_env:
|
|
title = Content.assemble(
|
|
title_prefix,
|
|
Content.from_markup(
|
|
"Replace key for [bold]$provider[/bold]",
|
|
provider=provider_label,
|
|
),
|
|
)
|
|
else:
|
|
title = Content.assemble(
|
|
title_prefix,
|
|
Content.from_markup(
|
|
"API key for [bold]$provider[/bold]",
|
|
provider=provider_label,
|
|
),
|
|
)
|
|
yield Static(title, classes="auth-prompt-title")
|
|
if active_env_var:
|
|
env_var = active_env_var
|
|
is_scoped_env = env_var.startswith("DEEPAGENTS_CODE_")
|
|
env_status_style = "$warning" if is_scoped_env else "$success"
|
|
env_note = (
|
|
"This scoped env var takes priority. A saved key will be used "
|
|
f"only when {env_var} is unset."
|
|
if is_scoped_env
|
|
else (
|
|
"Paste a key below to use a different key for Deep Agents Code."
|
|
)
|
|
)
|
|
yield Static(
|
|
Content.assemble(
|
|
(
|
|
"Current key is set from environment variable ",
|
|
env_status_style,
|
|
),
|
|
(env_var, f"bold {env_status_style}"),
|
|
(".", env_status_style),
|
|
"\n",
|
|
(env_note, "italic $text-muted"),
|
|
),
|
|
classes="auth-prompt-status",
|
|
id="auth-prompt-env-status",
|
|
)
|
|
if self._reason:
|
|
yield Static(
|
|
Content.from_markup("$reason", reason=self._reason),
|
|
classes="auth-prompt-copy",
|
|
)
|
|
yield Static(
|
|
self._build_key_instructions(),
|
|
classes="auth-prompt-instructions",
|
|
id="auth-prompt-key-instructions",
|
|
)
|
|
if self._store_warning:
|
|
yield Static(
|
|
Content.from_markup("$msg", msg=self._store_warning),
|
|
classes="auth-prompt-error",
|
|
)
|
|
yield Input(
|
|
placeholder=self._input_placeholder
|
|
or (
|
|
"Paste a new key to replace the stored one"
|
|
if self._has_existing
|
|
else "Paste your API key"
|
|
),
|
|
password=True,
|
|
id="auth-prompt-input",
|
|
)
|
|
storage_note: Content | None
|
|
if self._is_langsmith:
|
|
storage_note = Content.from_markup(
|
|
"Deep Agents Code stores the above key locally and turns on "
|
|
"LangSmith tracing. To pause tracing without removing the key, "
|
|
"set [bold]DEEPAGENTS_CODE_LANGSMITH_TRACING=false[/bold]."
|
|
)
|
|
elif is_service(self._provider):
|
|
# Services (e.g. Tavily) skip the storage note: the title and
|
|
# reason copy already say what the key is for, so it only adds
|
|
# redundant copy here.
|
|
storage_note = None
|
|
else:
|
|
storage_note = Content.from_markup(
|
|
"Deep Agents Code stores the above key locally and uses it "
|
|
"when you select [bold]$provider[/bold] models.",
|
|
provider=provider_label,
|
|
)
|
|
if storage_note is not None:
|
|
yield Static(
|
|
storage_note,
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-storage-note",
|
|
)
|
|
yield Static(
|
|
self._build_advanced_toggle_label(),
|
|
classes="auth-prompt-advanced-toggle",
|
|
id="auth-prompt-advanced-toggle",
|
|
)
|
|
if self._env_var:
|
|
key_meta = Static(
|
|
Content.assemble(
|
|
"Alternatively, environment variables can be used in place "
|
|
"of the key stored above. Set ",
|
|
(f"DEEPAGENTS_CODE_{self._env_var}", TStyle(bold=True)),
|
|
" for a Deep Agents Code-only key; it has the highest "
|
|
"priority. Set ",
|
|
(self._env_var, TStyle(bold=True)),
|
|
" to share a key with other provider SDK tools; it is used "
|
|
"only when no scoped or stored key exists. ",
|
|
(
|
|
"Configuration docs",
|
|
self._link_style(CONFIGURATION_DOCS_URL),
|
|
),
|
|
".",
|
|
),
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-key-meta",
|
|
)
|
|
key_meta.display = self._advanced_visible
|
|
yield key_meta
|
|
if self._is_langsmith:
|
|
project_label = Static(
|
|
Content.from_markup("[bold]Project name[/bold]"),
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-project-label",
|
|
)
|
|
project_label.display = self._advanced_visible
|
|
yield project_label
|
|
project_input = Input(
|
|
value=self._existing_project,
|
|
placeholder="LANGSMITH_PROJECT (default: deepagents-code)",
|
|
id="auth-prompt-project",
|
|
)
|
|
project_input.display = self._advanced_visible
|
|
yield project_input
|
|
project_hint_widget = Static(
|
|
Content.from_markup(
|
|
"Route agent traces to this LangSmith project. "
|
|
"Leave blank to use the default [bold]deepagents-code[/bold]."
|
|
),
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-project-hint",
|
|
)
|
|
project_hint_widget.display = self._advanced_visible
|
|
yield project_hint_widget
|
|
else:
|
|
base_url_label = Static(
|
|
Content.from_markup("[bold]Base URL override[/bold]"),
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-base-url-label",
|
|
)
|
|
base_url_label.display = self._advanced_visible
|
|
yield base_url_label
|
|
base_url_input = Input(
|
|
value=self._existing_base_url,
|
|
placeholder="Base URL",
|
|
id="auth-prompt-base-url",
|
|
)
|
|
base_url_input.display = self._advanced_visible
|
|
yield base_url_input
|
|
base_url_hint_widget = Static(
|
|
self._build_base_url_hint(),
|
|
classes="auth-prompt-meta",
|
|
id="auth-prompt-base-url-hint",
|
|
)
|
|
base_url_hint_widget.display = self._advanced_visible
|
|
yield base_url_hint_widget
|
|
yield Static("", classes="auth-prompt-error", id="auth-prompt-error")
|
|
save_label = self._submit_label or (
|
|
"Enter replace" if self._has_existing else "Enter save"
|
|
)
|
|
help_parts = [f"{save_label} {glyphs.bullet} Esc cancel", "F2 advanced"]
|
|
if self._has_existing:
|
|
help_parts.append("Ctrl+D delete stored")
|
|
yield Static(
|
|
f" {glyphs.bullet} ".join(help_parts),
|
|
classes="auth-prompt-help",
|
|
)
|
|
|
|
def _link_style(self, url: str) -> TStyle:
|
|
"""Return a theme-aware style for inline modal links.
|
|
|
|
Args:
|
|
url: Link target.
|
|
|
|
Returns:
|
|
Textual style that opens `url` when clicked.
|
|
"""
|
|
colors = theme.get_theme_colors(self)
|
|
if self.app.theme in {"ansi-dark", "ansi-light"}:
|
|
return TStyle(bold=True, underline=True, link=url)
|
|
return TStyle(foreground=TColor.parse(colors.primary), underline=True, link=url)
|
|
|
|
def _build_key_instructions(self) -> Content:
|
|
"""Build provider-specific API-key acquisition guidance.
|
|
|
|
Returns:
|
|
Content shown before the API-key input. May append muted notices: a
|
|
provider-specific caveat (e.g. Anthropic subscription plans are
|
|
unsupported) and/or a warning that a user-configured `api_key_url`
|
|
was rejected for using an unsupported URL scheme.
|
|
"""
|
|
config = self._config
|
|
configured_url = config.get_provider_api_key_url(self._provider)
|
|
rejected_url = False
|
|
if configured_url and not _is_safe_acquisition_url(configured_url):
|
|
logger.warning(
|
|
"Ignoring api_key_url for %s: unsupported URL scheme", self._provider
|
|
)
|
|
configured_url = None
|
|
rejected_url = True
|
|
url = configured_url or PROVIDER_API_KEY_URLS.get(
|
|
self._provider, _PROVIDERS_DOCS_URL
|
|
)
|
|
provider = _provider_display_name(self._provider, config)
|
|
label = (
|
|
f"{provider} key page"
|
|
if configured_url or self._provider in PROVIDER_API_KEY_URLS
|
|
else f"{provider} setup docs"
|
|
)
|
|
if self._provider == "azure_openai":
|
|
instructions = Content.assemble(
|
|
"Find your key in your Azure OpenAI resource's "
|
|
"Keys and Endpoint page, then paste it below. ",
|
|
(label, self._link_style(url)),
|
|
)
|
|
elif self._provider == "openai":
|
|
instructions = Content.assemble(
|
|
f"Sign in to {provider}, create or copy an API key, then "
|
|
"paste it below. Minimum permissions needed: "
|
|
"under Model capabilities, grant Write access to Responses "
|
|
"(/v1/responses). For older models, you may also need "
|
|
"Request access to Chat completions (/v1/chat/completions). ",
|
|
(label, self._link_style(url)),
|
|
)
|
|
elif self._provider == "anthropic":
|
|
instructions = Content.assemble(
|
|
f"Sign in to {provider}, create or copy an API key, "
|
|
"then paste it below. ",
|
|
(label, self._link_style(url)),
|
|
"\n",
|
|
(
|
|
(
|
|
"Subscription plans (Claude Pro/Max, Claude Code) cannot "
|
|
"be used for Anthropic calls in Deep Agents Code. Only a "
|
|
"standard API key with pay-as-you-go billing works here."
|
|
),
|
|
"italic $text-muted",
|
|
),
|
|
)
|
|
else:
|
|
instructions = Content.assemble(
|
|
f"Sign in to {provider}, create or copy an API key, "
|
|
"then paste it below. ",
|
|
(label, self._link_style(url)),
|
|
)
|
|
if rejected_url:
|
|
notice = (
|
|
"Your configured api_key_url was ignored (unsupported URL "
|
|
"scheme); showing the default link instead."
|
|
)
|
|
instructions = Content.assemble(
|
|
instructions,
|
|
"\n",
|
|
(notice, "italic $text-muted"),
|
|
)
|
|
return instructions
|
|
|
|
def _build_advanced_toggle_label(self) -> str:
|
|
"""Build the disclosure-row label for advanced settings.
|
|
|
|
Returns:
|
|
Toggle label reflecting the current expanded state.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
marker = (
|
|
glyphs.disclosure_expanded
|
|
if self._advanced_visible
|
|
else glyphs.disclosure_collapsed
|
|
)
|
|
return f"{marker} Advanced (F2)"
|
|
|
|
def _build_base_url_hint(self) -> Content:
|
|
"""Build the optional base-URL hint shown inside Advanced.
|
|
|
|
Returns:
|
|
Content describing blank behavior and env-var precedence.
|
|
"""
|
|
surviving_base_url_env = get_default_base_url_env(self._provider)
|
|
endpoint_envs = get_base_url_env_vars(self._provider)
|
|
env_order = ", then ".join(
|
|
item for env in endpoint_envs for item in (f"DEEPAGENTS_CODE_{env}", env)
|
|
)
|
|
if surviving_base_url_env and env_order:
|
|
return Content.from_markup(
|
|
"Override the provider endpoint for this stored key. "
|
|
"Leave blank to use [bold]$prefixed[/bold].\n"
|
|
"Env override order: [bold]$order[/bold].",
|
|
prefixed=surviving_base_url_env,
|
|
order=env_order,
|
|
)
|
|
endpoint_env = get_base_url_env_var(self._provider)
|
|
if endpoint_env and env_order:
|
|
return Content.from_markup(
|
|
"Override the provider endpoint for this stored key. "
|
|
"Leave blank to use the provider default.\n"
|
|
"Env override order: [bold]$order[/bold].",
|
|
order=env_order,
|
|
)
|
|
return Content.from_markup(
|
|
"Override the provider endpoint for this stored key. "
|
|
"Leave blank to use the provider default."
|
|
)
|
|
|
|
def action_toggle_advanced(self) -> None:
|
|
"""Show or hide optional endpoint and env-var details.
|
|
|
|
Restores focus to the key input when collapsing so keyboard entry
|
|
resumes on the field the user most likely wants.
|
|
"""
|
|
self._advanced_visible = not self._advanced_visible
|
|
for selector in (
|
|
"#auth-prompt-key-meta",
|
|
"#auth-prompt-base-url-label",
|
|
"#auth-prompt-base-url",
|
|
"#auth-prompt-base-url-hint",
|
|
"#auth-prompt-project-label",
|
|
"#auth-prompt-project",
|
|
"#auth-prompt-project-hint",
|
|
):
|
|
for widget in self.query(selector):
|
|
widget.display = self._advanced_visible
|
|
self.query_one("#auth-prompt-advanced-toggle", Static).update(
|
|
self._build_advanced_toggle_label()
|
|
)
|
|
if not self._advanced_visible:
|
|
self.query_one("#auth-prompt-input", Input).focus()
|
|
|
|
def on_click(self, event: Click) -> None:
|
|
"""Open style-embedded hyperlinks or toggle Advanced."""
|
|
widget = event.widget
|
|
if (
|
|
widget is not None
|
|
and widget.id == "auth-prompt-advanced-toggle"
|
|
and not event.style.link
|
|
):
|
|
self.action_toggle_advanced()
|
|
event.stop()
|
|
return
|
|
open_style_link(event)
|
|
|
|
def on_mouse_move(self, event: MouseMove) -> None:
|
|
"""Show a pointer over links and the clickable Advanced row."""
|
|
widget = event.widget
|
|
self.styles.pointer = (
|
|
"pointer"
|
|
if event.style.link
|
|
or (widget is not None and widget.id == "auth-prompt-advanced-toggle")
|
|
else "default"
|
|
)
|
|
|
|
def on_leave(self) -> None:
|
|
"""Reset the pointer shape when the mouse leaves the prompt."""
|
|
self.styles.pointer = "default"
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply ASCII border when needed."""
|
|
if is_ascii_mode():
|
|
container = self.query_one(Vertical)
|
|
colors = theme.get_theme_colors(self)
|
|
container.styles.border = ("ascii", colors.success)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
"""Validate, persist, and dismiss.
|
|
|
|
Reads both fields regardless of which one was submitted, so pressing
|
|
Enter in either the key or the secondary input (base URL, or the
|
|
LangSmith project name) saves the pair.
|
|
"""
|
|
event.stop()
|
|
cleaned = self.query_one("#auth-prompt-input", Input).value.strip()
|
|
if self._is_langsmith:
|
|
base_url = ""
|
|
project = self.query_one("#auth-prompt-project", Input).value.strip()
|
|
else:
|
|
base_url = self.query_one("#auth-prompt-base-url", Input).value.strip()
|
|
project = ""
|
|
if not cleaned:
|
|
if self._allow_empty_submit:
|
|
# Optional prompts (e.g. the Tavily onboarding step) treat an
|
|
# empty submit as an intentional skip. We deliberately reuse
|
|
# `CANCELLED` rather than add a `SKIPPED` outcome: every caller
|
|
# that allows empty submit wants identical "did not save"
|
|
# handling for skip and Escape, so the distinction would be
|
|
# dead weight. Revisit if a caller ever needs to tell a
|
|
# deliberate decline from an accidental dismissal.
|
|
self.dismiss(AuthResult.CANCELLED)
|
|
return
|
|
self._show_error("API key cannot be empty.")
|
|
return
|
|
try:
|
|
outcome = auth_store.set_stored_key(
|
|
self._provider,
|
|
cleaned,
|
|
base_url=base_url or None,
|
|
project=project or None,
|
|
)
|
|
except (ValueError, RuntimeError, OSError) as exc:
|
|
# `auth_store` exception messages never include the secret value,
|
|
# but the path can include user-controlled `DEFAULT_STATE_DIR`
|
|
# bytes — render via `Content.from_markup` so a `[` in the path
|
|
# can't break Textual's markup pipeline.
|
|
logger.warning(
|
|
"Failed to persist credential for %s: %s", self._provider, exc
|
|
)
|
|
self._show_error("Could not save credential: $exc", exc=str(exc))
|
|
return
|
|
for warning in outcome.warnings:
|
|
# chmod failures are security regressions the user must see —
|
|
# `logger.warning` alone is invisible inside a Textual session.
|
|
self.app.notify(warning, severity="warning", markup=False)
|
|
if self._is_langsmith:
|
|
apply_stored_langsmith_auth(replace_project=True)
|
|
clear_caches()
|
|
self.dismiss(AuthResult.SAVED)
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Dismiss without saving."""
|
|
self.dismiss(AuthResult.CANCELLED)
|
|
|
|
def action_delete_stored(self) -> None:
|
|
"""Open the delete-confirmation overlay, or quit when nothing is stored.
|
|
|
|
Ctrl+D deletes a stored credential, but its `priority` binding also
|
|
intercepts the app-level Ctrl+D=quit. When there's no credential to
|
|
delete, fall through to quit rather than swallowing the key (mirroring
|
|
the thread selector). `app.exit()` is used instead of `dismiss()`, which
|
|
would just close the modal silently and re-swallow the key.
|
|
"""
|
|
if not self._has_existing:
|
|
self.app.exit()
|
|
return
|
|
self.app.push_screen(
|
|
DeleteCredentialConfirmScreen(self._provider),
|
|
self._on_delete_confirmed,
|
|
)
|
|
|
|
def _on_delete_confirmed(self, confirmed: bool | None) -> None:
|
|
"""Handle the result of the confirmation overlay.
|
|
|
|
Args:
|
|
confirmed: `True` if the user pressed Enter, `False` on Esc.
|
|
"""
|
|
if not confirmed:
|
|
return
|
|
try:
|
|
removed = auth_store.delete_stored_key(self._provider)
|
|
except RuntimeError as exc:
|
|
logger.warning(
|
|
"Failed to delete credential for %s: %s", self._provider, exc
|
|
)
|
|
self._show_error("Could not delete credential: $exc", exc=str(exc))
|
|
return
|
|
if not removed:
|
|
# The entry was gone — likely a concurrent delete from another
|
|
# app instance. Surface that fact so "delete" UX doesn't lie when
|
|
# nothing actually happened on disk.
|
|
self.app.notify(
|
|
f"No stored credential for {self._provider} — already removed.",
|
|
severity="information",
|
|
markup=False,
|
|
)
|
|
clear_caches()
|
|
self.dismiss(AuthResult.DELETED)
|
|
|
|
def _show_error(self, template: str, /, **substitutions: str) -> None:
|
|
"""Render `template` via markup substitution in the inline error slot.
|
|
|
|
Args:
|
|
template: Markup template (e.g. `"Could not save: $exc"`).
|
|
**substitutions: `$name` substitution values; Textual escapes them.
|
|
"""
|
|
error = self.query_one("#auth-prompt-error", Static)
|
|
error.update(Content.from_markup(template, **substitutions))
|
|
|
|
|
|
class AuthManagerScreen(ModalScreen[None]):
|
|
"""Modal that lists configured providers and lets the user manage keys.
|
|
|
|
Reachable via the `/auth` slash command. Always dismisses with `None`;
|
|
state changes are persisted by `AuthPromptScreen` and reflected by
|
|
re-rendering the option list when this screen is reopened or after a
|
|
save/delete completes.
|
|
|
|
Well-known providers whose integration package isn't installed yet are
|
|
surfaced greyed-out so they stay discoverable. Selecting one routes
|
|
through an install confirmation: on confirm the screen records the extra on
|
|
`pending_install_extra` and dismisses so the app can install it (mirroring
|
|
the model selector's install-on-select flow) and reopen the manager.
|
|
"""
|
|
|
|
class CredentialSaved(Message):
|
|
"""Posted when a key prompt successfully persists credentials."""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("escape", "cancel", "Close", show=False, priority=True),
|
|
Binding("tab", "cursor_down", "Next", show=False, priority=True),
|
|
Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
AuthManagerScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
AuthManagerScreen > Vertical {
|
|
width: 76;
|
|
max-width: 90%;
|
|
height: 80%;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
padding: 1 2;
|
|
}
|
|
|
|
AuthManagerScreen .auth-manager-title {
|
|
text-style: bold;
|
|
color: $primary;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthManagerScreen .auth-manager-copy {
|
|
height: auto;
|
|
color: $text-muted;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
/* `1fr` + `min-height` keeps the option list from pushing the footer
|
|
off-screen on short terminals: the list shrinks (and starts scrolling)
|
|
before the footer is hidden. */
|
|
AuthManagerScreen OptionList {
|
|
height: 1fr;
|
|
min-height: 3;
|
|
background: $background;
|
|
}
|
|
|
|
AuthManagerScreen .auth-manager-warning {
|
|
height: auto;
|
|
color: $warning;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
AuthManagerScreen .auth-manager-help {
|
|
height: auto;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
text-align: center;
|
|
margin-top: 1;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, *, initial_provider: str | None = None) -> None:
|
|
"""Initialize the manager with an empty install-on-select registry.
|
|
|
|
Args:
|
|
initial_provider: Provider whose row should start highlighted —
|
|
set when reopening after an install-on-select so the cursor
|
|
lands on the just-installed provider ready for a key, rather
|
|
than resetting to the top of the list.
|
|
"""
|
|
super().__init__()
|
|
# Uninstalled known providers mapped to the extra that installs them,
|
|
# populated each time the option list is built. Selecting one routes
|
|
# to the install confirmation instead of the key prompt.
|
|
self._install_extras: dict[str, str] = {}
|
|
# Set when the user confirms installing a provider's extra; the app
|
|
# reads these off the screen after dismissal to install then reopen
|
|
# the manager with the just-installed provider highlighted.
|
|
self.pending_install_extra: str | None = None
|
|
self.pending_install_provider: str | None = None
|
|
self._initial_provider = initial_provider
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the manager.
|
|
|
|
Yields:
|
|
Widgets for the manager listing.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
options, store_warning = self._build_options_with_warning()
|
|
with Vertical():
|
|
yield Static("Manage API keys", classes="auth-manager-title")
|
|
yield Static(self._build_description(), classes="auth-manager-copy")
|
|
if store_warning:
|
|
# Surface auth.json corruption directly — `_build_options`
|
|
# falling back silently used to make a corrupt file look
|
|
# identical to "no keys stored".
|
|
yield Static(
|
|
Content.from_markup("$msg", msg=store_warning),
|
|
classes="auth-manager-warning",
|
|
)
|
|
yield OptionList(*options, id="auth-manager-options")
|
|
yield Static(
|
|
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab "
|
|
f"navigate {glyphs.bullet} Enter add/replace/delete/install "
|
|
f"{glyphs.bullet} Esc close",
|
|
classes="auth-manager-help",
|
|
)
|
|
|
|
def _build_description(self) -> Content:
|
|
"""Build the description line with an inline docs hyperlink.
|
|
|
|
Returns:
|
|
Description content. Themes other than the ANSI palette render
|
|
the link in the primary color so it reads as clickable; ANSI
|
|
users get a bold-only treatment that still reaches the
|
|
terminal's link handler via `Style(link=...)`.
|
|
"""
|
|
colors = theme.get_theme_colors(self)
|
|
ansi = self.app.theme in {"ansi-dark", "ansi-light"}
|
|
link_style: str | TStyle = (
|
|
TStyle(bold=True, link=_PROVIDERS_DOCS_URL)
|
|
if ansi
|
|
else TStyle(
|
|
foreground=TColor.parse(colors.primary),
|
|
link=_PROVIDERS_DOCS_URL,
|
|
)
|
|
)
|
|
return Content.assemble(
|
|
"Lists installed model providers, services like web search, and any "
|
|
"providers you've configured in ~/.deepagents/config.toml. Greyed-out "
|
|
"providers aren't installed yet — select one to install it. ",
|
|
("Docs", link_style),
|
|
)
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply ASCII border and highlight the initial provider when set."""
|
|
if is_ascii_mode():
|
|
container = self.query_one(Vertical)
|
|
colors = theme.get_theme_colors(self)
|
|
container.styles.border = ("ascii", colors.success)
|
|
self._highlight_initial_provider()
|
|
|
|
def _highlight_initial_provider(self) -> None:
|
|
"""Move the cursor to `initial_provider`'s row if it is listed.
|
|
|
|
Used when the manager reopens after an install-on-select so the cursor
|
|
lands on the just-installed provider (ready for a key) instead of
|
|
resetting to the top of the list.
|
|
"""
|
|
if self._initial_provider is None:
|
|
return
|
|
option_list = self.query_one("#auth-manager-options", OptionList)
|
|
try:
|
|
index = option_list.get_option_index(self._initial_provider)
|
|
except OptionDoesNotExist:
|
|
return
|
|
option_list.highlighted = index
|
|
option_list.scroll_to_highlight()
|
|
|
|
def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler
|
|
"""Open style-embedded hyperlinks (the title `Docs` link)."""
|
|
open_style_link(event)
|
|
|
|
def on_mouse_move(self, event: MouseMove) -> None:
|
|
"""Show a pointer over inline docs links."""
|
|
self.styles.pointer = "pointer" if event.style.link else "default"
|
|
|
|
def on_leave(self) -> None:
|
|
"""Reset the pointer shape when the mouse leaves the manager."""
|
|
self.styles.pointer = "default"
|
|
|
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
"""Open the prompt for the selected provider.
|
|
|
|
Greyed-out (uninstalled) providers route to an install confirmation
|
|
instead of the key prompt, since their package must be installed before
|
|
a credential is useful.
|
|
"""
|
|
provider = event.option.id
|
|
if not provider:
|
|
return
|
|
extra = self._install_extras.get(provider)
|
|
if extra is not None:
|
|
self._prompt_install_provider(provider, extra)
|
|
return
|
|
if provider == CODEX_PROVIDER:
|
|
# ChatGPT auth uses an OAuth browser flow, not an API key. The
|
|
# selector dispatches to a dedicated modal that already knows
|
|
# how to surface the authorize URL inline (so headless / SSH
|
|
# users can still paste it manually) and run the loopback
|
|
# callback wait on a worker.
|
|
self._open_codex_screen()
|
|
return
|
|
if is_service(provider):
|
|
# Services (e.g. Tavily web search) use a plain API key, stored the
|
|
# same way as a model-provider key.
|
|
self.app.push_screen(
|
|
AuthPromptScreen(provider, SERVICE_API_KEY_ENV[provider]),
|
|
self._on_prompt_closed,
|
|
)
|
|
return
|
|
env_var = get_credential_env_var(provider)
|
|
self.app.push_screen(
|
|
AuthPromptScreen(provider, env_var),
|
|
self._on_prompt_closed,
|
|
)
|
|
|
|
def _prompt_install_provider(self, provider: str, extra: str) -> None:
|
|
"""Confirm installing an uninstalled provider's extra, then dismiss.
|
|
|
|
On confirm, record the extra on `pending_install_extra` and dismiss so
|
|
the app can install it (with a server restart) and reopen the manager
|
|
with the provider now installed. On cancel, stay on the manager.
|
|
|
|
Args:
|
|
provider: The uninstalled provider the user selected.
|
|
extra: The `deepagents-code` extra that installs `provider`.
|
|
"""
|
|
from deepagents_code.widgets.install_confirm import (
|
|
InstallProviderConfirmScreen,
|
|
)
|
|
|
|
def _on_confirm(proceed: bool | None) -> None:
|
|
if proceed:
|
|
self.pending_install_extra = extra
|
|
self.pending_install_provider = provider
|
|
self.dismiss(None)
|
|
else:
|
|
# Declined or dismissed: clear any pending request so a reused
|
|
# screen never carries a stale install request, and stay put.
|
|
self.pending_install_extra = None
|
|
self.pending_install_provider = None
|
|
|
|
self.app.push_screen(
|
|
InstallProviderConfirmScreen(provider, extra),
|
|
_on_confirm,
|
|
)
|
|
|
|
def _open_codex_screen(self) -> None:
|
|
"""Push the ChatGPT OAuth flow modal and refresh on close.
|
|
|
|
When `openai_codex` is already signed in, give the user a chance to
|
|
sign out before launching a fresh sign-in flow. Otherwise just run
|
|
the sign-in worker.
|
|
"""
|
|
from deepagents_code.integrations import openai_codex
|
|
from deepagents_code.widgets.codex_auth import (
|
|
CodexAuthScreen,
|
|
CodexSignedInScreen,
|
|
)
|
|
|
|
status = openai_codex.get_status()
|
|
if status.logged_in and not status.is_expired:
|
|
self.app.push_screen(
|
|
CodexSignedInScreen(),
|
|
self._on_codex_signed_in_closed,
|
|
)
|
|
return
|
|
self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
|
|
|
|
def _on_codex_closed(self, _result: bool | None) -> None:
|
|
"""Refresh the option list once the codex flow dismisses."""
|
|
clear_caches()
|
|
self._refresh_options()
|
|
|
|
def _on_codex_signed_in_closed(self, action: CodexSignedInAction | None) -> None:
|
|
"""Handle dismissal of the "already signed in" overlay.
|
|
|
|
Args:
|
|
action: `SIGN_OUT` to clear the token, `REAUTH` to run the
|
|
sign-in flow again, `None` to close cleanly.
|
|
"""
|
|
from deepagents_code.widgets.codex_auth import CodexSignedInAction
|
|
|
|
if action is CodexSignedInAction.SIGN_OUT:
|
|
from deepagents_code.integrations import openai_codex
|
|
|
|
removed = openai_codex.logout()
|
|
if removed:
|
|
self.app.notify("Signed out of ChatGPT.", markup=False)
|
|
clear_caches()
|
|
self._refresh_options()
|
|
elif action is CodexSignedInAction.REAUTH:
|
|
from deepagents_code.widgets.codex_auth import CodexAuthScreen
|
|
|
|
self.app.push_screen(CodexAuthScreen(), self._on_codex_closed)
|
|
else:
|
|
self._refresh_options()
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Close the manager."""
|
|
self.dismiss(None)
|
|
|
|
def action_cursor_down(self) -> None:
|
|
"""Move the option-list cursor down."""
|
|
self.query_one("#auth-manager-options", OptionList).action_cursor_down()
|
|
|
|
def action_cursor_up(self) -> None:
|
|
"""Move the option-list cursor up."""
|
|
self.query_one("#auth-manager-options", OptionList).action_cursor_up()
|
|
|
|
def _on_prompt_closed(self, result: AuthResult | None) -> None:
|
|
"""Refresh the option list once the prompt dismisses."""
|
|
self._refresh_options()
|
|
if result is AuthResult.SAVED:
|
|
self.post_message(self.CredentialSaved())
|
|
|
|
def _refresh_options(self) -> None:
|
|
"""Rebuild option labels from current store state."""
|
|
option_list = self.query_one("#auth-manager-options", OptionList)
|
|
highlighted = option_list.highlighted
|
|
option_list.clear_options()
|
|
options, _ = self._build_options_with_warning()
|
|
for option in options:
|
|
option_list.add_option(option)
|
|
if highlighted is not None and option_list.option_count:
|
|
option_list.highlighted = min(highlighted, option_list.option_count - 1)
|
|
|
|
def _build_options_with_warning(self) -> tuple[list[Option], str | None]:
|
|
"""Render the option list, returning a corruption warning if any.
|
|
|
|
Returns:
|
|
`(options, warning_message)`. `warning_message` is `None` when
|
|
the credential file is readable; otherwise a one-line hint
|
|
telling the user the file is unreadable so a corrupt store
|
|
doesn't silently look identical to "no keys stored".
|
|
"""
|
|
warning: str | None = None
|
|
try:
|
|
stored = set(auth_store.list_configured_providers())
|
|
except RuntimeError as exc:
|
|
logger.warning("Failed to list stored credentials: %s", exc)
|
|
stored = set()
|
|
warning = (
|
|
f"Credential file is unreadable ({exc}). "
|
|
"Saving a key here will overwrite it."
|
|
)
|
|
|
|
config = ModelConfig.load()
|
|
config_providers = {
|
|
name for name, cfg in config.providers.items() if cfg.get("api_key_env")
|
|
}
|
|
|
|
# Only show well-known providers whose LangChain package is actually
|
|
# installed. `get_available_models` returns providers it could
|
|
# successfully import profiles for, so it doubles as an install
|
|
# gate. Stored and config-defined providers are always shown — even
|
|
# if the package was later uninstalled — so a stale credential can
|
|
# still be cleaned up and an explicitly-declared provider stays
|
|
# visible.
|
|
installed = set(get_available_models().keys())
|
|
well_known_installed = set(PROVIDER_API_KEY_ENV) & installed
|
|
# `openai_codex` is gated on `langchain-openai` being installed (we
|
|
# surface it whenever `openai` was discovered) rather than on
|
|
# `PROVIDER_API_KEY_ENV`, since it has no env var of its own.
|
|
codex_installed = {CODEX_PROVIDER} if "openai" in installed else set()
|
|
|
|
shown = well_known_installed | codex_installed | stored | config_providers
|
|
# Surface well-known providers whose package isn't installed yet as
|
|
# greyed-out, install-on-select entries so they stay
|
|
# discoverable (mirrors the model selector). Disabled providers and
|
|
# ones already shown above are skipped.
|
|
self._install_extras = self._uninstalled_known_providers(config, shown)
|
|
|
|
# Resolve each manageable entry's auth status once and reuse it for
|
|
# both ordering and badge rendering. `_auth_status_for` reads the
|
|
# credential file, so resolving it separately in the sort key and in
|
|
# `_format_label` would read `auth.json` twice per row (and, on a
|
|
# corrupt store, log the same warning twice). A single pass halves both.
|
|
services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras)
|
|
status_by_key = {key: _auth_status_for(key) for key in shown | services}
|
|
|
|
# Float entries that already have a credential configured to the top so
|
|
# the keys a user is actively using are easiest to find; everything else
|
|
# keeps alphabetical order (the `key` tiebreaker). Uninstalled
|
|
# install-on-select entries are listed afterwards (alphabetically) since
|
|
# selecting them installs a package rather than managing a key.
|
|
def sort_key(key: str) -> tuple[int, str]:
|
|
configured = status_by_key[key].state is ProviderAuthState.CONFIGURED
|
|
return (0 if configured else 1, key)
|
|
|
|
manageable = sorted(status_by_key, key=sort_key)
|
|
extra_providers = sorted(self._install_extras)
|
|
options = [
|
|
Option(self._format_label(key, status=status_by_key[key]), id=key)
|
|
for key in manageable
|
|
]
|
|
options.extend(
|
|
Option(self._format_label(provider, installed=False), id=provider)
|
|
for provider in extra_providers
|
|
)
|
|
return options, warning
|
|
|
|
@staticmethod
|
|
def _uninstalled_known_providers(
|
|
config: ModelConfig, shown: set[str]
|
|
) -> dict[str, str]:
|
|
"""Map known providers missing their package to the installing extra.
|
|
|
|
Args:
|
|
config: Loaded model config, used to skip disabled providers.
|
|
shown: Providers already listed (installed/stored/config) to skip.
|
|
|
|
Returns:
|
|
`{provider: extra}` for each well-known, enabled provider whose
|
|
integration package is not installed and has a curated extra.
|
|
"""
|
|
from deepagents_code.config_manifest import (
|
|
is_provider_package_installed,
|
|
provider_install_extra,
|
|
)
|
|
|
|
uninstalled: dict[str, str] = {}
|
|
for provider in PROVIDER_API_KEY_ENV:
|
|
if provider in shown or not config.is_provider_enabled(provider):
|
|
continue
|
|
extra = provider_install_extra(provider)
|
|
if extra is None or is_provider_package_installed(provider):
|
|
continue
|
|
uninstalled[provider] = extra
|
|
return uninstalled
|
|
|
|
@staticmethod
|
|
def _format_label(
|
|
provider: str,
|
|
*,
|
|
installed: bool = True,
|
|
status: ProviderAuthStatus | None = None,
|
|
) -> Content:
|
|
"""Build a `Content` label for `provider` showing its credential source.
|
|
|
|
Args:
|
|
provider: Provider config key.
|
|
installed: Whether the provider's integration package is installed.
|
|
Uninstalled providers render dimmed with a `[not installed]`
|
|
marker since selecting them prompts an install, not a key.
|
|
status: Precomputed auth status to render. Pass this when the
|
|
caller already resolved it to avoid a duplicate credential-file
|
|
read; resolved on demand when omitted. Ignored for uninstalled
|
|
providers, which render no badge.
|
|
|
|
Returns:
|
|
A composed `Content` with the provider label and a status badge.
|
|
"""
|
|
name = _provider_display_name(provider)
|
|
if not installed:
|
|
return Content.assemble(
|
|
Content.styled(name, "dim"),
|
|
" ",
|
|
Content.styled("[not installed]", "dim"),
|
|
)
|
|
if status is None:
|
|
status = _auth_status_for(provider)
|
|
badge = format_auth_badge(status)
|
|
return Content.assemble(
|
|
Content.from_markup("$provider", provider=name),
|
|
" ",
|
|
badge,
|
|
)
|