mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
feat(code): add LangSmith tracing config to /auth (#4193)
LangSmith tracing can now be configured directly from `/auth` (and `dcode auth set langsmith`), including enabling tracing, a temporary opt-out via `DEEPAGENTS_CODE_LANGSMITH_TRACING=false`, and a custom project name. --- Wires LangSmith tracing into the `/auth` surface as a configurable service, mirroring how Tavily is already handled. Storing a LangSmith key via `/auth` persists it to the credential store, bridges it onto `LANGSMITH_API_KEY`, and opts the user into tracing — so configuring tracing no longer requires exporting environment variables before launch. Key behaviors: - A stored key turns tracing on by default. An explicit falsy tracing flag (most simply `DEEPAGENTS_CODE_LANGSMITH_TRACING=false`) is honored as a non-destructive, session-scoped opt-out — pause tracing without deleting the key. The TUI prompt surfaces this path. - An optional project name (advanced field in the TUI prompt, `--project` on `dcode auth set`) routes traces to a custom `LANGSMITH_PROJECT`; the default stays `deepagents-code`. - Services are now surfaced in `dcode auth list`/`status` (this also fixes a pre-existing gap where Tavily was invisible to the CLI). - Bootstrap reordered so a `/auth`-stored key is applied before orphaned-tracing detection, keeping tracing alive instead of being disabled. Made by [Open SWE](https://openswe.vercel.app/agents/2ba1485c-7c70-59b6-82bf-18302c214c6c) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -12,7 +12,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
|
||||
| Command | Aliases | Description |
|
||||
| --- | --- | --- |
|
||||
| `/agents` | | Browse and switch between available agents |
|
||||
| `/auth` | `/connect` | Connect and manage model provider credentials |
|
||||
| `/auth` | `/connect` | Connect and manage provider and service credentials |
|
||||
| `/auto-update` | | Turn automatic updates on or off |
|
||||
| `/changelog` | | Open the changelog in a browser |
|
||||
| `/clear` | | Clear the chat and start a new thread |
|
||||
|
||||
@@ -63,6 +63,7 @@ from deepagents_code.config import (
|
||||
get_default_coding_instructions,
|
||||
get_glyphs,
|
||||
get_langsmith_project_name,
|
||||
restore_user_tracing_env,
|
||||
settings,
|
||||
)
|
||||
from deepagents_code.configurable_model import ConfigurableModelMiddleware
|
||||
@@ -1523,6 +1524,7 @@ def create_cli_agent(
|
||||
shell_env["LANGSMITH_PROJECT"] = settings.user_langchain_project
|
||||
else:
|
||||
shell_env.pop("LANGSMITH_PROJECT", None)
|
||||
restore_user_tracing_env(shell_env)
|
||||
# Re-apply a launch-time PYTHONPATH that was stripped from the server
|
||||
# interpreter but relayed for approval-gated `execute` commands.
|
||||
_apply_inherited_pythonpath(shell_env)
|
||||
|
||||
@@ -101,6 +101,13 @@ def setup_auth_parser(
|
||||
default=None,
|
||||
help="Copy the key from this environment variable instead of stdin",
|
||||
)
|
||||
set_parser.add_argument(
|
||||
"--project",
|
||||
dest="project",
|
||||
metavar="NAME",
|
||||
default=None,
|
||||
help="With `set langsmith`, set a custom LangSmith project name",
|
||||
)
|
||||
set_parser.add_argument(
|
||||
"-h",
|
||||
"--help",
|
||||
@@ -160,7 +167,11 @@ def run_auth_command(args: argparse.Namespace) -> int:
|
||||
if command in {"list", "ls"}:
|
||||
return _run_list()
|
||||
if command == "set":
|
||||
return _run_set(args.provider, from_env=args.from_env)
|
||||
return _run_set(
|
||||
args.provider,
|
||||
from_env=args.from_env,
|
||||
project=getattr(args, "project", None),
|
||||
)
|
||||
if command in {"remove", "rm", "delete"}:
|
||||
return _run_remove(args.provider)
|
||||
if command == "status":
|
||||
@@ -233,6 +244,7 @@ def _known_providers() -> tuple[list[str], str | None]:
|
||||
from deepagents_code.model_config import (
|
||||
CODEX_PROVIDER,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
SERVICE_API_KEY_ENV,
|
||||
ModelConfig,
|
||||
get_available_models,
|
||||
)
|
||||
@@ -253,8 +265,12 @@ def _known_providers() -> tuple[list[str], str | None]:
|
||||
# no API-key env var entry. Mirror the TUI auth manager by showing it when
|
||||
# the OpenAI integration was discovered.
|
||||
codex_installed = {CODEX_PROVIDER} if "openai" in installed else set()
|
||||
# Non-model services (Tavily web search, LangSmith tracing) are always
|
||||
# shown — they are configurable here regardless of any backing package —
|
||||
# so the CLI listing matches the TUI `/auth` manager.
|
||||
services = set(SERVICE_API_KEY_ENV)
|
||||
providers = sorted(
|
||||
well_known_installed | codex_installed | stored | config_providers
|
||||
well_known_installed | codex_installed | stored | config_providers | services
|
||||
)
|
||||
return providers, warning
|
||||
|
||||
@@ -288,13 +304,22 @@ def _warn_if_store_unreadable() -> None:
|
||||
|
||||
def _print_rows(providers: list[str]) -> None:
|
||||
"""Print one `<provider> <status>` row per provider, column-aligned."""
|
||||
from deepagents_code.model_config import get_provider_auth_status
|
||||
from deepagents_code.model_config import (
|
||||
get_provider_auth_status,
|
||||
get_service_auth_status,
|
||||
is_service,
|
||||
)
|
||||
|
||||
if not providers:
|
||||
return
|
||||
width = max(len(name) for name in providers)
|
||||
for provider in providers:
|
||||
label = _resolution_label(get_provider_auth_status(provider))
|
||||
status = (
|
||||
get_service_auth_status(provider)
|
||||
if is_service(provider)
|
||||
else get_provider_auth_status(provider)
|
||||
)
|
||||
label = _resolution_label(status)
|
||||
# Plain stdout so rows stay greppable/pipeable, not Rich-styled.
|
||||
print(f"{provider.ljust(width)} {label}") # noqa: T201
|
||||
|
||||
@@ -333,13 +358,17 @@ def _run_status(provider: str | None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_set(provider: str, *, from_env: str | None) -> int:
|
||||
def _run_set(provider: str, *, from_env: str | None, project: str | None) -> int:
|
||||
"""Store an API key for `provider`, reading it from env or stdin.
|
||||
|
||||
Returns:
|
||||
Process exit code (`0` on success, `1` on a recoverable input error).
|
||||
"""
|
||||
from deepagents_code.model_config import CODEX_PROVIDER
|
||||
from deepagents_code.model_config import (
|
||||
CODEX_PROVIDER,
|
||||
LANGSMITH_SERVICE,
|
||||
is_langsmith,
|
||||
)
|
||||
|
||||
if provider == CODEX_PROVIDER:
|
||||
print( # noqa: T201
|
||||
@@ -349,6 +378,13 @@ def _run_set(provider: str, *, from_env: str | None) -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
if project is not None and not is_langsmith(provider):
|
||||
print( # noqa: T201
|
||||
f"Error: --project is only valid for {LANGSMITH_SERVICE}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
@@ -383,8 +419,17 @@ def _run_set(provider: str, *, from_env: str | None) -> int:
|
||||
return 1
|
||||
|
||||
try:
|
||||
# Preserve a previously stored project unless `--project` overrides it
|
||||
# (an explicit empty value clears it). Resolved inside the try so a
|
||||
# corrupt store surfaces as a clean error, not a traceback.
|
||||
stored_project = (
|
||||
project if project is not None else auth_store.get_stored_project(provider)
|
||||
)
|
||||
outcome = auth_store.set_stored_key(
|
||||
provider, key, base_url=auth_store.get_stored_base_url(provider)
|
||||
provider,
|
||||
key,
|
||||
base_url=auth_store.get_stored_base_url(provider),
|
||||
project=stored_project,
|
||||
)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
# `auth_store` messages never include the secret value. `ValueError`
|
||||
|
||||
@@ -67,6 +67,15 @@ class ApiKeyCredential(TypedDict):
|
||||
the URL.
|
||||
"""
|
||||
|
||||
project: NotRequired[str]
|
||||
"""Optional LangSmith project name paired with this credential.
|
||||
|
||||
Set only for the `langsmith` tracing service when the user supplies a
|
||||
custom project in `/auth`; absent means traces fall back to the default
|
||||
(`deepagents-code`). Not a secret — it is shown in the `/auth` advanced
|
||||
panel and applied to `LANGSMITH_PROJECT` at startup.
|
||||
"""
|
||||
|
||||
|
||||
class OAuthCredential(TypedDict):
|
||||
"""A persisted OAuth subscription credential.
|
||||
@@ -312,6 +321,15 @@ def _coerce_credential(raw: Any) -> StoredCredential | None: # noqa: ANN401
|
||||
logger.warning(
|
||||
"Ignoring malformed base_url for a stored credential: %r", base_url
|
||||
)
|
||||
project = raw.get("project")
|
||||
if isinstance(project, str) and project:
|
||||
credential["project"] = project
|
||||
elif project is not None:
|
||||
# Same rationale as `base_url`: a hand-edited non-string value is
|
||||
# dropped with a trace. `project` is non-secret, so logging is safe.
|
||||
logger.warning(
|
||||
"Ignoring malformed project for a stored credential: %r", project
|
||||
)
|
||||
return credential
|
||||
# OAuth is reserved for a future PR — silently skip until the producer
|
||||
# path lands. `cred_type in {"oauth"}` falls through to None here.
|
||||
@@ -352,8 +370,28 @@ def get_stored_base_url(provider: str) -> str | None:
|
||||
return entry.get("base_url") or None
|
||||
|
||||
|
||||
def get_stored_project(provider: str) -> str | None:
|
||||
"""Return the LangSmith project paired with `provider`'s stored key, or `None`.
|
||||
|
||||
Returns `None` when no key is stored and when a key is stored without a
|
||||
custom project (the user left the field blank, meaning "use the default").
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the credential file is corrupt.
|
||||
""" # noqa: DOC502 - re-raised from `_read_raw` via `load_credentials`
|
||||
creds = load_credentials()
|
||||
entry = creds.get(provider)
|
||||
if entry is None or entry["type"] != "api_key":
|
||||
return None
|
||||
return entry.get("project") or None
|
||||
|
||||
|
||||
def set_stored_key(
|
||||
provider: str, key: str, *, base_url: str | None = None
|
||||
provider: str,
|
||||
key: str,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> WriteOutcome:
|
||||
"""Persist an API key for `provider`.
|
||||
|
||||
@@ -362,19 +400,31 @@ def set_stored_key(
|
||||
`apply_stored_credentials` in `model_config` — a stored empty would
|
||||
unconditionally overwrite the env var).
|
||||
|
||||
This rewrites the whole credential record: `base_url` and `project` are
|
||||
*not* merged with any previously stored values. Passing blank/`None` for
|
||||
either clears it, so a caller rotating a key while wanting to keep the
|
||||
existing endpoint/project must read it back (e.g. via `get_stored_base_url`
|
||||
/ `get_stored_project`) and pass it in again.
|
||||
|
||||
Args:
|
||||
provider: Provider identifier (e.g., `"anthropic"`).
|
||||
key: The API key value. Whitespace is stripped before storage.
|
||||
base_url: Optional provider endpoint to pair with the key. Whitespace
|
||||
is stripped; blank/`None` stores no endpoint, meaning the key uses
|
||||
the provider default rather than any inherited (e.g. gateway) URL.
|
||||
project: Optional LangSmith project name to pair with the key. Valid
|
||||
only for the `langsmith` tracing service. Whitespace is stripped;
|
||||
blank/`None` stores no project, meaning traces use the default
|
||||
project.
|
||||
|
||||
Returns:
|
||||
A `WriteOutcome` whose `warnings` tuple lists chmod failures the
|
||||
caller should surface to the user. Empty on a clean save.
|
||||
|
||||
Raises:
|
||||
ValueError: If `provider` or the stripped `key` is empty.
|
||||
ValueError: If `provider` or the stripped `key` is empty, or a non-empty
|
||||
`project` is paired with a provider other than the `langsmith`
|
||||
service.
|
||||
RuntimeError: If the credential file is corrupt and cannot be read, or
|
||||
the new file cannot be written (e.g. no disk space or an
|
||||
unwritable state directory).
|
||||
@@ -398,6 +448,19 @@ def set_stored_key(
|
||||
cleaned_base_url = base_url.strip() if base_url else ""
|
||||
if cleaned_base_url:
|
||||
entry["base_url"] = cleaned_base_url
|
||||
cleaned_project = project.strip() if project else ""
|
||||
if cleaned_project:
|
||||
# A project name is meaningful only for the LangSmith tracing service;
|
||||
# enforce the invariant at the write boundary so a stray project can
|
||||
# never be persisted onto an unrelated provider, regardless of caller.
|
||||
# Lazy import avoids a circular dependency (model_config imports this
|
||||
# module), matching the pattern in `auth_path`.
|
||||
from deepagents_code.model_config import is_langsmith
|
||||
|
||||
if not is_langsmith(provider):
|
||||
msg = f"project is only valid for the langsmith service, not {provider!r}"
|
||||
raise ValueError(msg)
|
||||
entry["project"] = cleaned_project
|
||||
creds[provider] = entry
|
||||
data["version"] = _STORAGE_VERSION
|
||||
data["credentials"] = creds
|
||||
|
||||
@@ -80,9 +80,11 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
),
|
||||
SlashCommand(
|
||||
name="/auth",
|
||||
description="Connect and manage model provider credentials",
|
||||
description="Connect and manage provider and service credentials",
|
||||
bypass_tier=BypassTier.IMMEDIATE_UI,
|
||||
hidden_keywords="key keys credential credentials login token api",
|
||||
hidden_keywords=(
|
||||
"key keys credential credentials login token api tracing langsmith"
|
||||
),
|
||||
aliases=("/connect",),
|
||||
),
|
||||
SlashCommand(
|
||||
|
||||
@@ -12,7 +12,7 @@ import shlex
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from enum import StrEnum
|
||||
from importlib.metadata import PackageNotFoundError, distribution, version
|
||||
from pathlib import Path
|
||||
@@ -41,8 +41,26 @@ logger = logging.getLogger(__name__)
|
||||
# callers that never touch `settings` (e.g. `deepagents --help`).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_bootstrap_done = False
|
||||
"""Whether `_ensure_bootstrap()` has executed."""
|
||||
|
||||
@dataclass
|
||||
class _BootstrapState:
|
||||
"""Mutable state captured by `_ensure_bootstrap()`."""
|
||||
|
||||
done: bool = False
|
||||
"""Whether `_ensure_bootstrap()` has executed."""
|
||||
|
||||
start_path: Path | None = None
|
||||
"""Working directory captured at bootstrap time for dotenv and discovery."""
|
||||
|
||||
original_langsmith_project: str | None = None
|
||||
"""Caller's `LANGSMITH_PROJECT` before the app overrides it for traces."""
|
||||
|
||||
original_tracing_env: dict[str, str | None] = dataclass_field(default_factory=dict)
|
||||
"""Caller's tracing-enable env before Deep Agents Code mutates flags."""
|
||||
|
||||
|
||||
_bootstrap_state = _BootstrapState()
|
||||
"""State captured and mutated by lazy bootstrap."""
|
||||
|
||||
_bootstrap_lock = threading.Lock()
|
||||
"""Guards `_ensure_bootstrap()` against concurrent access from the main thread
|
||||
@@ -51,16 +69,6 @@ and the prewarm worker thread."""
|
||||
_singleton_lock = threading.Lock()
|
||||
"""Guards lazy singleton construction in `_get_console` / `_get_settings`."""
|
||||
|
||||
_bootstrap_start_path: Path | None = None
|
||||
"""Working directory captured at bootstrap time for dotenv and project discovery."""
|
||||
|
||||
_original_langsmith_project: str | None = None
|
||||
"""Caller's `LANGSMITH_PROJECT` value before the app overrides it for agent traces.
|
||||
|
||||
Captured inside `_ensure_bootstrap()` after dotenv loading but before the
|
||||
`LANGSMITH_PROJECT` override, so `.env`-only values are visible.
|
||||
"""
|
||||
|
||||
_dotenv_loaded_values: dict[str, str] = {}
|
||||
"""Environment values injected by our dotenv loader and safe to refresh later."""
|
||||
|
||||
@@ -438,6 +446,31 @@ def _tracing_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _disable_set_tracing_flags() -> list[str]:
|
||||
"""Set every configured tracing-enable flag to `false`.
|
||||
|
||||
Returns:
|
||||
Env var names that were disabled.
|
||||
"""
|
||||
disabled = [var for var in _TRACING_ENABLE_ENV_VARS if var in os.environ]
|
||||
for var in disabled:
|
||||
os.environ[var] = "false"
|
||||
return disabled
|
||||
|
||||
|
||||
def restore_user_tracing_env(env: dict[str, str]) -> None:
|
||||
"""Restore caller tracing flags in an environment passed to user code.
|
||||
|
||||
Args:
|
||||
env: Environment mapping prepared for a child/user subprocess.
|
||||
"""
|
||||
for var, value in _bootstrap_state.original_tracing_env.items():
|
||||
if value is None:
|
||||
env.pop(var, None)
|
||||
else:
|
||||
env[var] = value
|
||||
|
||||
|
||||
def _disable_orphaned_tracing() -> None:
|
||||
"""Disable LangSmith tracing when enabled without a usable API key.
|
||||
|
||||
@@ -471,9 +504,7 @@ def _disable_orphaned_tracing() -> None:
|
||||
if has_key or _has_langsmith_profile_credentials():
|
||||
return
|
||||
|
||||
disabled = [var for var in _TRACING_ENABLE_ENV_VARS if var in os.environ]
|
||||
for var in disabled:
|
||||
os.environ[var] = "false"
|
||||
disabled = _disable_set_tracing_flags()
|
||||
_orphaned_tracing_disabled_notice = _build_orphaned_tracing_disabled_notice()
|
||||
logger.warning(
|
||||
"LangSmith tracing is enabled (%s) but no API key is set; disabling "
|
||||
@@ -503,6 +534,101 @@ def _apply_default_langsmith_project() -> None:
|
||||
os.environ["LANGSMITH_PROJECT"] = LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
|
||||
def apply_stored_langsmith_auth(*, replace_project: bool = False) -> None:
|
||||
"""Apply a `/auth`-stored LangSmith key and tracing settings now.
|
||||
|
||||
Args:
|
||||
replace_project: Whether the stored LangSmith project should replace
|
||||
the current process `LANGSMITH_PROJECT`. Startup leaves this false
|
||||
so an explicit environment value remains authoritative; the `/auth`
|
||||
save path sets it true because the saved project is the newest user
|
||||
choice for the already-running session.
|
||||
"""
|
||||
from deepagents_code.model_config import apply_stored_service_credentials
|
||||
|
||||
apply_stored_service_credentials()
|
||||
_apply_stored_langsmith_tracing(replace_project=replace_project)
|
||||
_disable_orphaned_tracing()
|
||||
_apply_default_langsmith_project()
|
||||
|
||||
|
||||
def _apply_stored_langsmith_tracing(*, replace_project: bool = False) -> None:
|
||||
"""Enable tracing (and apply a custom project) for a `/auth`-stored key.
|
||||
|
||||
Storing a LangSmith key via `/auth` is a deliberate opt-in to tracing, but
|
||||
a key alone never starts tracing — the SDK only traces when a tracing-enable
|
||||
flag is truthy. So when a key is stored, turn tracing on by default.
|
||||
|
||||
The opt-out is intentionally non-destructive and session-scoped: an explicit
|
||||
falsy tracing flag (most simply `DEEPAGENTS_CODE_LANGSMITH_TRACING=false`,
|
||||
which bootstrap bridges to `LANGSMITH_TRACING`) is honored and tracing stays
|
||||
off, so the stored key can be paused without deleting it. A custom stored
|
||||
project is applied to `LANGSMITH_PROJECT` when the user has not set one,
|
||||
unless `replace_project` is set for the immediate `/auth` save path.
|
||||
|
||||
No-op when no LangSmith key is stored, so a key supplied only through the
|
||||
environment keeps the prior behavior (tracing stays off unless a flag is
|
||||
set).
|
||||
|
||||
A stored key is trusted by *presence*, not validity: this never pings
|
||||
LangSmith (a network round-trip at startup would fight the package's
|
||||
startup-perf budget). So a stored-but-invalid key (typo'd, revoked, or for
|
||||
the wrong workspace) still force-enables tracing, and its traces are then
|
||||
silently dropped at ingest with only SDK-internal 401s — which
|
||||
`_quiet_sdk_tracing_logging` routes away from the TUI. `_disable_orphaned_tracing`
|
||||
and `consume_orphaned_tracing_disabled_notice` guard only the *absent*-key
|
||||
case, not the invalid-key case. If traces never appear, the key is the first
|
||||
thing to re-check via `/auth`.
|
||||
|
||||
The store is read exactly once: a single corrupt-file `RuntimeError` is
|
||||
logged and treated as "no stored key" rather than being raised (bootstrap
|
||||
must never crash the app) or partially applied.
|
||||
"""
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code._env_vars import classify_env_bool
|
||||
from deepagents_code.model_config import LANGSMITH_SERVICE
|
||||
|
||||
try:
|
||||
creds = auth_store.load_credentials()
|
||||
except RuntimeError:
|
||||
logger.warning(
|
||||
"Could not read the stored LangSmith credential; the credential file "
|
||||
"may be corrupt. Re-add the key via /auth."
|
||||
)
|
||||
return
|
||||
entry = creds.get(LANGSMITH_SERVICE)
|
||||
# No-op unless a LangSmith API key was stored via `/auth`. A key supplied
|
||||
# only through the environment never lands here, keeping its prior behavior
|
||||
# (tracing stays off unless a flag is set).
|
||||
if entry is None or entry["type"] != "api_key" or not entry["key"]:
|
||||
return
|
||||
|
||||
# The key was bridged onto LANGSMITH_API_KEY by
|
||||
# `apply_stored_service_credentials`. Decide whether to enable tracing.
|
||||
flags = [
|
||||
classify_env_bool(os.environ[var])
|
||||
for var in _TRACING_ENABLE_ENV_VARS
|
||||
if var in os.environ
|
||||
]
|
||||
if any(flag is False for flag in flags):
|
||||
# Explicit, deliberate opt-out — keep the key but make the opt-out
|
||||
# authoritative over sibling SDK tracing flags.
|
||||
_disable_set_tracing_flags()
|
||||
return
|
||||
if not any(flag is True for flag in flags):
|
||||
os.environ["LANGSMITH_TRACING"] = "true"
|
||||
|
||||
project = entry.get("project") or None
|
||||
if replace_project:
|
||||
if project:
|
||||
os.environ["LANGSMITH_PROJECT"] = project
|
||||
else:
|
||||
os.environ.pop("LANGSMITH_PROJECT", None)
|
||||
return
|
||||
if project and not os.environ.get("LANGSMITH_PROJECT"):
|
||||
os.environ["LANGSMITH_PROJECT"] = project
|
||||
|
||||
|
||||
def _ensure_bootstrap() -> None:
|
||||
"""Run one-time bootstrap: dotenv loading and `LANGSMITH_PROJECT` override.
|
||||
|
||||
@@ -514,13 +640,11 @@ def _ensure_bootstrap() -> None:
|
||||
loops. Exceptions are caught and logged at ERROR level; the app proceeds
|
||||
with the environment as-is.
|
||||
"""
|
||||
global _bootstrap_done, _bootstrap_start_path, _original_langsmith_project # noqa: PLW0603
|
||||
|
||||
if _bootstrap_done:
|
||||
if _bootstrap_state.done:
|
||||
return
|
||||
|
||||
with _bootstrap_lock:
|
||||
if _bootstrap_done: # double-check after acquiring lock
|
||||
if _bootstrap_state.done: # double-check after acquiring lock
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -529,8 +653,8 @@ def _ensure_bootstrap() -> None:
|
||||
)
|
||||
|
||||
ctx = _get_server_project_context()
|
||||
_bootstrap_start_path = ctx.user_cwd if ctx else None
|
||||
_load_dotenv(start_path=_bootstrap_start_path)
|
||||
_bootstrap_state.start_path = ctx.user_cwd if ctx else None
|
||||
_load_dotenv(start_path=_bootstrap_state.start_path)
|
||||
|
||||
# `configure_debug_logging` already ran at import, before the `.env`
|
||||
# above was loaded. Re-run it so a `DEEPAGENTS_CODE_DEBUG` set only in
|
||||
@@ -546,7 +670,12 @@ def _ensure_bootstrap() -> None:
|
||||
|
||||
# Capture AFTER dotenv loading so .env-only values are visible,
|
||||
# but BEFORE the override below replaces it.
|
||||
_original_langsmith_project = os.environ.get("LANGSMITH_PROJECT")
|
||||
_bootstrap_state.original_langsmith_project = os.environ.get(
|
||||
"LANGSMITH_PROJECT"
|
||||
)
|
||||
_bootstrap_state.original_tracing_env = {
|
||||
var: os.environ.get(var) for var in _TRACING_ENABLE_ENV_VARS
|
||||
}
|
||||
|
||||
# CRITICAL: Override LANGSMITH_PROJECT to route agent traces to a
|
||||
# separate project. LangSmith reads LANGSMITH_PROJECT at invocation
|
||||
@@ -589,29 +718,18 @@ def _ensure_bootstrap() -> None:
|
||||
canonical,
|
||||
)
|
||||
|
||||
# Tracing enabled without a key floods the TUI with 401 ingest
|
||||
# errors; disable it before any traced run starts.
|
||||
_disable_orphaned_tracing()
|
||||
|
||||
# If tracing is still active but no project is configured, default
|
||||
# to `deepagents-code` so ingestion matches the name we display and
|
||||
# look up. Runs after `_disable_orphaned_tracing` so a keyless setup
|
||||
# (tracing already turned off) is left untouched.
|
||||
_apply_default_langsmith_project()
|
||||
|
||||
# Bridge stored service keys (e.g. Tavily web search, entered via
|
||||
# `/auth`) onto their canonical env vars before settings detection,
|
||||
# so a stored key activates the feature without exporting the var.
|
||||
from deepagents_code.model_config import apply_stored_service_credentials
|
||||
|
||||
apply_stored_service_credentials()
|
||||
# Bridge stored service keys, apply stored LangSmith tracing defaults,
|
||||
# disable orphaned tracing, and route active tracing to the displayed
|
||||
# project. Keeping this in one helper lets `/auth` save apply the same
|
||||
# state immediately inside an already-running TUI session.
|
||||
apply_stored_langsmith_auth()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Bootstrap failed; .env values and LANGSMITH_PROJECT override "
|
||||
"may be missing. The app will proceed with environment as-is.",
|
||||
)
|
||||
finally:
|
||||
_bootstrap_done = True
|
||||
_bootstrap_state.done = True
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1783,7 +1901,9 @@ class Settings:
|
||||
)
|
||||
|
||||
deepagents_langchain_project = resolve_env_var(LANGSMITH_PROJECT)
|
||||
user_langchain_project = _original_langsmith_project # Use saved original!
|
||||
# Use the saved original, not the current `LANGSMITH_PROJECT` that
|
||||
# bootstrap may have overridden for agent traces.
|
||||
user_langchain_project = _bootstrap_state.original_langsmith_project
|
||||
|
||||
# Detect project
|
||||
from deepagents_code.project_utils import find_project_root
|
||||
@@ -1987,8 +2107,10 @@ class Settings:
|
||||
# re-apply the default so ingestion keeps matching the name
|
||||
# `get_langsmith_project_name` displays (the default is a no-op when
|
||||
# tracing is off, so a disabled setup is left unset).
|
||||
if _original_langsmith_project:
|
||||
os.environ["LANGSMITH_PROJECT"] = _original_langsmith_project
|
||||
if _bootstrap_state.original_langsmith_project:
|
||||
os.environ["LANGSMITH_PROJECT"] = (
|
||||
_bootstrap_state.original_langsmith_project
|
||||
)
|
||||
else:
|
||||
os.environ.pop("LANGSMITH_PROJECT", None)
|
||||
_apply_default_langsmith_project()
|
||||
@@ -3788,11 +3910,11 @@ def _get_settings() -> Settings:
|
||||
return cached
|
||||
_ensure_bootstrap()
|
||||
try:
|
||||
inst = Settings.from_environment(start_path=_bootstrap_start_path)
|
||||
inst = Settings.from_environment(start_path=_bootstrap_state.start_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to initialize settings from environment (start_path=%s)",
|
||||
_bootstrap_start_path,
|
||||
_bootstrap_state.start_path,
|
||||
)
|
||||
raise
|
||||
globals()["settings"] = inst
|
||||
|
||||
@@ -547,15 +547,25 @@ Providers not listed here fall through to the config-file check or the langchain
|
||||
registry fallback.
|
||||
"""
|
||||
|
||||
LANGSMITH_SERVICE = "langsmith"
|
||||
"""Service name for LangSmith tracing in `SERVICE_API_KEY_ENV`.
|
||||
|
||||
Storing a key for this service via `/auth` also enables tracing at startup
|
||||
(see `config._apply_stored_langsmith_tracing`) and can carry a custom project
|
||||
name, so it gets special handling beyond a plain key copy.
|
||||
"""
|
||||
|
||||
SERVICE_API_KEY_ENV: dict[str, str] = {
|
||||
LANGSMITH_SERVICE: "LANGSMITH_API_KEY",
|
||||
"tavily": "TAVILY_API_KEY",
|
||||
}
|
||||
"""Non-model services configurable via `/auth`, mapped to their API-key env var.
|
||||
|
||||
These are not LLM providers — they back features such as web search — but
|
||||
their credentials follow the same store-on-disk model as model providers, so
|
||||
they appear in the `/auth` manager and can be entered directly in the TUI
|
||||
instead of being exported as environment variables before launch.
|
||||
These are not LLM providers — they back features such as web search (Tavily) or
|
||||
agent tracing (LangSmith) — but their credentials follow the same store-on-disk
|
||||
model as model providers, so they appear in the `/auth` manager and can be
|
||||
entered directly in the TUI instead of being exported as environment variables
|
||||
before launch.
|
||||
"""
|
||||
|
||||
CODEX_PROVIDER = "openai_codex"
|
||||
@@ -2012,6 +2022,16 @@ def is_service(name: str) -> bool:
|
||||
return name in SERVICE_API_KEY_ENV
|
||||
|
||||
|
||||
def is_langsmith(name: str) -> bool:
|
||||
"""Return whether `name` is the LangSmith tracing service.
|
||||
|
||||
Centralizes the identity check so the LangSmith-specific branches (project
|
||||
field instead of a base URL, tracing auto-enable) share one definition
|
||||
rather than scattering `== LANGSMITH_SERVICE` comparisons.
|
||||
"""
|
||||
return name == LANGSMITH_SERVICE
|
||||
|
||||
|
||||
def get_service_auth_status(service: str) -> ProviderAuthStatus:
|
||||
"""Return credential readiness for a non-model service (e.g. `"tavily"`).
|
||||
|
||||
@@ -2042,9 +2062,10 @@ def apply_stored_service_credentials() -> None:
|
||||
|
||||
Services (e.g. web search via Tavily) have no base URL to reconcile, so
|
||||
this is a plain key copy onto the canonical env var name the underlying
|
||||
SDK reads. A stored key takes precedence over an existing env var, matching
|
||||
`apply_stored_credentials`. Only the unprefixed canonical name is written,
|
||||
so a `DEEPAGENTS_CODE_`-prefixed override still wins via `resolve_env_var`.
|
||||
SDK reads. A stored key takes precedence over an existing plain env var,
|
||||
matching `apply_stored_credentials`; a `DEEPAGENTS_CODE_`-prefixed override
|
||||
is left authoritative because the app already treats it as the top-priority
|
||||
per-session credential.
|
||||
"""
|
||||
for service, env_var in SERVICE_API_KEY_ENV.items():
|
||||
try:
|
||||
@@ -2056,7 +2077,12 @@ def apply_stored_service_credentials() -> None:
|
||||
service,
|
||||
)
|
||||
continue
|
||||
if stored and os.environ.get(env_var) != stored:
|
||||
if not stored:
|
||||
continue
|
||||
prefixed = f"{_ENV_PREFIX}{env_var}"
|
||||
if prefixed in os.environ:
|
||||
continue
|
||||
if os.environ.get(env_var) != stored:
|
||||
os.environ[env_var] = stored
|
||||
|
||||
|
||||
|
||||
@@ -635,6 +635,7 @@ def show_auth_help() -> None:
|
||||
console.print()
|
||||
console.print("[bold]Options:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" --from-env VAR With `set`, copy the key from env var VAR")
|
||||
console.print(" --project NAME With `set langsmith`, set the trace project")
|
||||
console.print(" -h, --help Show this help message")
|
||||
console.print()
|
||||
console.print(
|
||||
@@ -648,6 +649,9 @@ def show_auth_help() -> None:
|
||||
console.print(" dcode auth list")
|
||||
console.print(" echo $ANTHROPIC_API_KEY | dcode auth set anthropic")
|
||||
console.print(" dcode auth set openai --from-env OPENAI_API_KEY")
|
||||
console.print(
|
||||
" echo $LANGSMITH_API_KEY | dcode auth set langsmith --project my-app"
|
||||
)
|
||||
console.print(" dcode auth status anthropic")
|
||||
console.print(" dcode auth remove anthropic")
|
||||
console.print(" dcode auth path")
|
||||
|
||||
@@ -41,7 +41,11 @@ if TYPE_CHECKING:
|
||||
|
||||
from deepagents_code import auth_store, theme
|
||||
from deepagents_code.auth_display import format_auth_badge
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
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,
|
||||
@@ -58,6 +62,7 @@ from deepagents_code.model_config import (
|
||||
get_default_base_url_env,
|
||||
get_provider_auth_status,
|
||||
get_service_auth_status,
|
||||
is_langsmith,
|
||||
is_service,
|
||||
resolved_env_var_name,
|
||||
)
|
||||
@@ -83,6 +88,7 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||
"groq": "Groq",
|
||||
"huggingface": "Hugging Face",
|
||||
"ibm": "IBM watsonx",
|
||||
"langsmith": "LangSmith (tracing)",
|
||||
"litellm": "LiteLLM",
|
||||
"mistralai": "Mistral AI",
|
||||
"nvidia": "NVIDIA",
|
||||
@@ -105,6 +111,7 @@ PROVIDER_API_KEY_URLS: dict[str, str] = {
|
||||
"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",
|
||||
@@ -495,6 +502,10 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
self._provider = provider
|
||||
self._env_var = env_var
|
||||
self._reason = reason
|
||||
# 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.
|
||||
@@ -508,7 +519,10 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
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._advanced_visible = bool(self._existing_base_url)
|
||||
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(
|
||||
@@ -520,6 +534,7 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
)
|
||||
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."
|
||||
@@ -629,12 +644,20 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
password=True,
|
||||
id="auth-prompt-input",
|
||||
)
|
||||
yield Static(
|
||||
Content.from_markup(
|
||||
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]."
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
yield Static(
|
||||
storage_note,
|
||||
classes="auth-prompt-meta",
|
||||
id="auth-prompt-storage-note",
|
||||
)
|
||||
@@ -665,27 +688,53 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
)
|
||||
key_meta.display = self._advanced_visible
|
||||
yield key_meta
|
||||
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
|
||||
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 = "Enter replace" if self._has_existing else "Enter save"
|
||||
help_parts = [f"{save_label} {glyphs.bullet} Esc cancel", "F2 advanced"]
|
||||
@@ -824,6 +873,9 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
"#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
|
||||
@@ -871,17 +923,26 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
"""Validate, persist, and dismiss.
|
||||
|
||||
Reads both fields regardless of which one was submitted, so pressing
|
||||
Enter in either the key or the base-URL input saves the pair.
|
||||
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()
|
||||
base_url = self.query_one("#auth-prompt-base-url", 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:
|
||||
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
|
||||
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,
|
||||
@@ -897,6 +958,8 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -162,6 +162,64 @@ class TestSet:
|
||||
assert auth_store.get_stored_key("openai") == "sk-new"
|
||||
assert auth_store.get_stored_base_url("openai") == "https://gateway.example/v1"
|
||||
|
||||
def test_set_langsmith_with_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`--project` stores a custom LangSmith project alongside the key."""
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("lsv2_test\n"))
|
||||
code = run_auth_command(
|
||||
_ns(
|
||||
auth_command="set",
|
||||
provider="langsmith",
|
||||
from_env=None,
|
||||
project="my-app",
|
||||
)
|
||||
)
|
||||
assert code == 0
|
||||
assert auth_store.get_stored_key("langsmith") == "lsv2_test"
|
||||
assert auth_store.get_stored_project("langsmith") == "my-app"
|
||||
|
||||
def test_set_langsmith_preserves_existing_project(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rotating the key without `--project` keeps the stored project."""
|
||||
auth_store.set_stored_key("langsmith", "old", project="my-app")
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("new\n"))
|
||||
code = run_auth_command(
|
||||
_ns(auth_command="set", provider="langsmith", from_env=None, project=None)
|
||||
)
|
||||
assert code == 0
|
||||
assert auth_store.get_stored_key("langsmith") == "new"
|
||||
assert auth_store.get_stored_project("langsmith") == "my-app"
|
||||
|
||||
def test_set_langsmith_empty_project_clears_existing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An explicit empty `--project` clears a previously stored project."""
|
||||
auth_store.set_stored_key("langsmith", "old", project="my-app")
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("new\n"))
|
||||
code = run_auth_command(
|
||||
_ns(auth_command="set", provider="langsmith", from_env=None, project="")
|
||||
)
|
||||
assert code == 0
|
||||
assert auth_store.get_stored_key("langsmith") == "new"
|
||||
assert auth_store.get_stored_project("langsmith") is None
|
||||
|
||||
def test_set_project_rejected_for_non_langsmith(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""`--project` is only valid for the langsmith service."""
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-ant\n"))
|
||||
code = run_auth_command(
|
||||
_ns(
|
||||
auth_command="set",
|
||||
provider="anthropic",
|
||||
from_env=None,
|
||||
project="my-app",
|
||||
)
|
||||
)
|
||||
assert code == 1
|
||||
assert auth_store.get_stored_key("anthropic") is None
|
||||
assert "--project is only valid for langsmith" in capsys.readouterr().err
|
||||
|
||||
def test_set_from_unset_env_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
@@ -424,6 +482,15 @@ class TestStatus:
|
||||
assert code == 0
|
||||
assert "env: ANTHROPIC_API_KEY" in capsys.readouterr().out
|
||||
|
||||
def test_status_service_stored(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A stored service (langsmith) resolves via the service status path."""
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
code = run_auth_command(_ns(auth_command="status", provider="langsmith"))
|
||||
assert code == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "langsmith" in out
|
||||
assert "stored" in out
|
||||
|
||||
def test_status_env_uses_prefixed_override(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
@@ -555,29 +622,39 @@ class TestList:
|
||||
tmp_path / "missing.toml",
|
||||
)
|
||||
|
||||
assert _known_providers() == (["openai", "openai_codex"], None)
|
||||
from deepagents_code.model_config import SERVICE_API_KEY_ENV
|
||||
|
||||
expected = sorted({"openai", "openai_codex", *SERVICE_API_KEY_ENV})
|
||||
assert _known_providers() == (expected, None)
|
||||
code = run_auth_command(_ns(auth_command="list"))
|
||||
|
||||
assert code == 0
|
||||
assert "openai_codex" in capsys.readouterr().out
|
||||
|
||||
@pytest.mark.usefixtures("clean_model_caches")
|
||||
def test_list_empty_when_no_providers(
|
||||
def test_list_shows_services_when_no_model_providers(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""With no installed, stored, or configured providers, say so plainly."""
|
||||
"""Services (e.g. LangSmith tracing) are always listed, even with no models.
|
||||
|
||||
Mirrors the TUI `/auth` manager, where services are configurable
|
||||
regardless of whether any model-provider package is installed.
|
||||
"""
|
||||
monkeypatch.setattr("deepagents_code.model_config.get_available_models", dict)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "missing.toml",
|
||||
)
|
||||
assert _known_providers() == ([], None)
|
||||
|
||||
from deepagents_code.model_config import SERVICE_API_KEY_ENV
|
||||
|
||||
assert _known_providers() == (sorted(SERVICE_API_KEY_ENV), None)
|
||||
code = run_auth_command(_ns(auth_command="list"))
|
||||
assert code == 0
|
||||
assert "No providers found." in capsys.readouterr().out
|
||||
assert "langsmith" in capsys.readouterr().out
|
||||
|
||||
def test_list_warns_when_store_corrupt(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
|
||||
@@ -89,6 +89,67 @@ class TestRoundTrip:
|
||||
assert auth_store.get_stored_base_url("openai") is None
|
||||
assert auth_store.get_stored_key("openai") == "k"
|
||||
|
||||
def test_project_round_trips(self) -> None:
|
||||
"""A stored project is persisted alongside the key."""
|
||||
auth_store.set_stored_key("langsmith", "k", project=" my-app ")
|
||||
assert auth_store.get_stored_key("langsmith") == "k"
|
||||
assert auth_store.get_stored_project("langsmith") == "my-app"
|
||||
|
||||
def test_blank_project_not_stored(self) -> None:
|
||||
"""A blank/omitted project stores nothing (use the default project)."""
|
||||
auth_store.set_stored_key("langsmith", "k", project=" ")
|
||||
assert auth_store.get_stored_project("langsmith") is None
|
||||
auth_store.set_stored_key("langsmith", "k")
|
||||
assert auth_store.get_stored_project("langsmith") is None
|
||||
|
||||
def test_resave_without_project_drops_it(self) -> None:
|
||||
"""Replacing a credential without a project clears the prior one."""
|
||||
auth_store.set_stored_key("langsmith", "k", project="my-app")
|
||||
auth_store.set_stored_key("langsmith", "k2")
|
||||
assert auth_store.get_stored_project("langsmith") is None
|
||||
|
||||
def test_project_rejected_for_non_langsmith(self) -> None:
|
||||
"""A non-empty project may only pair with the langsmith service.
|
||||
|
||||
The invariant is enforced at the write boundary (not just in the CLI),
|
||||
and nothing is persisted when it is violated.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="langsmith"):
|
||||
auth_store.set_stored_key("anthropic", "k", project="my-app")
|
||||
assert auth_store.get_stored_key("anthropic") is None
|
||||
|
||||
def test_blank_project_allowed_for_non_langsmith(self) -> None:
|
||||
"""A blank project is a no-op for any provider, so the key still stores."""
|
||||
auth_store.set_stored_key("anthropic", "k", project=" ")
|
||||
assert auth_store.get_stored_key("anthropic") == "k"
|
||||
assert auth_store.get_stored_project("anthropic") is None
|
||||
|
||||
def test_malformed_project_is_dropped_and_logged(
|
||||
self, fake_home: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A non-string project is dropped, the key survives, and it's logged."""
|
||||
path = _auth_file(fake_home)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"credentials": {
|
||||
"langsmith": {
|
||||
"type": "api_key",
|
||||
"key": "k",
|
||||
"added_at": "",
|
||||
"project": 1234,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
with caplog.at_level("WARNING", logger="deepagents_code.auth_store"):
|
||||
assert auth_store.get_stored_project("langsmith") is None
|
||||
assert auth_store.get_stored_key("langsmith") == "k"
|
||||
assert any("malformed project" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_malformed_base_url_is_dropped_and_logged(
|
||||
self, fake_home: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
||||
@@ -140,15 +140,29 @@ class TestAuthPromptScreen:
|
||||
|
||||
def test_provider_metadata_maps_reference_known_providers(self) -> None:
|
||||
"""Map keys stay in sync with real providers so none silently never resolve."""
|
||||
known = set(model_config.PROVIDER_API_KEY_ENV) | {model_config.CODEX_PROVIDER}
|
||||
# Services (e.g. LangSmith tracing) may carry a display name and key URL
|
||||
# but live in SERVICE_API_KEY_ENV rather than PROVIDER_API_KEY_ENV.
|
||||
known = (
|
||||
set(model_config.PROVIDER_API_KEY_ENV)
|
||||
| set(model_config.SERVICE_API_KEY_ENV)
|
||||
| {model_config.CODEX_PROVIDER}
|
||||
)
|
||||
assert set(PROVIDER_DISPLAY_NAMES) <= known
|
||||
# Codex uses ChatGPT login, not an API-key page, so it has no key URL.
|
||||
# Services (e.g. Tavily) carry a key URL but live in SERVICE_API_KEY_ENV.
|
||||
assert set(PROVIDER_API_KEY_URLS) <= (
|
||||
set(model_config.PROVIDER_API_KEY_ENV)
|
||||
| set(model_config.SERVICE_API_KEY_ENV)
|
||||
)
|
||||
|
||||
def test_langsmith_service_has_display_name_and_key_url(self) -> None:
|
||||
"""LangSmith tracing surfaces a branded label and a key-acquisition URL."""
|
||||
assert PROVIDER_DISPLAY_NAMES[model_config.LANGSMITH_SERVICE] == (
|
||||
"LangSmith (tracing)"
|
||||
)
|
||||
assert PROVIDER_API_KEY_URLS[model_config.LANGSMITH_SERVICE] == (
|
||||
"https://smith.langchain.com/settings"
|
||||
)
|
||||
|
||||
def test_every_known_provider_has_a_display_name(self) -> None:
|
||||
"""A new provider can't ship without a branded `/auth` label.
|
||||
|
||||
@@ -516,6 +530,19 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
assert base_url.display is True
|
||||
assert base_url.value == "https://proxy.example/v1"
|
||||
|
||||
async def test_existing_project_expands_advanced_by_default(self) -> None:
|
||||
"""A stored LangSmith project keeps the advanced section visible."""
|
||||
auth_store.set_stored_key("langsmith", "lsv2_existing", project="my-app")
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
|
||||
await pilot.pause()
|
||||
project_label = app.screen.query_one("#auth-prompt-project-label", Static)
|
||||
project_input = app.screen.query_one("#auth-prompt-project", Input)
|
||||
assert project_label.display is True
|
||||
assert project_input.display is True
|
||||
assert project_input.value == "my-app"
|
||||
|
||||
async def test_paste_and_submit_persists(self) -> None:
|
||||
"""Submitting a non-empty value writes to the store and dismisses True."""
|
||||
app = _AuthHostApp()
|
||||
@@ -530,6 +557,35 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
assert auth_store.get_stored_key("anthropic") == "sk-ant-test-12345"
|
||||
|
||||
async def test_langsmith_submit_applies_tracing_env_immediately(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Saving LangSmith auth activates tracing in the running process."""
|
||||
import os
|
||||
|
||||
for var in (
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGSMITH_TRACING",
|
||||
"LANGCHAIN_TRACING_V2",
|
||||
"LANGSMITH_PROJECT",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_TRACING",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_PROJECT",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#auth-prompt-input", Input).value = "lsv2_live"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_live"
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
|
||||
async def test_base_url_round_trips_on_submit(self) -> None:
|
||||
"""A base URL typed alongside the key is persisted as the pair."""
|
||||
app = _AuthHostApp()
|
||||
@@ -593,6 +649,34 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
base_url_field = app.screen.query_one("#auth-prompt-base-url", Input)
|
||||
assert base_url_field.value == "https://stored.example/v1"
|
||||
|
||||
async def test_langsmith_prompt_saves_key_and_project(self) -> None:
|
||||
"""The LangSmith prompt persists a key plus its custom project name."""
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
|
||||
await pilot.pause()
|
||||
await pilot.press("f2")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#auth-prompt-input", Input).value = "lsv2_test"
|
||||
project_field = app.screen.query_one("#auth-prompt-project", Input)
|
||||
project_field.value = "my-app"
|
||||
project_field.focus()
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
assert auth_store.get_stored_key("langsmith") == "lsv2_test"
|
||||
assert auth_store.get_stored_project("langsmith") == "my-app"
|
||||
|
||||
async def test_langsmith_prompt_has_no_base_url_field(self) -> None:
|
||||
"""The LangSmith prompt swaps the base-URL field for the project field."""
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
|
||||
await pilot.pause()
|
||||
assert not app.screen.query("#auth-prompt-base-url")
|
||||
assert app.screen.query("#auth-prompt-project")
|
||||
|
||||
async def test_empty_submit_shows_error_and_does_not_dismiss(self) -> None:
|
||||
"""Empty input renders an inline error instead of dismissing."""
|
||||
app = _AuthHostApp()
|
||||
@@ -1258,8 +1342,15 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
ids = {
|
||||
options.get_option_at_index(i).id for i in range(options.option_count)
|
||||
}
|
||||
# Non-model services (e.g. Tavily) are always listed for key entry.
|
||||
assert ids == {"openai", "openai_codex", "anthropic", "tavily"}
|
||||
# Non-model services (Tavily search, LangSmith tracing) are always
|
||||
# listed for key entry.
|
||||
assert ids == {
|
||||
"openai",
|
||||
"openai_codex",
|
||||
"anthropic",
|
||||
"tavily",
|
||||
"langsmith",
|
||||
}
|
||||
|
||||
async def test_selecting_service_opens_prompt_for_its_env_var(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -21,6 +21,7 @@ from deepagents_code.config import (
|
||||
ModelResult,
|
||||
Settings,
|
||||
_apply_default_langsmith_project,
|
||||
_apply_stored_langsmith_tracing,
|
||||
_create_model_from_class,
|
||||
_create_model_via_init,
|
||||
_disable_orphaned_tracing,
|
||||
@@ -29,6 +30,7 @@ from deepagents_code.config import (
|
||||
_read_config_toml_retries,
|
||||
_resolve_retry_kwargs,
|
||||
_resolve_retry_param_name,
|
||||
apply_stored_langsmith_auth,
|
||||
build_langsmith_thread_url,
|
||||
consume_orphaned_tracing_disabled_notice,
|
||||
create_model,
|
||||
@@ -129,11 +131,11 @@ class TestRuntimeDotenvReload:
|
||||
tmp_path / "missing-global.env",
|
||||
)
|
||||
config_mod._dotenv_loaded_values.clear()
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
|
||||
try:
|
||||
# User never set LANGSMITH_PROJECT; tracing is active with a key.
|
||||
config_mod._original_langsmith_project = None
|
||||
config_mod._bootstrap_state.original_langsmith_project = None
|
||||
monkeypatch.setenv("LANGSMITH_TRACING", "true")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
|
||||
@@ -148,7 +150,7 @@ class TestRuntimeDotenvReload:
|
||||
|
||||
assert os.environ["LANGSMITH_PROJECT"] == LANGSMITH_PROJECT_DEFAULT
|
||||
finally:
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
config_mod._dotenv_loaded_values.clear()
|
||||
|
||||
|
||||
@@ -2110,6 +2112,194 @@ class TestDisableOrphanedTracing:
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
|
||||
|
||||
class TestApplyStoredLangSmithTracing:
|
||||
"""Tests for _apply_stored_langsmith_tracing()."""
|
||||
|
||||
@pytest.fixture
|
||||
def fake_state_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect the credential store into a temp directory."""
|
||||
state = tmp_path / ".state"
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_STATE_DIR", state)
|
||||
return state
|
||||
|
||||
def test_noop_without_stored_key(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No stored key leaves the environment untouched (no auto-enable)."""
|
||||
import os
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert "LANGSMITH_TRACING" not in os.environ
|
||||
|
||||
def test_enables_tracing_when_key_stored(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored LangSmith key turns tracing on by default."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
for var in ("LANGSMITH_TRACING", "LANGCHAIN_TRACING_V2"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
|
||||
def test_respects_explicit_opt_out(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An explicit falsy tracing flag is honored as a temporary opt-out."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("LANGSMITH_TRACING", "false")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
|
||||
def test_scoped_opt_out_disables_sibling_tracing_flags(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The documented scoped opt-out wins over other truthy tracing flags."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "false")
|
||||
monkeypatch.setenv("LANGSMITH_TRACING", "false")
|
||||
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "true")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
assert os.environ["LANGCHAIN_TRACING_V2"] == "false"
|
||||
|
||||
def test_leaves_explicit_enable_untouched(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An already-truthy tracing flag is left as-is."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "true")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGCHAIN_TRACING_V2"] == "true"
|
||||
assert "LANGSMITH_TRACING" not in os.environ
|
||||
|
||||
def test_applies_stored_custom_project(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored custom project is applied when none is already set."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
for var in ("LANGSMITH_TRACING", "LANGSMITH_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test", project="my-app")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGSMITH_PROJECT"] == "my-app"
|
||||
|
||||
def test_stored_project_does_not_override_env(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An explicit LANGSMITH_PROJECT wins over the stored project."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.setenv("LANGSMITH_PROJECT", "from-env")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test", project="my-app")
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert os.environ["LANGSMITH_PROJECT"] == "from-env"
|
||||
|
||||
def test_replace_project_applies_stored_project_over_existing_env(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Immediate `/auth` save applies the latest stored project."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.setenv("LANGSMITH_PROJECT", "old-project")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test", project="my-app")
|
||||
_apply_stored_langsmith_tracing(replace_project=True)
|
||||
assert os.environ["LANGSMITH_PROJECT"] == "my-app"
|
||||
|
||||
def test_replace_project_clears_existing_env_when_project_removed(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Immediate `/auth` save clears the old project when the field is blank."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.setenv("LANGSMITH_PROJECT", "old-project")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
_apply_stored_langsmith_tracing(replace_project=True)
|
||||
assert "LANGSMITH_PROJECT" not in os.environ
|
||||
|
||||
def test_immediate_auth_clear_restores_default_project(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Clearing `/auth` project updates the active traced session."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
monkeypatch.setenv("LANGSMITH_PROJECT", "old-project")
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
apply_stored_langsmith_auth(replace_project=True)
|
||||
assert os.environ["LANGSMITH_PROJECT"] == LANGSMITH_PROJECT_DEFAULT
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
|
||||
def test_corrupt_store_warns_and_leaves_env_untouched(
|
||||
self,
|
||||
fake_state_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A corrupt credential file is logged and tracing is left untouched."""
|
||||
import os
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
fake_state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(fake_state_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
with caplog.at_level("WARNING", logger="deepagents_code.config"):
|
||||
_apply_stored_langsmith_tracing()
|
||||
assert "LANGSMITH_TRACING" not in os.environ
|
||||
assert any("may be corrupt" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
class TestGetTracingStatus:
|
||||
"""Tests for get_tracing_status()."""
|
||||
|
||||
@@ -3929,8 +4119,8 @@ class TestLazyModuleAttributes:
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
# Reset flag so bootstrap will re-enter
|
||||
original = config_mod._bootstrap_done
|
||||
config_mod._bootstrap_done = False
|
||||
original = config_mod._bootstrap_state.done
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
with patch(
|
||||
@@ -3939,9 +4129,9 @@ class TestLazyModuleAttributes:
|
||||
_ensure_bootstrap() # should warn, not raise
|
||||
|
||||
# Flag must be set even after failure
|
||||
assert config_mod._bootstrap_done is True
|
||||
assert config_mod._bootstrap_state.done is True
|
||||
finally:
|
||||
config_mod._bootstrap_done = original
|
||||
config_mod._bootstrap_state.done = original
|
||||
|
||||
def test_get_settings_returns_same_instance(self) -> None:
|
||||
"""_get_settings caches in globals — two calls return the same object."""
|
||||
@@ -3958,9 +4148,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_PROJECT", "my-agent-project")
|
||||
@@ -3975,13 +4165,13 @@ class TestLazyModuleAttributes:
|
||||
):
|
||||
_ensure_bootstrap()
|
||||
|
||||
assert config_mod._original_langsmith_project is None
|
||||
assert config_mod._bootstrap_state.original_langsmith_project is None
|
||||
import os
|
||||
|
||||
assert os.environ["LANGSMITH_PROJECT"] == "my-agent-project"
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_ensure_bootstrap_preserves_original_langsmith(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -3990,9 +4180,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("LANGSMITH_PROJECT", "user-project")
|
||||
@@ -4007,13 +4197,15 @@ class TestLazyModuleAttributes:
|
||||
):
|
||||
_ensure_bootstrap()
|
||||
|
||||
assert config_mod._original_langsmith_project == "user-project"
|
||||
assert (
|
||||
config_mod._bootstrap_state.original_langsmith_project == "user-project"
|
||||
)
|
||||
import os
|
||||
|
||||
assert os.environ["LANGSMITH_PROJECT"] == "agent-project"
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_propagates_prefixed_langsmith_vars(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -4022,9 +4214,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
|
||||
@@ -4047,8 +4239,8 @@ class TestLazyModuleAttributes:
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_test"
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_overrides_canonical_with_prefixed_value(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -4057,9 +4249,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_original")
|
||||
@@ -4080,8 +4272,8 @@ class TestLazyModuleAttributes:
|
||||
# Prefixed value wins — canonical is overwritten.
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_override"
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_propagates_empty_string(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -4090,9 +4282,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "")
|
||||
@@ -4113,8 +4305,8 @@ class TestLazyModuleAttributes:
|
||||
# Empty string propagated — lets user explicitly disable tracing.
|
||||
assert os.environ["LANGSMITH_TRACING"] == ""
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_defaults_project_when_tracing_and_key(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -4131,9 +4323,9 @@ class TestLazyModuleAttributes:
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("LANGSMITH_TRACING", "true")
|
||||
@@ -4152,8 +4344,8 @@ class TestLazyModuleAttributes:
|
||||
|
||||
assert os.environ["LANGSMITH_PROJECT"] == LANGSMITH_PROJECT_DEFAULT
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_keyless_tracing_leaves_project_unset(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -4171,9 +4363,9 @@ class TestLazyModuleAttributes:
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
original_done = config_mod._bootstrap_done
|
||||
original_ls = config_mod._original_langsmith_project
|
||||
config_mod._bootstrap_done = False
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("LANGSMITH_TRACING", "true")
|
||||
@@ -4204,8 +4396,166 @@ class TestLazyModuleAttributes:
|
||||
assert "LANGSMITH_PROJECT" not in os.environ
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
finally:
|
||||
config_mod._bootstrap_done = original_done
|
||||
config_mod._original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
|
||||
def test_bootstrap_stored_langsmith_key_keeps_tracing_enabled(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A `/auth`-stored LangSmith key survives `_disable_orphaned_tracing`.
|
||||
|
||||
End-to-end regression guard for the bootstrap ordering: a key stored via
|
||||
`/auth` (never exported to the env) must be bridged onto
|
||||
`LANGSMITH_API_KEY` and auto-enable tracing *before*
|
||||
`_disable_orphaned_tracing` runs, so the orphan guard sees the key and
|
||||
leaves tracing on. The helper-level `_apply_stored_langsmith_tracing`
|
||||
tests cannot catch a regression that reorders the two bootstrap steps.
|
||||
"""
|
||||
import os
|
||||
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
|
||||
)
|
||||
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
for var in (
|
||||
"LANGSMITH_TRACING",
|
||||
"LANGCHAIN_TRACING_V2",
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGCHAIN_API_KEY",
|
||||
"LANGSMITH_PROJECT",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_TRACING",
|
||||
"DEEPAGENTS_CODE_LANGSMITH_PROJECT",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_stored")
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._load_dotenv"),
|
||||
patch(
|
||||
"deepagents_code.project_utils.get_server_project_context",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.config._has_langsmith_profile_credentials",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.config._has_langsmith_profile_custom_endpoint",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
_ensure_bootstrap()
|
||||
|
||||
# The stored key was bridged onto the canonical env var, and tracing
|
||||
# stayed on instead of being disabled as orphaned.
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_stored"
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
finally:
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.original_tracing_env = original_tracing
|
||||
|
||||
def test_bootstrap_prefixed_langsmith_key_wins_over_stored_key(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A session-scoped LangSmith key remains authoritative at bootstrap."""
|
||||
import os
|
||||
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.config import _ensure_bootstrap
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
|
||||
)
|
||||
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_prefixed")
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
|
||||
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_LANGSMITH_PROJECT", raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_stored")
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._load_dotenv"),
|
||||
patch(
|
||||
"deepagents_code.project_utils.get_server_project_context",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
_ensure_bootstrap()
|
||||
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_prefixed"
|
||||
assert os.environ["LANGSMITH_TRACING"] == "true"
|
||||
finally:
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.original_tracing_env = original_tracing
|
||||
|
||||
def test_scoped_tracing_opt_out_restores_user_tracing_for_shell_env(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Deep Agents Code opt-out does not leak into child command envs."""
|
||||
import os
|
||||
|
||||
import deepagents_code.config as config_mod
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.config import _ensure_bootstrap, restore_user_tracing_env
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
|
||||
)
|
||||
|
||||
original_done = config_mod._bootstrap_state.done
|
||||
original_ls = config_mod._bootstrap_state.original_langsmith_project
|
||||
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
|
||||
config_mod._bootstrap_state.done = False
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "false")
|
||||
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "true")
|
||||
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
|
||||
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_LANGSMITH_PROJECT", raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_stored")
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._load_dotenv"),
|
||||
patch(
|
||||
"deepagents_code.project_utils.get_server_project_context",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
_ensure_bootstrap()
|
||||
|
||||
assert os.environ["LANGSMITH_TRACING"] == "false"
|
||||
assert os.environ["LANGCHAIN_TRACING_V2"] == "false"
|
||||
|
||||
shell_env = os.environ.copy()
|
||||
restore_user_tracing_env(shell_env)
|
||||
|
||||
assert "LANGSMITH_TRACING" not in shell_env
|
||||
assert shell_env["LANGCHAIN_TRACING_V2"] == "true"
|
||||
finally:
|
||||
config_mod._bootstrap_state.done = original_done
|
||||
config_mod._bootstrap_state.original_langsmith_project = original_ls
|
||||
config_mod._bootstrap_state.original_tracing_env = original_tracing
|
||||
|
||||
|
||||
class TestApplyDefaultLangsmithProject:
|
||||
|
||||
@@ -432,8 +432,34 @@ class TestServiceCredentials:
|
||||
from deepagents_code.model_config import is_service
|
||||
|
||||
assert is_service("tavily") is True
|
||||
assert is_service("langsmith") is True
|
||||
assert is_service("anthropic") is False
|
||||
|
||||
def test_langsmith_service_env_var(self) -> None:
|
||||
"""LangSmith is registered as a service mapped to its API-key env var."""
|
||||
from deepagents_code.model_config import (
|
||||
LANGSMITH_SERVICE,
|
||||
SERVICE_API_KEY_ENV,
|
||||
)
|
||||
|
||||
assert SERVICE_API_KEY_ENV[LANGSMITH_SERVICE] == "LANGSMITH_API_KEY"
|
||||
|
||||
def test_apply_exports_stored_langsmith_key(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored LangSmith key is copied onto LANGSMITH_API_KEY."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_service_credentials
|
||||
|
||||
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
||||
auth_store.set_stored_key("langsmith", "lsv2_test")
|
||||
apply_stored_service_credentials()
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_test"
|
||||
|
||||
def test_status_missing_when_unset(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
@@ -520,6 +546,23 @@ class TestServiceCredentials:
|
||||
apply_stored_service_credentials()
|
||||
assert os.environ["TAVILY_API_KEY"] == "from-store"
|
||||
|
||||
def test_apply_stored_key_respects_prefixed_override(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A scoped service key is not overwritten by a stored key."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_service_credentials
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_prefixed")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_prefixed")
|
||||
auth_store.set_stored_key("langsmith", "lsv2_stored")
|
||||
apply_stored_service_credentials()
|
||||
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_prefixed"
|
||||
|
||||
|
||||
class TestSplitCredentialSource:
|
||||
"""`warn_on_split_credential_source` flags key/endpoint env-tier mismatches."""
|
||||
|
||||
@@ -58,11 +58,14 @@ def _run_cli_main(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
name for name in sys.modules if name.startswith(prefixes)
|
||||
)
|
||||
config_module = sys.modules.get("deepagents_code.config")
|
||||
bootstrap_done = (
|
||||
getattr(config_module, "_bootstrap_done", None)
|
||||
bootstrap_state = (
|
||||
getattr(config_module, "_bootstrap_state", None)
|
||||
if config_module is not None
|
||||
else None
|
||||
)
|
||||
bootstrap_done = (
|
||||
bootstrap_state.done if bootstrap_state is not None else None
|
||||
)
|
||||
print("LOADED_MODULES=" + json.dumps(loaded), file=sys.stderr)
|
||||
print("BOOTSTRAP_DONE=" + json.dumps(bootstrap_done), file=sys.stderr)
|
||||
"""
|
||||
|
||||
@@ -95,13 +95,13 @@ def _make_banner(
|
||||
# Temporarily clear the cached settings singleton so _get_settings()
|
||||
# re-creates it from the patched env vars inside the context manager.
|
||||
saved = _cfg.__dict__.pop("settings", None)
|
||||
saved_bootstrap = _cfg._bootstrap_done
|
||||
_cfg._bootstrap_done = False
|
||||
saved_bootstrap = _cfg._bootstrap_state.done
|
||||
_cfg._bootstrap_state.done = False
|
||||
try:
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
return WelcomeBanner(thread_id=thread_id)
|
||||
finally:
|
||||
_cfg._bootstrap_done = saved_bootstrap
|
||||
_cfg._bootstrap_state.done = saved_bootstrap
|
||||
if saved is not None:
|
||||
_cfg.__dict__["settings"] = saved
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user