mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): honor Baseten base URL env precedence (#4328)
Baseten endpoint configuration now follows `langchain-baseten`: `BASETEN_BASE_URL` is the primary base URL env var and `BASETEN_API_BASE` remains as the legacy fallback. Stored `/auth` endpoints now pair Baseten keys with the canonical endpoint env var while clearing stale legacy values. ## Changes - Register Baseten in `PROVIDER_BASE_URL_ENV` with `BASETEN_BASE_URL` before `BASETEN_API_BASE`. - Update `ModelConfig.get_base_url` to check all built-in base URL env vars in provider precedence order instead of only the canonical name. - Preserve `config.toml` `base_url_env` override behavior for custom providers. - Add regression coverage for Baseten env precedence, legacy fallback, `/auth` endpoint application, and local test env isolation.
This commit is contained in:
@@ -636,6 +636,8 @@ PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = {
|
||||
# SDK reads ANTHROPIC_BASE_URL.
|
||||
# azure_openai AzureChatOpenAI and the openai SDK both read
|
||||
# AZURE_OPENAI_ENDPOINT.
|
||||
# baseten ChatBaseten reads BASETEN_BASE_URL, then falls back to
|
||||
# BASETEN_API_BASE.
|
||||
# cohere langchain_cohere passes base_url=None, so the cohere SDK's
|
||||
# CO_API_URL is what takes effect.
|
||||
# deepseek ChatDeepSeek reads DEEPSEEK_API_BASE (alias base_url).
|
||||
@@ -662,16 +664,18 @@ PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = {
|
||||
# sit on the openai SDK, whose only base-URL env var is the shared
|
||||
# OPENAI_BASE_URL. That name is intentionally NOT listed under those
|
||||
# providers: writing or clearing it under another provider's name would
|
||||
# clobber the user's real OpenAI endpoint. In practice the integration always
|
||||
# passes base_url explicitly, so the shared fallback never fires.
|
||||
# clobber the user's real OpenAI endpoint. Each is listed above under its own
|
||||
# dedicated name(s) instead. In practice the integration always passes
|
||||
# base_url explicitly, so the shared fallback never fires.
|
||||
#
|
||||
# Omitted (no dedicated, provider-specific endpoint env var): baseten
|
||||
# (hardcoded default + base_url arg), litellm (api_base arg, per-provider
|
||||
# env), google_vertexai (endpoint derived from the region). A `/auth`
|
||||
# endpoint for these still resolves through the stored-credential step of
|
||||
# `get_base_url` and reaches the model as the `base_url` kwarg.
|
||||
# Omitted (no dedicated, provider-specific endpoint env var): litellm
|
||||
# (api_base arg, per-provider env), google_vertexai (endpoint derived from the
|
||||
# region). A `/auth` endpoint for these still resolves through the
|
||||
# stored-credential step of `get_base_url` and reaches the model as the
|
||||
# `base_url` kwarg.
|
||||
"anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_URL"),
|
||||
"azure_openai": ("AZURE_OPENAI_ENDPOINT",),
|
||||
"baseten": ("BASETEN_BASE_URL", "BASETEN_API_BASE"),
|
||||
"cohere": ("CO_API_URL",),
|
||||
"deepseek": ("DEEPSEEK_API_BASE",),
|
||||
"fireworks": ("FIREWORKS_BASE_URL", "FIREWORKS_API_BASE"),
|
||||
@@ -689,13 +693,14 @@ PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = {
|
||||
}
|
||||
"""Every base-URL env var a provider's SDK may read.
|
||||
|
||||
Element `[0]` is the *canonical* name — the one we write a stored endpoint to,
|
||||
and the one `get_base_url` reads through `resolve_env_var` so `base_url` gets the
|
||||
same `DEEPAGENTS_CODE_*` > plain-var precedence as API keys. The remaining names
|
||||
are alternates the SDK might also honor; `apply_stored_credentials` clears them
|
||||
when applying or resetting an endpoint, so a stale value (e.g. an inherited
|
||||
gateway URL) can't leak through. Clearing every name is what lets the write path
|
||||
treat the canonical as authoritative regardless of which name the SDK prefers.
|
||||
Element `[0]` is the *canonical* name — the one we write a stored endpoint to.
|
||||
`get_base_url` reads each name in tuple order through `resolve_env_var`, so every
|
||||
base URL gets the same `DEEPAGENTS_CODE_*` > plain-var precedence as API keys.
|
||||
The remaining names are alternates the SDK might also honor;
|
||||
`apply_stored_credentials` clears them when applying or resetting an endpoint, so
|
||||
a stale value (e.g. an inherited gateway URL) can't leak through. Clearing every
|
||||
name is what lets the write path treat the canonical as authoritative regardless
|
||||
of which name the SDK prefers.
|
||||
|
||||
The key and its endpoint are a coherent pair: a gateway key only works against
|
||||
the gateway URL, a provider-native key only against the provider's own endpoint,
|
||||
@@ -1968,6 +1973,26 @@ def get_credential_env_var(provider: str) -> str | None:
|
||||
return PROVIDER_API_KEY_ENV.get(provider)
|
||||
|
||||
|
||||
def get_base_url_env_vars(provider: str) -> tuple[str, ...]:
|
||||
"""Return base-URL env var names for a provider in resolution order.
|
||||
|
||||
Checks the config file's `base_url_env` first (user override), then falls
|
||||
back to the hardcoded `PROVIDER_BASE_URL_ENV` map.
|
||||
|
||||
Args:
|
||||
provider: Provider name.
|
||||
|
||||
Returns:
|
||||
Environment variable names, or an empty tuple if the provider has no
|
||||
base-URL env var (config-declared or built-in).
|
||||
"""
|
||||
config = ModelConfig.load()
|
||||
config_env = config.get_base_url_env(provider)
|
||||
if config_env:
|
||||
return (config_env,)
|
||||
return PROVIDER_BASE_URL_ENV.get(provider, ())
|
||||
|
||||
|
||||
def get_base_url_env_var(provider: str) -> str | None:
|
||||
"""Return the canonical base-URL env var name for a provider.
|
||||
|
||||
@@ -1982,11 +2007,8 @@ def get_base_url_env_var(provider: str) -> str | None:
|
||||
Environment variable name, or None if the provider has no base-URL env
|
||||
var (config-declared or built-in).
|
||||
"""
|
||||
config = ModelConfig.load()
|
||||
config_env = config.get_base_url_env(provider)
|
||||
if config_env:
|
||||
return config_env
|
||||
return _canonical_base_url_env(provider)
|
||||
env_vars = get_base_url_env_vars(provider)
|
||||
return env_vars[0] if env_vars else None
|
||||
|
||||
|
||||
def get_default_base_url_env(provider: str) -> str | None:
|
||||
@@ -2010,11 +2032,11 @@ def get_default_base_url_env(provider: str) -> str | None:
|
||||
The `DEEPAGENTS_CODE_`-prefixed env var name still in effect after a
|
||||
blank save, or `None`.
|
||||
"""
|
||||
env_var = get_base_url_env_var(provider)
|
||||
if not env_var:
|
||||
return None
|
||||
prefixed = f"{_ENV_PREFIX}{env_var}"
|
||||
return prefixed if os.environ.get(prefixed) else None
|
||||
for env_var in get_base_url_env_vars(provider):
|
||||
prefixed = f"{_ENV_PREFIX}{env_var}"
|
||||
if os.environ.get(prefixed):
|
||||
return prefixed
|
||||
return None
|
||||
|
||||
|
||||
def is_service(name: str) -> bool:
|
||||
@@ -2468,16 +2490,17 @@ class ModelConfig:
|
||||
Resolution order (first match wins):
|
||||
|
||||
1. `base_url` in the provider's `config.toml` section.
|
||||
2. The provider's base-URL env var via `resolve_env_var`, so
|
||||
`DEEPAGENTS_CODE_{VAR}` beats the plain `{VAR}` — mirroring how API
|
||||
keys resolve. This also surfaces the value `apply_stored_credentials`
|
||||
bridged in from a `/auth` credential, and the gateway-provisioned
|
||||
URL in the default (no-override) case.
|
||||
2. The provider's base-URL env vars via `resolve_env_var`, in provider
|
||||
precedence order, so `DEEPAGENTS_CODE_{VAR}` beats the plain `{VAR}`
|
||||
for each name — mirroring how API keys resolve. This also surfaces
|
||||
the value `apply_stored_credentials` bridged in from a `/auth`
|
||||
credential, and the gateway-provisioned URL in the default
|
||||
(no-override) case.
|
||||
3. The endpoint stored with a `/auth` credential. This is the source
|
||||
for providers that have no base-URL env var (e.g. an OpenAI-
|
||||
compatible provider like OpenRouter, Groq, or Baseten): step 2 has
|
||||
no name to read, so the stored endpoint is taken directly. It then
|
||||
reaches the model as the `base_url` constructor kwarg via
|
||||
compatible provider like Litellm): step 2 has no name to read, so
|
||||
the stored endpoint is taken directly. It then reaches the model as
|
||||
the `base_url` constructor kwarg via
|
||||
`_get_provider_kwargs`, the same path a `config.toml` literal uses.
|
||||
For providers that *do* have an env var, the stored endpoint already
|
||||
arrives via step 2 (it was bridged onto the env var), so this step
|
||||
@@ -2504,12 +2527,16 @@ class ModelConfig:
|
||||
config_url = provider.get("base_url") if provider else None
|
||||
if config_url:
|
||||
return config_url
|
||||
env_var = (provider.get("base_url_env") if provider else None) or (
|
||||
_canonical_base_url_env(provider_name)
|
||||
config_env = provider.get("base_url_env") if provider else None
|
||||
env_vars = (
|
||||
(config_env,)
|
||||
if config_env
|
||||
else PROVIDER_BASE_URL_ENV.get(provider_name, ())
|
||||
)
|
||||
resolved = resolve_env_var(env_var) if env_var else None
|
||||
if resolved:
|
||||
return resolved
|
||||
for env_var in env_vars:
|
||||
resolved = resolve_env_var(env_var)
|
||||
if resolved:
|
||||
return resolved
|
||||
try:
|
||||
return auth_store.get_stored_base_url(provider_name)
|
||||
except RuntimeError:
|
||||
|
||||
@@ -58,6 +58,7 @@ from deepagents_code.model_config import (
|
||||
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,
|
||||
@@ -875,22 +876,25 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
Content describing blank behavior and env-var precedence.
|
||||
"""
|
||||
surviving_base_url_env = get_default_base_url_env(self._provider)
|
||||
endpoint_env = get_base_url_env_var(self._provider)
|
||||
if surviving_base_url_env and endpoint_env:
|
||||
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]$prefixed[/bold], then [bold]$plain[/bold].",
|
||||
"Env override order: [bold]$order[/bold].",
|
||||
prefixed=surviving_base_url_env,
|
||||
plain=endpoint_env,
|
||||
order=env_order,
|
||||
)
|
||||
if endpoint_env:
|
||||
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]$prefixed[/bold], then [bold]$plain[/bold].",
|
||||
prefixed=f"DEEPAGENTS_CODE_{endpoint_env}",
|
||||
plain=endpoint_env,
|
||||
"Env override order: [bold]$order[/bold].",
|
||||
order=env_order,
|
||||
)
|
||||
return Content.from_markup(
|
||||
"Override the provider endpoint for this stored key. "
|
||||
|
||||
@@ -131,9 +131,13 @@ def _clear_provider_base_url_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_API_URL",
|
||||
"BASETEN_BASE_URL",
|
||||
"BASETEN_API_BASE",
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"DEEPAGENTS_CODE_OPENAI_BASE_URL",
|
||||
"DEEPAGENTS_CODE_ANTHROPIC_BASE_URL",
|
||||
"DEEPAGENTS_CODE_BASETEN_BASE_URL",
|
||||
"DEEPAGENTS_CODE_BASETEN_API_BASE",
|
||||
"DEEPAGENTS_CODE_GOOGLE_GEMINI_BASE_URL",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
@@ -1040,6 +1040,26 @@ api_key_url = "javascript:alert(1)"
|
||||
# The URL value itself is not leaked into the hint.
|
||||
assert "scoped.example" not in text
|
||||
|
||||
async def test_base_url_hint_names_surviving_alternate_var(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The blank-endpoint hint includes alternate env vars that still resolve."""
|
||||
monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", tmp_path / "none.toml")
|
||||
monkeypatch.setenv(
|
||||
"DEEPAGENTS_CODE_BASETEN_API_BASE", "https://legacy.example/v1"
|
||||
)
|
||||
model_config.clear_caches()
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_prompt("baseten", "BASETEN_API_KEY")
|
||||
await pilot.pause()
|
||||
hint = app.screen.query_one("#auth-prompt-base-url-hint", Static)
|
||||
text = str(hint.content)
|
||||
assert "Leave blank to use DEEPAGENTS_CODE_BASETEN_API_BASE" in text
|
||||
assert "DEEPAGENTS_CODE_BASETEN_BASE_URL, then BASETEN_BASE_URL" in text
|
||||
assert "DEEPAGENTS_CODE_BASETEN_API_BASE, then BASETEN_API_BASE" in text
|
||||
assert "legacy.example" not in text
|
||||
|
||||
async def test_no_logging_of_secret(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Submitting a key never lands its value in widget logs."""
|
||||
secret = "sk-do-not-log-zzz"
|
||||
|
||||
@@ -15,6 +15,7 @@ from deepagents_code.model_config import (
|
||||
IMPLICIT_AUTH_PROVIDERS,
|
||||
NO_AUTH_REQUIRED_PROVIDERS,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
PROVIDER_BASE_URL_ENV,
|
||||
RETRY_PARAM_BY_PROVIDER,
|
||||
THREAD_COLUMN_DEFAULTS,
|
||||
ModelConfig,
|
||||
@@ -319,6 +320,27 @@ class TestStoredCredentials:
|
||||
# The alternate name the SDK also reads must not retain a stale value.
|
||||
assert "OPENAI_API_BASE" not in os.environ
|
||||
|
||||
def test_apply_stored_credentials_sets_baseten_base_url(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored Baseten endpoint writes `BASETEN_BASE_URL` and clears legacy."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_credentials
|
||||
|
||||
monkeypatch.delenv("BASETEN_API_KEY", raising=False)
|
||||
monkeypatch.setenv("BASETEN_API_BASE", "https://stale.example/v1")
|
||||
auth_store.set_stored_key(
|
||||
"baseten", "from-store", base_url="https://mine.example/v1"
|
||||
)
|
||||
|
||||
assert apply_stored_credentials("baseten") is True
|
||||
assert os.environ["BASETEN_BASE_URL"] == "https://mine.example/v1"
|
||||
assert "BASETEN_API_BASE" not in os.environ
|
||||
|
||||
def test_apply_stored_credentials_blank_base_url_clears_gateway(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
@@ -1284,6 +1306,17 @@ class TestProviderApiKeyEnv:
|
||||
assert PROVIDER_API_KEY_ENV["xai"] == "XAI_API_KEY"
|
||||
|
||||
|
||||
class TestProviderBaseUrlEnv:
|
||||
"""Tests for PROVIDER_BASE_URL_ENV constant."""
|
||||
|
||||
def test_baseten_matches_langchain_baseten_precedence(self) -> None:
|
||||
"""Baseten reads the new env var before the legacy fallback."""
|
||||
assert PROVIDER_BASE_URL_ENV["baseten"] == (
|
||||
"BASETEN_BASE_URL",
|
||||
"BASETEN_API_BASE",
|
||||
)
|
||||
|
||||
|
||||
class TestModelConfigLoad:
|
||||
"""Tests for ModelConfig.load() method."""
|
||||
|
||||
@@ -1587,6 +1620,21 @@ models = ["llama3"]
|
||||
|
||||
assert config.get_base_url("openai") == "https://gw.example/openai/v1"
|
||||
|
||||
def test_baseten_base_url_precedes_legacy_api_base(self, monkeypatch):
|
||||
"""Baseten follows `langchain-baseten` endpoint env precedence."""
|
||||
monkeypatch.setenv("BASETEN_BASE_URL", "https://new.example/v1")
|
||||
monkeypatch.setenv("BASETEN_API_BASE", "https://legacy.example/v1")
|
||||
config = ModelConfig()
|
||||
|
||||
assert config.get_base_url("baseten") == "https://new.example/v1"
|
||||
|
||||
def test_baseten_falls_back_to_legacy_api_base(self, monkeypatch):
|
||||
"""Baseten still honors the legacy endpoint env var."""
|
||||
monkeypatch.setenv("BASETEN_API_BASE", "https://legacy.example/v1")
|
||||
config = ModelConfig()
|
||||
|
||||
assert config.get_base_url("baseten") == "https://legacy.example/v1"
|
||||
|
||||
def test_env_prefix_overrides_plain(self, monkeypatch):
|
||||
"""`DEEPAGENTS_CODE_*` beats the plain env var, like API keys."""
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://plain.example/v1")
|
||||
@@ -1629,17 +1677,17 @@ models = ["m1"]
|
||||
) -> None:
|
||||
"""A `/auth` endpoint resolves for a provider with no base-URL env var.
|
||||
|
||||
Some OpenAI-compatible providers (e.g. Baseten) have an API-key env var
|
||||
but no dedicated base-URL env var, so steps 1-2 find nothing. The
|
||||
stored endpoint must still resolve here so it reaches the model as the
|
||||
`base_url` kwarg — otherwise a value saved in `/auth` is silently lost.
|
||||
Some providers have an API-key env var but no dedicated base-URL env var,
|
||||
so steps 1-2 find nothing. The stored endpoint must still resolve here so
|
||||
it reaches the model as the `base_url` kwarg — otherwise a value saved in
|
||||
`/auth` is silently lost.
|
||||
"""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("baseten", "k", base_url="https://proxy.example/v1")
|
||||
auth_store.set_stored_key("litellm", "k", base_url="https://proxy.example/v1")
|
||||
config = ModelConfig()
|
||||
|
||||
assert config.get_base_url("baseten") == "https://proxy.example/v1"
|
||||
assert config.get_base_url("litellm") == "https://proxy.example/v1"
|
||||
|
||||
def test_config_literal_wins_over_stored_base_url(
|
||||
self,
|
||||
@@ -1704,6 +1752,31 @@ class TestGetDefaultBaseUrlEnv:
|
||||
== "DEEPAGENTS_CODE_OPENAI_BASE_URL"
|
||||
)
|
||||
|
||||
def test_returns_prefixed_alternate_when_set(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A prefixed alternate is named when it supplies the blank fallback."""
|
||||
monkeypatch.setenv(
|
||||
"DEEPAGENTS_CODE_BASETEN_API_BASE", "https://legacy.example/v1"
|
||||
)
|
||||
assert (
|
||||
model_config.get_default_base_url_env("baseten")
|
||||
== "DEEPAGENTS_CODE_BASETEN_API_BASE"
|
||||
)
|
||||
|
||||
def test_canonical_prefixed_name_precedes_alternate(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The helper matches `get_base_url` provider env precedence."""
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_BASETEN_BASE_URL", "https://new.example/v1")
|
||||
monkeypatch.setenv(
|
||||
"DEEPAGENTS_CODE_BASETEN_API_BASE", "https://legacy.example/v1"
|
||||
)
|
||||
assert (
|
||||
model_config.get_default_base_url_env("baseten")
|
||||
== "DEEPAGENTS_CODE_BASETEN_BASE_URL"
|
||||
)
|
||||
|
||||
def test_ignores_plain_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A plain endpoint var is cleared on a blank save, so it is not named."""
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://gateway.example/v1")
|
||||
|
||||
Reference in New Issue
Block a user