Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions libs/partners/anthropic/langchain_anthropic/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import datetime
import hashlib
import json
import os
import re
import warnings
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
Expand Down Expand Up @@ -76,6 +77,18 @@

_MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)

_LANGSMITH_GATEWAY_DEFAULT_URL = "https://gateway.smith.langchain.com/anthropic"


def _resolve_gateway_base_url() -> str | None:
raw = os.getenv("LANGSMITH_GATEWAY")
if raw is None or raw.lower() in ("false", "0", "no"):
return None
if raw.lower() in ("true", "1", "yes"):
return _LANGSMITH_GATEWAY_DEFAULT_URL
return raw


_USER_AGENT: Final[str] = f"langchain-anthropic/{__version__}"


Expand Down Expand Up @@ -945,22 +958,35 @@ class ChatAnthropic(BaseChatModel):

anthropic_api_url: str | None = Field(
alias="base_url",
default_factory=from_env(
default_factory=lambda: _resolve_gateway_base_url()
or from_env(
["ANTHROPIC_API_URL", "ANTHROPIC_BASE_URL"],
default="https://api.anthropic.com",
),
)(),
)
"""Base URL for API requests. Only specify if using a proxy or service emulator.

If a value isn't passed in, will attempt to read the value first from
`ANTHROPIC_API_URL` and if that is not set, `ANTHROPIC_BASE_URL`.

If `LANGSMITH_GATEWAY` is set, it takes precedence over both env vars.
"""

anthropic_api_key: SecretStr = Field(
alias="api_key",
default_factory=secret_from_env("ANTHROPIC_API_KEY", default=""),
default_factory=lambda: SecretStr(
(
os.getenv("LANGSMITH_GATEWAY_API_KEY")
if _resolve_gateway_base_url() is not None
else None
)
or secret_from_env("ANTHROPIC_API_KEY", default="")().get_secret_value()
),
)
"""Automatically read from env var `ANTHROPIC_API_KEY` if not provided."""
"""Automatically read from env var `ANTHROPIC_API_KEY` if not provided.

If `LANGSMITH_GATEWAY` is enabled, `LANGSMITH_GATEWAY_API_KEY` takes precedence.
"""

