Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ celerybeat.pid
# Environments
.env
.envrc
*.pem
*.key
*.crt
credentials.json
.venv*
venv*
env/
Expand Down
3 changes: 3 additions & 0 deletions libs/core/langchain_core/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
print_text,
)
from langchain_core.utils.iter import batch_iterate
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth
from langchain_core.utils.pydantic import pre_init
from langchain_core.utils.strings import (
comma_list,
Expand All @@ -42,6 +43,7 @@
)

__all__ = (
"LangSmithGatewayOAuth",
"StrictFormatter",
"abatch_iterate",
"batch_iterate",
Expand Down Expand Up @@ -72,6 +74,7 @@

_dynamic_imports = {
"image": "__module__",
"LangSmithGatewayOAuth": "langsmith_gateway",
"abatch_iterate": "aiter",
"get_from_dict_or_env": "env",
"get_from_env": "env",
Expand Down
44 changes: 44 additions & 0 deletions libs/core/langchain_core/utils/langsmith_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Authentication helpers for LangSmith Gateway clients."""

from __future__ import annotations

from typing import TYPE_CHECKING

import httpx

if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator


class LangSmithGatewayOAuth(httpx.Auth):
"""Apply a LangSmith OAuth token to requests sent through Gateway.

Gateway removes this credential before forwarding the request to the selected
provider. The adapter therefore removes provider credential headers before
setting the Gateway-specific bearer token.

Args:
token: LangSmith OAuth access token.
"""

def __init__(self, token: str) -> None:
"""Initialize the auth adapter with a LangSmith OAuth access token."""
self._token = token

def _authorize(self, request: httpx.Request) -> httpx.Request:
request.headers.pop("Authorization", None)
request.headers.pop("X-Api-Key", None)
request.headers["Authorization"] = f"Bearer {self._token}"
return request

def auth_flow(
self, request: httpx.Request
) -> Generator[httpx.Request, httpx.Response, None]:
"""Add the bearer token to a synchronous request."""
yield self._authorize(request)

async def async_auth_flow(
self, request: httpx.Request
) -> AsyncGenerator[httpx.Request, httpx.Response]:
"""Add the bearer token to an asynchronous request."""
yield self._authorize(request)
21 changes: 21 additions & 0 deletions libs/core/tests/unit_tests/utils/test_langsmith_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import httpx

from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth


def test_gateway_oauth_replaces_provider_auth_headers() -> None:
"""Gateway OAuth must not be forwarded as a provider credential."""
auth = LangSmithGatewayOAuth("oauth-token")
request = httpx.Request(
"POST",
"https://gateway.smith.langchain.com/anthropic/v1/messages",
headers={
"Authorization": "Bearer provider-key",
"X-Api-Key": "provider-key",
},
)

authorized_request = next(auth.auth_flow(request))

assert authorized_request.headers["Authorization"] == "Bearer oauth-token"
assert "X-Api-Key" not in authorized_request.headers
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any

import anthropic
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth

_NOT_GIVEN: Any = object()

Expand Down Expand Up @@ -49,6 +50,7 @@ def _get_default_httpx_client(
base_url: str | None,
timeout: Any = _NOT_GIVEN,
anthropic_proxy: str | None = None,
auth: LangSmithGatewayOAuth | None = None,
) -> _SyncHttpxClientWrapper:
kwargs: dict[str, Any] = {
"base_url": base_url
Expand All @@ -59,6 +61,8 @@ def _get_default_httpx_client(
kwargs["timeout"] = timeout
if anthropic_proxy is not None:
kwargs["proxy"] = anthropic_proxy
if auth is not None:
kwargs["auth"] = auth
return _SyncHttpxClientWrapper(**kwargs)


Expand All @@ -68,6 +72,7 @@ def _get_default_async_httpx_client(
base_url: str | None,
timeout: Any = _NOT_GIVEN,
anthropic_proxy: str | None = None,
auth: LangSmithGatewayOAuth | None = None,
) -> _AsyncHttpxClientWrapper:
kwargs: dict[str, Any] = {
"base_url": base_url
Expand All @@ -78,4 +83,6 @@ def _get_default_async_httpx_client(
kwargs["timeout"] = timeout
if anthropic_proxy is not None:
kwargs["proxy"] = anthropic_proxy
if auth is not None:
kwargs["auth"] = auth
return _AsyncHttpxClientWrapper(**kwargs)
44 changes: 39 additions & 5 deletions libs/partners/anthropic/langchain_anthropic/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
convert_to_json_schema,
convert_to_openai_tool,
)
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth
from langchain_core.utils.pydantic import is_basemodel_subclass
from langchain_core.utils.utils import _build_model_kwargs
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
Expand Down Expand Up @@ -958,11 +959,13 @@ class ChatAnthropic(BaseChatModel):

anthropic_api_url: str | None = Field(
alias="base_url",
default_factory=lambda: _resolve_gateway_base_url()
or from_env(
["ANTHROPIC_API_URL", "ANTHROPIC_BASE_URL"],
default="https://api.anthropic.com",
)(),
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.

Expand All @@ -980,6 +983,12 @@ class ChatAnthropic(BaseChatModel):
if _resolve_gateway_base_url() is not None
else None
)
or (
"langsmith-gateway-oauth"
if _resolve_gateway_base_url() is not None
and os.getenv("LANGSMITH_GATEWAY_OAUTH_TOKEN") is not None
else None
)
or secret_from_env("ANTHROPIC_API_KEY", default="")().get_secret_value()
),
)
Expand All @@ -1000,6 +1009,16 @@ class ChatAnthropic(BaseChatModel):
default_headers: Mapping[str, str] | None = None
"""Headers to pass to the Anthropic clients, will be used for every API call."""

langsmith_gateway_oauth_token: SecretStr | None = Field(
default_factory=secret_from_env("LANGSMITH_GATEWAY_OAUTH_TOKEN", default=None),
exclude=True,
)
Comment on lines +1012 to +1015

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.

🔴 Scope OAuth token to gateway requests

The environment token is loaded unconditionally, even when LANGSMITH_GATEWAY is unset/false and this client resolves to https://api.anthropic.com. In that normal direct-provider configuration, _gateway_oauth_auth replaces the provider credential with the LangSmith bearer token, disclosing the OAuth credential to Anthropic and causing authentication to fail. The same unconditional default exists in ChatFireworks and BaseChatOpenAI; Fireworks sends it to its direct endpoint, while OpenAI sends it whenever a direct base_url is supplied (and otherwise now crashes constructing an HTTPX client with base_url=None). Please only activate this credential when the resolved endpoint is actually LangSmith Gateway.

(Refers to lines 1004-1007)


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

"""OAuth token used to authenticate this client to LangSmith Gateway.

When set, the token is sent as an `Authorization` bearer credential and is
never passed to Anthropic as an API key.
"""

betas: list[str] | None = None
"""List of beta features to enable. If specified, invocations will be routed
through `client.beta.messages.create`.
Expand Down Expand Up @@ -1229,6 +1248,17 @@ def _client_params(self) -> dict[str, Any]:

return client_params

@property
def _gateway_oauth_auth(self) -> LangSmithGatewayOAuth | None:
if (
self.langsmith_gateway_oauth_token is None
or self.anthropic_api_url != _resolve_gateway_base_url()
):
return None
return LangSmithGatewayOAuth(
self.langsmith_gateway_oauth_token.get_secret_value()
)

@cached_property
def _client(self) -> anthropic.Client:
client_params = self._client_params
Expand All @@ -1237,6 +1267,8 @@ def _client(self) -> anthropic.Client:
http_client_params["timeout"] = client_params["timeout"]
if self.anthropic_proxy:
http_client_params["anthropic_proxy"] = self.anthropic_proxy
if (auth := self._gateway_oauth_auth) is not None:
http_client_params["auth"] = auth
http_client = _get_default_httpx_client(**http_client_params)
params = {
**client_params,
Expand All @@ -1252,6 +1284,8 @@ def _async_client(self) -> anthropic.AsyncClient:
http_client_params["timeout"] = client_params["timeout"]
if self.anthropic_proxy:
http_client_params["anthropic_proxy"] = self.anthropic_proxy
if (auth := self._gateway_oauth_auth) is not None:
http_client_params["auth"] = auth
http_client = _get_default_async_httpx_client(**http_client_params)
params = {
**client_params,
Expand Down
29 changes: 29 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 @@ -3616,3 +3616,32 @@ def test_langsmith_gateway_api_key_not_used_without_gateway(
monkeypatch.setenv("ANTHROPIC_API_KEY", "provider-key")
llm = ChatAnthropic(model=MODEL_NAME)
assert llm.anthropic_api_key.get_secret_value() == "provider-key"


def test_langsmith_gateway_oauth_uses_placeholder_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.setenv("LANGSMITH_GATEWAY_OAUTH_TOKEN", "oauth-token")
monkeypatch.delenv("LANGSMITH_GATEWAY_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

llm = ChatAnthropic(model=MODEL_NAME)

assert llm.anthropic_api_key.get_secret_value() == "langsmith-gateway-oauth"
assert llm._gateway_oauth_auth is not None


def test_langsmith_gateway_oauth_is_not_used_for_custom_base_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LANGSMITH_GATEWAY", "true")
monkeypatch.setenv("LANGSMITH_GATEWAY_OAUTH_TOKEN", "oauth-token")

llm = ChatAnthropic(
model=MODEL_NAME,
api_key="provider-key",
base_url="https://api.anthropic.com",
)

assert llm._gateway_oauth_auth is None
57 changes: 55 additions & 2 deletions libs/partners/fireworks/langchain_fireworks/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
convert_to_json_schema,
convert_to_openai_tool,
)
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth
from langchain_core.utils.pydantic import is_basemodel_subclass
from langchain_core.utils.utils import _build_model_kwargs, from_env, secret_from_env
from pydantic import (
Expand Down Expand Up @@ -774,6 +775,12 @@ def model(self) -> str:
if _resolve_gateway_base_url() is not None
else None
)
or (
"langsmith-gateway-oauth"
if _resolve_gateway_base_url() is not None
and os.getenv("LANGSMITH_GATEWAY_OAUTH_TOKEN") is not None
else None
)
or secret_from_env(
"FIREWORKS_API_KEY",
error_message=(
Expand All @@ -793,15 +800,27 @@ def model(self) -> str:

fireworks_api_base: str | None = Field(
alias="base_url",
default_factory=lambda: _resolve_gateway_base_url()
or from_env("FIREWORKS_API_BASE", default=None)(),
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`.
"""

langsmith_gateway_oauth_token: SecretStr | None = Field(
default_factory=secret_from_env("LANGSMITH_GATEWAY_OAUTH_TOKEN", default=None),
exclude=True,
)
"""OAuth token used to authenticate this client to LangSmith Gateway.

When set, the token is sent as an `Authorization` bearer credential and is
never passed to Fireworks as an API key.
"""

request_timeout: float | tuple[float, float] | Any | None = Field(
default=None, alias="timeout"
)
Expand Down Expand Up @@ -902,23 +921,57 @@ def validate_environment(self) -> Self:
# so the LangChain `run_manager` sees each attempt. Suppress the
# SDK's built-in retry layer to avoid double-retrying.
if not self.client:
http_client = self._gateway_oauth_http_client()
self._sdk_client = Fireworks(
api_key=api_key,
base_url=base_url,
timeout=timeout,
max_retries=0,
http_client=http_client,
)
self.client = self._sdk_client.chat.completions
if not self.async_client:
http_async_client = self._gateway_oauth_async_http_client()
self._async_sdk_client = AsyncFireworks(
api_key=api_key,
base_url=base_url,
timeout=timeout,
max_retries=0,
http_client=http_async_client,
)
self.async_client = self._async_sdk_client.chat.completions
return self

def _is_langsmith_gateway(self) -> bool:
"""Return whether this instance is configured to use LangSmith Gateway."""
return self.fireworks_api_base == _resolve_gateway_base_url()

def _gateway_oauth_http_client(self) -> httpx.Client | None:
"""Build a synchronous OAuth client when Gateway OAuth is enabled."""
if (
self.langsmith_gateway_oauth_token is None
or not self._is_langsmith_gateway()
):
return None
return httpx.Client(
auth=LangSmithGatewayOAuth(
self.langsmith_gateway_oauth_token.get_secret_value()
)
)

def _gateway_oauth_async_http_client(self) -> httpx.AsyncClient | None:
"""Build an asynchronous OAuth client when Gateway OAuth is enabled."""
if (
self.langsmith_gateway_oauth_token is None
or not self._is_langsmith_gateway()
):
return None
return httpx.AsyncClient(
auth=LangSmithGatewayOAuth(
self.langsmith_gateway_oauth_token.get_secret_value()
)
)

def close(self) -> None:
"""Close the underlying sync HTTP client.

Expand Down
Loading
Loading