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:
Mason Daugherty
2026-06-26 16:17:01 -04:00
committed by GitHub
parent db2fef2953
commit 8f20d74892
5 changed files with 179 additions and 51 deletions
+64 -37
View File
@@ -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:
+12 -8
View File
@@ -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. "
+4
View File
@@ -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")