anthropic_proxy: str | None = Field(
default_factory=from_env("ANTHROPIC_PROXY", default=None)
Expand Down
42 changes: 42 additions & 0 deletions libs/partners/anthropic/tests/unit_tests/test_chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3574,3 +3574,45 @@ def mock_create(_payload: Any) -> list:
message_finish = cast("dict[str, Any]", stream_events[-1])
assert message_finish["event"] == "message-finish"
assert message_finish["metadata"]["stop_reason"] == "tool_use"


def test_langsmith_gateway_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.delenv("ANTHROPIC_API_URL", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
llm = ChatAnthropic(model=MODEL_NAME, api_key="test")
assert llm.anthropic_api_url == "https://gateway.smith.langchain.com/anthropic"


def test_langsmith_gateway_false(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "false")
monkeypatch.delenv("ANTHROPIC_API_URL", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
llm = ChatAnthropic(model=MODEL_NAME, api_key="test")
assert llm.anthropic_api_url == "https://api.anthropic.com"


def test_langsmith_gateway_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_URL", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
llm = ChatAnthropic(model=MODEL_NAME, api_key="test")
assert llm.anthropic_api_url == "https://api.anthropic.com"


def test_langsmith_gateway_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
llm = ChatAnthropic(model=MODEL_NAME)
assert llm.anthropic_api_key.get_secret_value() == "gateway-key"


def test_langsmith_gateway_api_key_not_used_without_gateway(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.setenv("ANTHROPIC_API_KEY", "provider-key")
llm = ChatAnthropic(model=MODEL_NAME)
assert llm.anthropic_api_key.get_secret_value() == "provider-key"
41 changes: 33 additions & 8 deletions libs/partners/fireworks/langchain_fireworks/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import contextlib
import json
import logging
import os
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
from operator import itemgetter
from typing import (
Expand Down Expand Up @@ -108,6 +109,17 @@

_MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES)

_LANGSMITH_GATEWAY_DEFAULT_URL = "https://gateway.smith.langchain.com/fireworks"


def _resolve_gateway_base_url() -> str | None:
raw = os.getenv("LANGSMITH_GATEWAY")
if raw is None or raw.lower() in ("false", "0", "no"):
return None
if raw.lower() in ("true", "1", "yes"):
return _LANGSMITH_GATEWAY_DEFAULT_URL
return raw


def _get_default_model_profile(model_name: str) -> ModelProfile:
default = _MODEL_PROFILES.get(model_name) or {}
Expand Down Expand Up @@ -756,25 +768,38 @@ def model(self) -> str:

fireworks_api_key: SecretStr = Field(
alias="api_key",
default_factory=secret_from_env(
"FIREWORKS_API_KEY",
error_message=(
"You must specify an api key. "
"You can pass it an argument as `api_key=...` or "
"set the environment variable `FIREWORKS_API_KEY`."
),
default_factory=lambda: SecretStr(
(
os.getenv("LANGSMITH_GATEWAY_API_KEY")
if _resolve_gateway_base_url() is not None
else None
)
or secret_from_env(
"FIREWORKS_API_KEY",
error_message=(
"You must specify an api key. "
"You can pass it an argument as `api_key=...` or "
"set the environment variable `FIREWORKS_API_KEY`."
),
)().get_secret_value()
),
)
"""Fireworks API key.

Automatically read from env variable `FIREWORKS_API_KEY` if not provided.

If `LANGSMITH_GATEWAY` is enabled, `LANGSMITH_GATEWAY_API_KEY` takes precedence.
"""

fireworks_api_base: str | None = Field(
alias="base_url", default_factory=from_env("FIREWORKS_API_BASE", default=None)
alias="base_url",
default_factory=lambda: _resolve_gateway_base_url()
or from_env("FIREWORKS_API_BASE", default=None)(),
)
"""Base URL path for API requests, leave blank if not using a proxy or service
emulator.

If `LANGSMITH_GATEWAY` is set, it takes precedence over `FIREWORKS_API_BASE`.
"""

request_timeout: float | tuple[float, float] | Any | None = Field(
Expand Down
38 changes: 38 additions & 0 deletions libs/partners/fireworks/tests/unit_tests/test_chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,3 +1591,41 @@ def test_request_timeout_tuple_normalized_to_httpx_timeout(
assert forwarded.connect == 5.0
assert forwarded.read == 30.0
assert async_mock.call_args.kwargs["timeout"] == forwarded


def test_langsmith_gateway_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
llm = _make_model()
assert llm.fireworks_api_base == "https://gateway.smith.langchain.com/fireworks"


def test_langsmith_gateway_false(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "false")
monkeypatch.delenv("FIREWORKS_API_BASE", raising=False)
llm = _make_model()
assert llm.fireworks_api_base is None


def test_langsmith_gateway_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
monkeypatch.delenv("FIREWORKS_API_BASE", raising=False)
llm = _make_model()
assert llm.fireworks_api_base is None


def test_langsmith_gateway_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.delenv("FIREWORKS_API_KEY", raising=False)
llm = ChatFireworks(model=MODEL_NAME) # type: ignore[call-arg]
assert llm.fireworks_api_key.get_secret_value() == "gateway-key"


def test_langsmith_gateway_api_key_not_used_without_gateway(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.setenv("FIREWORKS_API_KEY", "provider-key")
llm = ChatFireworks(model=MODEL_NAME) # type: ignore[call-arg]
assert llm.fireworks_api_key.get_secret_value() == "provider-key"
61 changes: 42 additions & 19 deletions libs/partners/openai/langchain_openai/chat_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,17 @@ def _get_ssrf_safe_client() -> httpx.Client:

_MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)

_LANGSMITH_GATEWAY_DEFAULT_URL = "https://gateway.smith.langchain.com/openai/v1"


def _resolve_gateway_base_url() -> str | None:
raw = os.getenv("LANGSMITH_GATEWAY")
if raw is None or raw.lower() in ("false", "0", "no"):
return None
if raw.lower() in ("true", "1", "yes"):
return _LANGSMITH_GATEWAY_DEFAULT_URL
return raw


def _get_default_model_profile(model_name: str) -> ModelProfile:
default = _MODEL_PROFILES.get(model_name) or {}
Expand Down Expand Up @@ -1167,33 +1178,45 @@ def validate_environment(self) -> Self:
or os.getenv("OPENAI_ORG_ID")
or os.getenv("OPENAI_ORGANIZATION")
)
self.openai_api_base = self.openai_api_base or os.getenv("OPENAI_API_BASE")

# Enable stream_usage by default if using default base URL and client
if (
all(
getattr(self, key, None) is None
for key in (
"stream_usage",
"openai_proxy",
"openai_api_base",
"base_url",
"client",
"root_client",
"async_client",
"root_async_client",
"http_client",
"http_async_client",
)
_gateway_base_url = _resolve_gateway_base_url()
_base_url_from_gateway = False
if self.openai_api_base is None:
if _gateway_base_url is not None:
self.openai_api_base = _gateway_base_url
_base_url_from_gateway = True
else:
self.openai_api_base = os.getenv("OPENAI_API_BASE")

# Enable stream_usage by default if using default base URL and client,
# or when the base URL was set by the LangSmith gateway (which proxies
# to OpenAI and supports streaming token usage).
if all(
getattr(self, key, None) is None
for key in (
"stream_usage",
"openai_proxy",
"client",
"root_client",
"async_client",
"root_async_client",
"http_client",
"http_async_client",
)
and "OPENAI_BASE_URL" not in os.environ
) and (
_base_url_from_gateway
or (self.openai_api_base is None and "OPENAI_BASE_URL" not in os.environ)
):
self.stream_usage = True

# Resolve API key from SecretStr or Callable
sync_api_key_value: str | Callable[[], str] | None = None
async_api_key_value: str | Callable[[], Awaitable[str]] | None = None

if self.openai_api_key is None and _base_url_from_gateway:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Provider env key bypasses gateway key

openai_api_key has already been populated from OPENAI_API_KEY by its field default factory before this validator runs, so this condition is false whenever both provider and gateway credentials are present. With LANGSMITH_GATEWAY=true, LANGSMITH_GATEWAY_API_KEY=gateway-key, and OPENAI_API_KEY=provider-key, the base URL is switched to the LangSmith gateway but the OpenAI provider key is sent to it, causing authentication to fail. The new positive test hides this by deleting OPENAI_API_KEY; gateway-key precedence needs to happen before the provider env fallback while still preserving an explicitly passed api_key.

(Refers to line 1215)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

gateway_api_key = os.getenv("LANGSMITH_GATEWAY_API_KEY")
if gateway_api_key is not None:
self.openai_api_key = SecretStr(gateway_api_key)

if self.openai_api_key is not None:
# Because OpenAI and AsyncOpenAI clients support either sync or async
# callables for the API key, we need to resolve separate values here.
Expand Down
39 changes: 39 additions & 0 deletions libs/partners/openai/tests/unit_tests/chat_models/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4501,3 +4501,42 @@ def test_defer_loading_in_responses_api_payload() -> None:
assert weather_tool["defer_loading"] is True
assert weather_tool["type"] == "function"
assert {"type": "tool_search"} in result["tools"]


def test_langsmith_gateway_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
llm = ChatOpenAI(model=OPENAI_TEST_MODEL, api_key=SecretStr("test"))
assert llm.openai_api_base == "https://gateway.smith.langchain.com/openai/v1"
assert llm.stream_usage is True


def test_langsmith_gateway_false(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "false")
llm = ChatOpenAI(model=OPENAI_TEST_MODEL, api_key=SecretStr("test"))
assert llm.openai_api_base is None


def test_langsmith_gateway_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
llm = ChatOpenAI(model=OPENAI_TEST_MODEL, api_key=SecretStr("test"))
assert llm.openai_api_base is None


def test_langsmith_gateway_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
llm = ChatOpenAI(model=OPENAI_TEST_MODEL)
assert isinstance(llm.openai_api_key, SecretStr)
assert llm.openai_api_key.get_secret_value() == "gateway-key"


def test_langsmith_gateway_api_key_not_used_without_gateway(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("LANGSMITH_GATEWAY", raising=False)
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "gateway-key")
monkeypatch.setenv("OPENAI_API_KEY", "provider-key")
llm = ChatOpenAI(model=OPENAI_TEST_MODEL)
assert isinstance(llm.openai_api_key, SecretStr)
assert llm.openai_api_key.get_secret_value() == "provider-key"
Loading