mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
feat(openrouter): add langchain-openrouter provider package (#35211)
Add a first-party `langchain-openrouter` partner package (`ChatOpenRouter`) that wraps the official `openrouter` Python SDK, providing native support for OpenRouter-specific features that `ChatOpenAI` intentionally does not handle. Also adds scope-clarifying docstrings to `ChatOpenAI` / `BaseChatOpenAI` warning users away from using `base_url` overrides with third-party providers. --- Closes #31325 Closes #32967 Closes #32977 Closes #32981 Closes #33643 Closes #33757 Closes #34056 Closes #34797 Closes #34962 Supersedes #33902, #34867 (thank you @elonfeng and @okamototk for your initial work on this!) --- Bugs with upstream sdk: - https://github.com/OpenRouterTeam/python-sdk/issues/38 - https://github.com/OpenRouterTeam/python-sdk/issues/51 - https://github.com/OpenRouterTeam/python-sdk/issues/52
This commit is contained in:
1
.github/workflows/pr_lint.yml
vendored
1
.github/workflows/pr_lint.yml
vendored
@@ -102,6 +102,7 @@ jobs:
|
||||
nomic
|
||||
ollama
|
||||
openai
|
||||
openrouter
|
||||
perplexity
|
||||
qdrant
|
||||
xai
|
||||
|
||||
@@ -273,6 +273,11 @@ SERIALIZABLE_MAPPING: dict[tuple[str, ...], tuple[str, ...]] = {
|
||||
"chat_models",
|
||||
"ChatGroq",
|
||||
),
|
||||
("langchain_openrouter", "chat_models", "ChatOpenRouter"): (
|
||||
"langchain_openrouter",
|
||||
"chat_models",
|
||||
"ChatOpenRouter",
|
||||
),
|
||||
("langchain_xai", "chat_models", "ChatXAI"): (
|
||||
"langchain_xai",
|
||||
"chat_models",
|
||||
|
||||
@@ -66,6 +66,7 @@ _BUILTIN_PROVIDERS: dict[str, tuple[str, str, Callable[..., BaseChatModel]]] = {
|
||||
"nvidia": ("langchain_nvidia_ai_endpoints", "ChatNVIDIA", _call),
|
||||
"ollama": ("langchain_ollama", "ChatOllama", _call),
|
||||
"openai": ("langchain_openai", "ChatOpenAI", _call),
|
||||
"openrouter": ("langchain_openrouter", "ChatOpenRouter", _call),
|
||||
"perplexity": ("langchain_perplexity", "ChatPerplexity", _call),
|
||||
"together": ("langchain_together", "ChatTogether", _call),
|
||||
"upstage": ("langchain_upstage", "ChatUpstage", _call),
|
||||
@@ -277,6 +278,7 @@ def init_chat_model(
|
||||
- `ibm` -> [`langchain-ibm`](https://docs.langchain.com/oss/python/integrations/providers/ibm)
|
||||
- `nvidia` -> [`langchain-nvidia-ai-endpoints`](https://docs.langchain.com/oss/python/integrations/providers/nvidia)
|
||||
- `xai` -> [`langchain-xai`](https://docs.langchain.com/oss/python/integrations/providers/xai)
|
||||
- `openrouter` -> [`langchain-openrouter`](https://docs.langchain.com/oss/python/integrations/providers/openrouter)
|
||||
- `perplexity` -> [`langchain-perplexity`](https://docs.langchain.com/oss/python/integrations/providers/perplexity)
|
||||
- `upstage` -> [`langchain-upstage`](https://docs.langchain.com/oss/python/integrations/providers/upstage)
|
||||
|
||||
|
||||
4
libs/model-profiles/uv.lock
generated
4
libs/model-profiles/uv.lock
generated
@@ -527,7 +527,7 @@ typing = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.11"
|
||||
version = "1.2.12"
|
||||
source = { editable = "../core" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -695,7 +695,7 @@ test-integration = [
|
||||
{ name = "httpx", specifier = ">=0.27.0,<1.0.0" },
|
||||
{ name = "numpy", marker = "python_full_version < '3.13'", specifier = ">=1.26.4" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13'", specifier = ">=2.1.0" },
|
||||
{ name = "pillow", specifier = ">=10.3.0,<12.0.0" },
|
||||
{ name = "pillow", specifier = ">=10.3.0,<13.0.0" },
|
||||
]
|
||||
typing = [
|
||||
{ name = "langchain-core", editable = "../core" },
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
"""OpenAI chat wrapper."""
|
||||
"""OpenAI chat wrapper.
|
||||
|
||||
!!! warning "API scope"
|
||||
|
||||
`ChatOpenAI` targets
|
||||
[official OpenAI API specifications](https://github.com/openai/openai-openapi)
|
||||
only. Non-standard response fields added by third-party providers (e.g.,
|
||||
`reasoning_content`, `reasoning_details`) are **not** extracted or
|
||||
preserved. If you are pointing `base_url` at a provider such as
|
||||
OpenRouter, vLLM, or DeepSeek, use the corresponding provider-specific
|
||||
LangChain package instead (e.g., `ChatDeepSeek`, `ChatOpenRouter`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -511,7 +522,14 @@ _DictOrPydantic: TypeAlias = dict | _BM
|
||||
|
||||
|
||||
class BaseChatOpenAI(BaseChatModel):
|
||||
"""Base wrapper around OpenAI large language models for chat."""
|
||||
"""Base wrapper around OpenAI large language models for chat.
|
||||
|
||||
This base class targets
|
||||
[official OpenAI API specifications](https://github.com/openai/openai-openapi)
|
||||
only. Non-standard response fields added by third-party providers (e.g.,
|
||||
`reasoning_content`) are not extracted. Use a provider-specific subclass for
|
||||
full provider support.
|
||||
"""
|
||||
|
||||
client: Any = Field(default=None, exclude=True)
|
||||
|
||||
@@ -2262,6 +2280,16 @@ class BaseChatOpenAI(BaseChatModel):
|
||||
class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
|
||||
r"""Interface to OpenAI chat model APIs.
|
||||
|
||||
!!! warning "API scope"
|
||||
|
||||
`ChatOpenAI` targets
|
||||
[official OpenAI API specifications](https://github.com/openai/openai-openapi)
|
||||
only. Non-standard response fields added by third-party providers (e.g.,
|
||||
`reasoning_content`, `reasoning_details`) are **not** extracted or
|
||||
preserved. If you are pointing `base_url` at a provider such as
|
||||
OpenRouter, vLLM, or DeepSeek, use the corresponding provider-specific
|
||||
LangChain package instead (e.g., `ChatDeepSeek`, `ChatOpenRouter`).
|
||||
|
||||
???+ info "Setup"
|
||||
|
||||
Install `langchain-openai` and set environment variable `OPENAI_API_KEY`.
|
||||
|
||||
1
libs/partners/openrouter/.gitignore
vendored
Normal file
1
libs/partners/openrouter/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__
|
||||
21
libs/partners/openrouter/LICENSE
Normal file
21
libs/partners/openrouter/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
65
libs/partners/openrouter/Makefile
Normal file
65
libs/partners/openrouter/Makefile
Normal file
@@ -0,0 +1,65 @@
|
||||
.PHONY: all format lint type test tests integration_tests help extended_tests
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
.EXPORT_ALL_VARIABLES:
|
||||
UV_FROZEN = true
|
||||
|
||||
# Define a variable for the test file path.
|
||||
TEST_FILE ?= tests/unit_tests/
|
||||
integration_test integration_tests: TEST_FILE = tests/integration_tests/
|
||||
|
||||
|
||||
# unit tests are run with the --disable-socket flag to prevent network calls
|
||||
test tests:
|
||||
uv run --group test pytest --disable-socket --allow-unix-socket $(TEST_FILE)
|
||||
|
||||
test_watch:
|
||||
uv run --group test ptw --snapshot-update --now . -- -vv $(TEST_FILE)
|
||||
|
||||
# integration tests are run without the --disable-socket flag to allow network calls
|
||||
integration_test integration_tests:
|
||||
uv run --group test --group test_integration pytest --timeout=30 $(TEST_FILE)
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
PYTHON_FILES=.
|
||||
MYPY_CACHE=.mypy_cache
|
||||
lint format: PYTHON_FILES=.
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=libs/partners/openrouter --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=langchain_openrouter
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && uv run --all-groups mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run --all-groups mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
check_imports: $(shell find langchain_openrouter -name '*.py')
|
||||
uv run --all-groups python ./scripts/check_imports.py $^
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '----'
|
||||
@echo 'check_imports - check imports'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo 'type - run type checking'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'tests - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
30
libs/partners/openrouter/README.md
Normal file
30
libs/partners/openrouter/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# langchain-openrouter
|
||||
|
||||
[](https://pypi.org/project/langchain-openrouter/#history)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pypistats.org/packages/langchain-openrouter)
|
||||
[](https://x.com/langchain)
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
pip install langchain-openrouter
|
||||
```
|
||||
|
||||
## 🤔 What is this?
|
||||
|
||||
This package contains the LangChain integration with [OpenRouter](https://openrouter.ai/), a unified API for hundreds of AI models across many providers.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
For full documentation, see the [API reference](https://reference.langchain.com/python/integrations/langchain_openrouter/). For conceptual guides, tutorials, and examples on using these classes, see the [LangChain Docs](https://docs.langchain.com/oss/python/integrations/providers/openrouter).
|
||||
|
||||
## 📕 Releases & Versioning
|
||||
|
||||
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
||||
|
||||
## 💁 Contributing
|
||||
|
||||
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
||||
|
||||
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
||||
@@ -0,0 +1,7 @@
|
||||
"""LangChain OpenRouter integration."""
|
||||
|
||||
from langchain_openrouter.chat_models import ChatOpenRouter
|
||||
|
||||
__all__ = [
|
||||
"ChatOpenRouter",
|
||||
]
|
||||
1308
libs/partners/openrouter/langchain_openrouter/chat_models.py
Normal file
1308
libs/partners/openrouter/langchain_openrouter/chat_models.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Model profile data. All edits should be made in profile_augmentations.toml."""
|
||||
2556
libs/partners/openrouter/langchain_openrouter/data/_profiles.py
Normal file
2556
libs/partners/openrouter/langchain_openrouter/data/_profiles.py
Normal file
File diff suppressed because it is too large
Load Diff
108
libs/partners/openrouter/pyproject.toml
Normal file
108
libs/partners/openrouter/pyproject.toml
Normal file
@@ -0,0 +1,108 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langchain-openrouter"
|
||||
description = "An integration package connecting OpenRouter and LangChain"
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
version = "0.0.2"
|
||||
requires-python = ">=3.10.0,<4.0.0"
|
||||
dependencies = [
|
||||
"langchain-core>=1.2.11,<2.0.0",
|
||||
"openrouter>=0.6.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.langchain.com/oss/python/integrations/providers/openrouter"
|
||||
Documentation = "https://reference.langchain.com/python/integrations/langchain_openrouter/"
|
||||
Repository = "https://github.com/langchain-ai/langchain"
|
||||
Issues = "https://github.com/langchain-ai/langchain/issues"
|
||||
Changelog = "https://github.com/langchain-ai/langchain/releases?q=%22langchain-openrouter%22"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
"pytest>=9.0.0,<10.0.0",
|
||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||
"pytest-socket>=0.7.0,<1.0.0",
|
||||
"pytest-watcher>=0.6.3,<1.0.0",
|
||||
"pytest-timeout>=2.4.0,<3.0.0",
|
||||
"langchain-tests",
|
||||
]
|
||||
test_integration = []
|
||||
lint = ["ruff>=0.15.0,<0.16.0"]
|
||||
dev = ["langchain-core"]
|
||||
typing = ["mypy>=1.19.1,<2.0.0"]
|
||||
|
||||
|
||||
[tool.uv.sources]
|
||||
langchain-core = { path = "../../core", editable = true }
|
||||
langchain-tests = { path = "../../standard-tests", editable = true }
|
||||
|
||||
[tool.mypy]
|
||||
disallow_untyped_defs = "True"
|
||||
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # Conflicts with formatter
|
||||
"PLR0913", # Too many arguments
|
||||
|
||||
# TODO
|
||||
"ANN401",
|
||||
"TC002",
|
||||
"TC003",
|
||||
]
|
||||
unfixable = ["B028"] # People should intentionally tune the stacklevel
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
ignore-var-parameters = true # ignore missing documentation for *args and **kwargs parameters
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = ["tests/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5"
|
||||
markers = [
|
||||
"compile: mark placeholder test used to compile integration tests without running them",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint.extend-per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
"S101", # Tests need assertions
|
||||
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
|
||||
"SLF001", # Private member access
|
||||
"PLR2004", # Magic values are fine in tests
|
||||
"D102",
|
||||
|
||||
# TODO
|
||||
"ARG002", # Unused method argument:
|
||||
]
|
||||
"scripts/*.py" = [
|
||||
"INP001", # Not a package
|
||||
]
|
||||
1
libs/partners/openrouter/scripts/__init__.py
Normal file
1
libs/partners/openrouter/scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Scripts for langchain-openrouter."""
|
||||
19
libs/partners/openrouter/scripts/check_imports.py
Normal file
19
libs/partners/openrouter/scripts/check_imports.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Script to check imports of given Python files."""
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from importlib.machinery import SourceFileLoader
|
||||
|
||||
if __name__ == "__main__":
|
||||
files = sys.argv[1:]
|
||||
has_failure = False
|
||||
for file in files:
|
||||
try:
|
||||
SourceFileLoader("x", file).load_module()
|
||||
except Exception: # noqa: PERF203, BLE001
|
||||
has_failure = True
|
||||
print(file) # noqa: T201
|
||||
traceback.print_exc()
|
||||
print() # noqa: T201
|
||||
|
||||
sys.exit(1 if has_failure else 0)
|
||||
1
libs/partners/openrouter/tests/__init__.py
Normal file
1
libs/partners/openrouter/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for langchain-openrouter."""
|
||||
39
libs/partners/openrouter/tests/conftest.py
Normal file
39
libs/partners/openrouter/tests/conftest.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Conftest for OpenRouter tests."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_tests.conftest import CustomPersister, CustomSerializer, base_vcr_config
|
||||
from vcr import VCR # type: ignore[import-untyped]
|
||||
|
||||
|
||||
def remove_request_headers(request: Any) -> Any:
|
||||
"""Redact all request headers to avoid leaking secrets."""
|
||||
for k in request.headers:
|
||||
request.headers[k] = "**REDACTED**"
|
||||
return request
|
||||
|
||||
|
||||
def remove_response_headers(response: dict) -> dict:
|
||||
"""Redact all response headers."""
|
||||
for k in response["headers"]:
|
||||
response["headers"][k] = "**REDACTED**"
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def vcr_config() -> dict:
|
||||
"""Extend the default configuration coming from langchain_tests."""
|
||||
config = base_vcr_config()
|
||||
config["before_record_request"] = remove_request_headers
|
||||
config["before_record_response"] = remove_response_headers
|
||||
config["serializer"] = "yaml.gz"
|
||||
config["path_transformer"] = VCR.ensure_suffix(".yaml.gz")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def pytest_recording_configure(config: dict, vcr: VCR) -> None: # noqa: ARG001
|
||||
"""Register custom VCR persister and serializer."""
|
||||
vcr.register_persister(CustomPersister())
|
||||
vcr.register_serializer("yaml.gz", CustomSerializer())
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for langchain-openrouter."""
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Integration tests for `ChatOpenRouter` chat model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessageChunk
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain_openrouter.chat_models import ChatOpenRouter
|
||||
|
||||
|
||||
def test_basic_invoke() -> None:
|
||||
"""Test basic invocation."""
|
||||
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
|
||||
response = model.invoke("Say 'hello' and nothing else.")
|
||||
assert response.content
|
||||
assert response.response_metadata.get("model_provider") == "openrouter"
|
||||
|
||||
|
||||
def test_streaming() -> None:
|
||||
"""Test streaming."""
|
||||
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
|
||||
full: BaseMessageChunk | None = None
|
||||
for chunk in model.stream("Say 'hello' and nothing else."):
|
||||
full = chunk if full is None else full + chunk
|
||||
assert isinstance(full, AIMessageChunk)
|
||||
assert full.content
|
||||
|
||||
|
||||
def test_tool_calling() -> None:
|
||||
"""Test tool calling via OpenRouter."""
|
||||
|
||||
class GetWeather(BaseModel):
|
||||
"""Get the current weather in a given location."""
|
||||
|
||||
location: str = Field(description="The city and state")
|
||||
|
||||
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
|
||||
model_with_tools = model.bind_tools([GetWeather])
|
||||
response = model_with_tools.invoke("What's the weather in San Francisco?")
|
||||
assert response.tool_calls
|
||||
|
||||
|
||||
def test_structured_output() -> None:
|
||||
"""Test structured output via OpenRouter."""
|
||||
|
||||
class Joke(BaseModel):
|
||||
"""A joke."""
|
||||
|
||||
setup: str = Field(description="The setup of the joke")
|
||||
punchline: str = Field(description="The punchline of the joke")
|
||||
|
||||
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
|
||||
structured = model.with_structured_output(Joke)
|
||||
result = structured.invoke("Tell me a joke about programming")
|
||||
assert isinstance(result, Joke)
|
||||
assert result.setup
|
||||
assert result.punchline
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Depends on reasoning model availability on OpenRouter.")
|
||||
def test_reasoning_content() -> None:
|
||||
"""Test reasoning content from a reasoning model."""
|
||||
model = ChatOpenRouter(
|
||||
model="openai/o3-mini",
|
||||
reasoning={"effort": "low"},
|
||||
)
|
||||
response = model.invoke("What is 2 + 2?")
|
||||
assert response.content
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Test compilation of integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.compile
|
||||
def test_placeholder() -> None:
|
||||
"""Used for compiling integration tests without running any real tests."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Standard integration tests for `ChatOpenRouter`."""
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_tests.integration_tests import ChatModelIntegrationTests
|
||||
|
||||
from langchain_openrouter.chat_models import ChatOpenRouter
|
||||
|
||||
MODEL_NAME = "openai/gpt-4o-mini"
|
||||
|
||||
|
||||
class TestChatOpenRouter(ChatModelIntegrationTests):
|
||||
"""Test `ChatOpenRouter` chat model."""
|
||||
|
||||
@property
|
||||
def chat_model_class(self) -> type[ChatOpenRouter]:
|
||||
"""Return class of chat model being tested."""
|
||||
return ChatOpenRouter
|
||||
|
||||
@property
|
||||
def chat_model_params(self) -> dict:
|
||||
"""Parameters to create chat model instance for testing."""
|
||||
return {
|
||||
"model": MODEL_NAME,
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
@property
|
||||
def returns_usage_metadata(self) -> bool:
|
||||
# Don't want to implement tests for now
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_json_mode(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_image_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_image_urls(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_video_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def model_override_value(self) -> str:
|
||||
return "openai/gpt-4o"
|
||||
|
||||
|
||||
AUDIO_MODEL = "google/gemini-2.5-flash"
|
||||
REASONING_MODEL = "openai/o3-mini"
|
||||
|
||||
|
||||
class TestChatOpenRouterMultiModal(ChatModelIntegrationTests):
|
||||
"""Tests for audio input and reasoning output capabilities.
|
||||
|
||||
Uses an audio-capable model as the base and creates separate model
|
||||
instances for reasoning tests.
|
||||
"""
|
||||
|
||||
@property
|
||||
def chat_model_class(self) -> type[ChatOpenRouter]:
|
||||
return ChatOpenRouter
|
||||
|
||||
@property
|
||||
def chat_model_params(self) -> dict:
|
||||
return {
|
||||
"model": AUDIO_MODEL,
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
@property
|
||||
def returns_usage_metadata(self) -> bool:
|
||||
# Don't want to implement tests for now
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_json_mode(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_image_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_image_urls(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_audio_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_video_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def model_override_value(self) -> str:
|
||||
return "openai/gpt-4o"
|
||||
|
||||
def invoke_with_reasoning_output(self, *, stream: bool = False) -> AIMessage:
|
||||
"""Invoke a reasoning model to exercise reasoning token tracking."""
|
||||
llm = ChatOpenRouter(
|
||||
model=REASONING_MODEL,
|
||||
reasoning={"effort": "medium"},
|
||||
)
|
||||
prompt = (
|
||||
"Explain the relationship between the 2008/9 economic crisis and "
|
||||
"the startup ecosystem in the early 2010s"
|
||||
)
|
||||
if stream:
|
||||
full: AIMessageChunk | None = None
|
||||
for chunk in llm.stream(prompt):
|
||||
full = chunk if full is None else full + chunk # type: ignore[assignment]
|
||||
assert full is not None
|
||||
return full
|
||||
return llm.invoke(prompt)
|
||||
1
libs/partners/openrouter/tests/unit_tests/__init__.py
Normal file
1
libs/partners/openrouter/tests/unit_tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for langchain-openrouter."""
|
||||
@@ -0,0 +1,30 @@
|
||||
# serializer version: 1
|
||||
# name: TestChatOpenRouterUnit.test_serdes[serialized]
|
||||
dict({
|
||||
'id': list([
|
||||
'langchain_openrouter',
|
||||
'chat_models',
|
||||
'ChatOpenRouter',
|
||||
]),
|
||||
'kwargs': dict({
|
||||
'max_retries': 2,
|
||||
'max_tokens': 100,
|
||||
'model_name': 'openai/gpt-4o-mini',
|
||||
'n': 1,
|
||||
'openrouter_api_key': dict({
|
||||
'id': list([
|
||||
'OPENROUTER_API_KEY',
|
||||
]),
|
||||
'lc': 1,
|
||||
'type': 'secret',
|
||||
}),
|
||||
'request_timeout': 60,
|
||||
'stop': list([
|
||||
]),
|
||||
'temperature': 0.0,
|
||||
}),
|
||||
'lc': 1,
|
||||
'name': 'ChatOpenRouter',
|
||||
'type': 'constructor',
|
||||
})
|
||||
# ---
|
||||
2667
libs/partners/openrouter/tests/unit_tests/test_chat_models.py
Normal file
2667
libs/partners/openrouter/tests/unit_tests/test_chat_models.py
Normal file
File diff suppressed because it is too large
Load Diff
12
libs/partners/openrouter/tests/unit_tests/test_imports.py
Normal file
12
libs/partners/openrouter/tests/unit_tests/test_imports.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Test `langchain_openrouter` public API surface."""
|
||||
|
||||
from langchain_openrouter import __all__
|
||||
|
||||
EXPECTED_ALL = [
|
||||
"ChatOpenRouter",
|
||||
]
|
||||
|
||||
|
||||
def test_all_imports() -> None:
|
||||
"""Verify that __all__ exports match the expected public API."""
|
||||
assert sorted(EXPECTED_ALL) == sorted(__all__)
|
||||
63
libs/partners/openrouter/tests/unit_tests/test_standard.py
Normal file
63
libs/partners/openrouter/tests/unit_tests/test_standard.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Standard unit tests for `ChatOpenRouter`."""
|
||||
|
||||
from langchain_tests.unit_tests import ChatModelUnitTests
|
||||
|
||||
from langchain_openrouter.chat_models import ChatOpenRouter
|
||||
|
||||
MODEL_NAME = "openai/gpt-4o-mini"
|
||||
|
||||
|
||||
class TestChatOpenRouterUnit(ChatModelUnitTests):
|
||||
"""Standard unit tests for `ChatOpenRouter` chat model."""
|
||||
|
||||
@property
|
||||
def chat_model_class(self) -> type[ChatOpenRouter]:
|
||||
"""Chat model class being tested."""
|
||||
return ChatOpenRouter
|
||||
|
||||
@property
|
||||
def init_from_env_params(self) -> tuple[dict, dict, dict]:
|
||||
"""Parameters to initialize from environment variables."""
|
||||
return (
|
||||
{
|
||||
"OPENROUTER_API_KEY": "api_key",
|
||||
},
|
||||
{
|
||||
"model": MODEL_NAME,
|
||||
},
|
||||
{
|
||||
"openrouter_api_key": "api_key",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def chat_model_params(self) -> dict:
|
||||
"""Parameters to create chat model instance for testing."""
|
||||
return {
|
||||
"model": MODEL_NAME,
|
||||
"api_key": "test-api-key",
|
||||
}
|
||||
|
||||
@property
|
||||
def supports_image_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_image_urls(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_audio_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_video_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_pdf_inputs(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def model_override_value(self) -> str:
|
||||
return "openai/gpt-4o"
|
||||
1790
libs/partners/openrouter/uv.lock
generated
Normal file
1790
libs/partners/openrouter/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user