Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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)
23 changes: 23 additions & 0 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 @@ -1000,6 +1001,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 +1240,14 @@ 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:
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 +1256,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 +1273,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
23 changes: 23 additions & 0 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 @@ -802,6 +803,16 @@ def model(self) -> str:
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,19 +913,31 @@ 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 = (
httpx.Client(auth=LangSmithGatewayOAuth(token.get_secret_value()))
if (token := self.langsmith_gateway_oauth_token) is not None
else None
Comment thread
open-swe[bot] marked this conversation as resolved.
Outdated
)
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 = (
httpx.AsyncClient(auth=LangSmithGatewayOAuth(token.get_secret_value()))
if (token := self.langsmith_gateway_oauth_token) is not None
else None
)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import httpx
import openai
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth
from pydantic import SecretStr

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -499,11 +500,19 @@ def _get_default_httpx_client(
base_url: str | None,
timeout: Any,
socket_options: tuple[SocketOption, ...] = (),
auth: LangSmithGatewayOAuth | None = None,
) -> _SyncHttpxClientWrapper:
"""Get default httpx client.

Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
"""
if auth is not None:
return _SyncHttpxClientWrapper(
base_url=base_url,
timeout=timeout,
transport=httpx.HTTPTransport(socket_options=socket_options),
auth=auth,
Comment thread
open-swe[bot] marked this conversation as resolved.
Outdated
)
try:
hash(timeout)
except TypeError:
Expand All @@ -516,11 +525,19 @@ def _get_default_async_httpx_client(
base_url: str | None,
timeout: Any,
socket_options: tuple[SocketOption, ...] = (),
auth: LangSmithGatewayOAuth | None = None,
) -> _AsyncHttpxClientWrapper:
"""Get default httpx client.

Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
"""
if auth is not None:
return _AsyncHttpxClientWrapper(
base_url=base_url,
timeout=timeout,
transport=httpx.AsyncHTTPTransport(socket_options=socket_options),
auth=auth,
)
try:
hash(timeout)
except TypeError:
Expand Down
28 changes: 24 additions & 4 deletions libs/partners/openai/langchain_openai/chat_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
convert_to_openai_function,
convert_to_openai_tool,
)
from langchain_core.utils.langsmith_gateway import LangSmithGatewayOAuth
from langchain_core.utils.pydantic import (
PydanticBaseModel,
TypeBaseModel,
Expand Down Expand Up @@ -809,6 +810,16 @@ async def get_api_key() -> str:

default_query: Mapping[str, object] | None = None

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 OpenAI as an API key.
"""

# Configure a custom httpx client. See the
# [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: Any | None = Field(default=None, exclude=True)
Expand Down Expand Up @@ -1233,6 +1244,13 @@ def validate_environment(self) -> Self:
}
if self.max_retries is not None:
client_params["max_retries"] = self.max_retries
gateway_oauth_auth = (
LangSmithGatewayOAuth(
self.langsmith_gateway_oauth_token.get_secret_value()
)
if self.langsmith_gateway_oauth_token is not None
else None
)

if self.openai_proxy and (self.http_client or self.http_async_client):
openai_proxy = self.openai_proxy
Expand Down Expand Up @@ -1280,6 +1298,7 @@ def validate_environment(self) -> Self:
self.openai_api_base,
self.request_timeout,
resolved_socket_options,
gateway_oauth_auth,
),
"api_key": sync_api_key_value,
}
Expand All @@ -1294,10 +1313,11 @@ def validate_environment(self) -> Self:
)
async_specific = {
"http_client": self.http_async_client
or _get_default_async_httpx_client(
self.openai_api_base,
self.request_timeout,
resolved_socket_options,
or _get_default_async_httpx_client(
self.openai_api_base,
self.request_timeout,
resolved_socket_options,
gateway_oauth_auth,
),
"api_key": async_api_key_value,
}
Expand Down
Loading