diff --git a/.github/workflows/pr_lint.yml b/.github/workflows/pr_lint.yml index 310474c4174..3dbf96783bd 100644 --- a/.github/workflows/pr_lint.yml +++ b/.github/workflows/pr_lint.yml @@ -31,7 +31,7 @@ # core, langchain, langchain-classic, model-profiles, # standard-tests, text-splitters, docs, anthropic, chroma, deepseek, exa, # fireworks, groq, huggingface, mistralai, nomic, ollama, openai, -# perplexity, qdrant, xai, infra, deps +# perplexity, qdrant, xai, infra, deps, partners # # Multiple scopes can be used by separating them with a comma. For example: # @@ -119,6 +119,7 @@ jobs: xai infra deps + partners requireScope: false disallowScopes: | release diff --git a/libs/core/langchain_core/language_models/chat_models.py b/libs/core/langchain_core/language_models/chat_models.py index 1378a17dbb8..4e4e3ab6d24 100644 --- a/libs/core/langchain_core/language_models/chat_models.py +++ b/libs/core/langchain_core/language_models/chat_models.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import inspect import json from abc import ABC, abstractmethod @@ -11,8 +12,8 @@ from functools import cached_property from operator import itemgetter from typing import TYPE_CHECKING, Any, Literal, cast -from pydantic import BaseModel, ConfigDict, Field -from typing_extensions import override +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self, override from langchain_core.caches import BaseCache from langchain_core.callbacks import ( @@ -32,7 +33,10 @@ from langchain_core.language_models.base import ( LangSmithParams, LanguageModelInput, ) -from langchain_core.language_models.model_profile import ModelProfile +from langchain_core.language_models.model_profile import ( + ModelProfile, + _warn_unknown_profile_keys, +) from langchain_core.load import dumpd, dumps from langchain_core.messages import ( AIMessage, @@ -357,6 +361,54 @@ class BaseChatModel(BaseLanguageModel[AIMessage], ABC): arbitrary_types_allowed=True, ) + def _resolve_model_profile(self) -> ModelProfile | None: + """Return the default model profile, or `None` if unavailable. + + Override this in subclasses instead of `_set_model_profile`. The base + validator calls it automatically and handles assignment. This avoids + coupling partner code to Pydantic validator mechanics. + + Each partner needs its own override because things can vary per-partner, + such as the attribute that identifies the model (e.g., `model`, + `model_name`, `model_id`, `deployment_name`) and the partner-local + `_get_default_model_profile` function that reads from each partner's own + profile data. + """ + # TODO: consider adding a `_model_identifier` property on BaseChatModel + # to standardize how partners identify their model, which could allow a + # default implementation here that calls a shared + # profile-loading mechanism. + return None + + @model_validator(mode="after") + def _set_model_profile(self) -> Self: + """Populate `profile` from `_resolve_model_profile` if not provided. + + Partners should override `_resolve_model_profile` rather than this + validator. Overriding this with a new `@model_validator` replaces the + base validator (Pydantic v2 behavior), bypassing the standard resolution + path. A plain method override does not prevent the base validator from + running. + """ + if self.profile is None: + # Suppress errors from partner overrides (e.g., missing profile + # files, broken imports) so model construction never fails over an + # optional field. + with contextlib.suppress(Exception): + self.profile = self._resolve_model_profile() + return self + + # NOTE: _check_profile_keys must be defined AFTER _set_model_profile. + # Pydantic v2 runs mode="after" validators in definition order. + @model_validator(mode="after") + def _check_profile_keys(self) -> Self: + """Warn on unrecognized profile keys.""" + # isinstance guard: ModelProfile is a TypedDict (always a dict), but + # protects against unexpected types from partner overrides. + if self.profile and isinstance(self.profile, dict): + _warn_unknown_profile_keys(self.profile) + return self + @cached_property def _serialized(self) -> dict[str, Any]: # self is always a Serializable object in this case, thus the result is diff --git a/libs/core/langchain_core/language_models/model_profile.py b/libs/core/langchain_core/language_models/model_profile.py index 8e1c6b4e21a..b556c0a6467 100644 --- a/libs/core/langchain_core/language_models/model_profile.py +++ b/libs/core/langchain_core/language_models/model_profile.py @@ -1,7 +1,14 @@ """Model profile types and utilities.""" +import logging +import warnings +from typing import get_type_hints + +from pydantic import ConfigDict from typing_extensions import TypedDict +logger = logging.getLogger(__name__) + class ModelProfile(TypedDict, total=False): """Model profile. @@ -14,6 +21,25 @@ class ModelProfile(TypedDict, total=False): and supported features. """ + __pydantic_config__ = ConfigDict(extra="allow") # type: ignore[misc] + + # --- Model metadata --- + + name: str + """Human-readable model name.""" + + status: str + """Model status (e.g., `'active'`, `'deprecated'`).""" + + release_date: str + """Model release date (ISO 8601 format, e.g., `'2025-06-01'`).""" + + last_updated: str + """Date the model was last updated (ISO 8601 format).""" + + open_weights: bool + """Whether the model weights are openly available.""" + # --- Input constraints --- max_input_tokens: int @@ -86,6 +112,45 @@ class ModelProfile(TypedDict, total=False): """Whether the model supports a native [structured output](https://docs.langchain.com/oss/python/langchain/models#structured-outputs) feature""" + # --- Other capabilities --- + + attachment: bool + """Whether the model supports file attachments.""" + + temperature: bool + """Whether the model supports a temperature parameter.""" + ModelProfileRegistry = dict[str, ModelProfile] """Registry mapping model identifiers or names to their ModelProfile.""" + + +def _warn_unknown_profile_keys(profile: ModelProfile) -> None: + """Warn if `profile` contains keys not declared on `ModelProfile`. + + Args: + profile: The model profile dict to check for undeclared keys. + """ + if not isinstance(profile, dict): + return + + try: + declared = frozenset(get_type_hints(ModelProfile).keys()) + except (TypeError, NameError): + # get_type_hints raises NameError on unresolvable forward refs and + # TypeError when annotations evaluate to non-type objects. + logger.debug( + "Could not resolve type hints for ModelProfile; " + "skipping unknown-key check.", + exc_info=True, + ) + return + + extra = sorted(set(profile) - declared) + if extra: + warnings.warn( + f"Unrecognized keys in model profile: {extra}. " + f"This may indicate a version mismatch between langchain-core " + f"and your provider package. Consider upgrading langchain-core.", + stacklevel=2, + ) diff --git a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py index f0280b2ce81..90a12671d7a 100644 --- a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py +++ b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py @@ -6,7 +6,8 @@ from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Any, Literal import pytest -from typing_extensions import override +from pydantic import model_validator +from typing_extensions import Self, override from langchain_core.callbacks import ( CallbackManagerForLLMRun, @@ -22,6 +23,7 @@ from langchain_core.language_models.fake_chat_models import ( FakeListChatModelError, GenericFakeChatModel, ) +from langchain_core.language_models.model_profile import ModelProfile from langchain_core.messages import ( AIMessage, AIMessageChunk, @@ -1230,6 +1232,76 @@ def test_model_profiles() -> None: assert model_with_profile.profile == {"max_input_tokens": 100} +def test_resolve_model_profile_hook_populates_profile() -> None: + """_resolve_model_profile is called when profile is None.""" + + class ResolverModel(GenericFakeChatModel): + def _resolve_model_profile(self) -> ModelProfile | None: + return {"max_input_tokens": 500} + + model = ResolverModel(messages=iter([])) + assert model.profile == {"max_input_tokens": 500} + + +def test_resolve_model_profile_hook_skipped_when_explicit() -> None: + """_resolve_model_profile is NOT called when profile is set explicitly.""" + + class ResolverModel(GenericFakeChatModel): + def _resolve_model_profile(self) -> ModelProfile | None: + return {"max_input_tokens": 500} + + model = ResolverModel(messages=iter([]), profile={"max_input_tokens": 999}) + assert model.profile is not None + assert model.profile["max_input_tokens"] == 999 + + +def test_resolve_model_profile_hook_exception_is_caught() -> None: + """Model is still usable if _resolve_model_profile raises.""" + + class BrokenProfileModel(GenericFakeChatModel): + def _resolve_model_profile(self) -> ModelProfile | None: + msg = "profile file not found" + raise RuntimeError(msg) + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + model = BrokenProfileModel(messages=iter([])) + + assert model.profile is None + + +def test_check_profile_keys_runs_despite_partner_override() -> None: + """Verify _check_profile_keys fires even when _set_model_profile is overridden. + + Because _check_profile_keys has a distinct validator name from + _set_model_profile, a partner override of the latter does not suppress + the key-checking validator. + """ + + class PartnerModel(GenericFakeChatModel): + """Simulates a partner that overrides _set_model_profile.""" + + @model_validator(mode="after") + def _set_model_profile(self) -> Self: + if self.profile is None: + profile: dict[str, Any] = { + "max_input_tokens": 100, + "partner_only_field": True, + } + self.profile = profile # type: ignore[assignment] + return self + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + model = PartnerModel(messages=iter([])) + + assert model.profile is not None + assert model.profile.get("partner_only_field") is True + profile_warnings = [x for x in w if "Unrecognized keys" in str(x.message)] + assert len(profile_warnings) == 1 + assert "partner_only_field" in str(profile_warnings[0].message) + + class MockResponse: """Mock response for testing _generate_response_from_error.""" diff --git a/libs/core/tests/unit_tests/language_models/test_model_profile.py b/libs/core/tests/unit_tests/language_models/test_model_profile.py new file mode 100644 index 00000000000..24b95ee0f71 --- /dev/null +++ b/libs/core/tests/unit_tests/language_models/test_model_profile.py @@ -0,0 +1,87 @@ +"""Tests for model profile types and utilities.""" + +import warnings +from typing import Any +from unittest.mock import patch + +from pydantic import BaseModel, ConfigDict, Field + +from langchain_core.language_models.model_profile import ( + ModelProfile, + _warn_unknown_profile_keys, +) + + +class TestModelProfileExtraAllow: + """Verify extra='allow' on ModelProfile TypedDict.""" + + def test_accepts_declared_keys(self) -> None: + profile: ModelProfile = {"max_input_tokens": 100, "tool_calling": True} + assert profile["max_input_tokens"] == 100 + + def test_extra_keys_accepted_via_typed_dict(self) -> None: + """ModelProfile TypedDict allows extra keys at construction.""" + profile = ModelProfile( + max_input_tokens=100, + unknown_future_field="value", # type: ignore[typeddict-unknown-key] + ) + assert profile["unknown_future_field"] == "value" # type: ignore[typeddict-item] + + def test_extra_keys_survive_pydantic_validation(self) -> None: + """Extra keys pass through even when parent model forbids extras.""" + + class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + profile: ModelProfile | None = Field(default=None) + + m = StrictModel( + profile={ + "max_input_tokens": 100, + "unknown_future_field": True, + } + ) + assert m.profile is not None + assert m.profile.get("unknown_future_field") is True + + +class TestWarnUnknownProfileKeys: + """Tests for _warn_unknown_profile_keys.""" + + def test_warns_on_extra_keys(self) -> None: + profile: dict[str, Any] = { + "max_input_tokens": 100, + "future_field": True, + "another": "val", + } + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_unknown_profile_keys(profile) # type: ignore[arg-type] + + assert len(w) == 1 + assert "another" in str(w[0].message) + assert "future_field" in str(w[0].message) + assert "upgrading langchain-core" in str(w[0].message) + + def test_silent_on_declared_keys_only(self) -> None: + profile: ModelProfile = {"max_input_tokens": 100, "tool_calling": True} + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_unknown_profile_keys(profile) + + assert len(w) == 0 + + def test_silent_on_empty_profile(self) -> None: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_unknown_profile_keys({}) + + assert len(w) == 0 + + def test_survives_get_type_hints_failure(self) -> None: + """Falls back to silent skip on TypeError from get_type_hints.""" + profile: dict[str, Any] = {"max_input_tokens": 100, "extra": True} + with patch( + "langchain_core.language_models.model_profile.get_type_hints", + side_effect=TypeError("broken"), + ): + _warn_unknown_profile_keys(profile) # type: ignore[arg-type] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py index 91ef582ef4a..1ae973c7ad4 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py @@ -388,36 +388,13 @@ def test_summarization_middleware_token_retention_preserves_ai_tool_pairs() -> N def test_summarization_middleware_missing_profile() -> None: - """Ensure automatic profile inference falls back when profiles are unavailable.""" - - class ImportErrorProfileModel(BaseChatModel): - @override - def _generate( - self, - messages: list[BaseMessage], - stop: list[str] | None = None, - run_manager: CallbackManagerForLLMRun | None = None, - **kwargs: Any, - ) -> ChatResult: - raise NotImplementedError - - @property - def _llm_type(self) -> str: - return "mock" - - # NOTE: Using __getattribute__ because @property cannot override Pydantic fields. - def __getattribute__(self, name: str) -> Any: - if name == "profile": - msg = "Profile not available" - raise AttributeError(msg) - return super().__getattribute__(name) - + """Ensure fractional limits fail when model has no profile data.""" with pytest.raises( ValueError, match="Model profile information is required to use fractional token limits", ): _ = SummarizationMiddleware( - model=ImportErrorProfileModel(), trigger=("fraction", 0.5), keep=("messages", 1) + model=MockChatModel(), trigger=("fraction", 0.5), keep=("messages", 1) ) diff --git a/libs/model-profiles/langchain_model_profiles/cli.py b/libs/model-profiles/langchain_model_profiles/cli.py index 58be483edf0..06246fd2333 100644 --- a/libs/model-profiles/langchain_model_profiles/cli.py +++ b/libs/model-profiles/langchain_model_profiles/cli.py @@ -5,8 +5,9 @@ import json import re import sys import tempfile +import warnings from pathlib import Path -from typing import Any +from typing import Any, get_type_hints import httpx @@ -150,6 +151,38 @@ def _apply_overrides( return merged +def _warn_undeclared_profile_keys( + profiles: dict[str, dict[str, Any]], +) -> None: + """Warn if any profile keys are not declared in `ModelProfile`. + + Args: + profiles: Mapping of model IDs to their profile dicts. + """ + try: + from langchain_core.language_models.model_profile import ModelProfile + except ImportError: + # langchain-core may not be installed or importable; skip check. + return + + try: + declared = set(get_type_hints(ModelProfile).keys()) + except (TypeError, NameError): + # get_type_hints raises NameError on unresolvable forward refs and + # TypeError when annotations evaluate to non-type objects. + return + extra = sorted({k for p in profiles.values() for k in p} - declared) + if extra: + warnings.warn( + f"Profile keys not declared in langchain_core ModelProfile: {extra}. " + f"Add these fields to " + f"langchain_core.language_models.model_profile.ModelProfile and " + f"release langchain-core before publishing partner packages that " + f"use these profiles.", + stacklevel=2, + ) + + def _ensure_safe_output_path(base_dir: Path, output_file: Path) -> None: """Ensure the resolved output path remains inside the expected directory.""" if base_dir.exists() and base_dir.is_symlink(): @@ -300,6 +333,8 @@ def refresh(provider: str, data_dir: Path) -> None: # noqa: C901, PLR0915 for model_id in sorted(extra_models): profiles[model_id] = _apply_overrides({}, provider_aug, model_augs[model_id]) + _warn_undeclared_profile_keys(profiles) + # Ensure directory exists try: data_dir.mkdir(parents=True, exist_ok=True, mode=0o755) diff --git a/libs/model-profiles/tests/unit_tests/test_cli.py b/libs/model-profiles/tests/unit_tests/test_cli.py index 6c265d37acc..aae60481717 100644 --- a/libs/model-profiles/tests/unit_tests/test_cli.py +++ b/libs/model-profiles/tests/unit_tests/test_cli.py @@ -1,12 +1,19 @@ """Tests for CLI functionality.""" import importlib.util +import warnings from pathlib import Path +from typing import Any, get_type_hints from unittest.mock import Mock, patch import pytest +from langchain_core.language_models.model_profile import ModelProfile -from langchain_model_profiles.cli import _model_data_to_profile, refresh +from langchain_model_profiles.cli import ( + _model_data_to_profile, + _warn_undeclared_profile_keys, + refresh, +) @pytest.fixture @@ -364,3 +371,104 @@ def test_model_data_to_profile_text_modalities() -> None: profile = _model_data_to_profile(image_gen_model) assert profile["text_inputs"] is True assert profile["text_outputs"] is False + + +def test_model_data_to_profile_keys_subset_of_model_profile() -> None: + """All CLI-emitted profile keys must be declared in `ModelProfile`.""" + # Build a model_data dict with every possible field populated so + # _model_data_to_profile includes all keys it can emit. + model_data = { + "id": "test-model", + "name": "Test Model", + "status": "active", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "open_weights": True, + "reasoning": True, + "tool_call": True, + "tool_choice": True, + "structured_output": True, + "attachment": True, + "temperature": True, + "image_url_inputs": True, + "image_tool_message": True, + "pdf_tool_message": True, + "pdf_inputs": True, + "limit": {"context": 100000, "output": 4096}, + "modalities": { + "input": ["text", "image", "audio", "video", "pdf"], + "output": ["text", "image", "audio", "video"], + }, + } + + profile = _model_data_to_profile(model_data) + declared_fields = set(get_type_hints(ModelProfile).keys()) + emitted_fields = set(profile.keys()) + extra = emitted_fields - declared_fields + + assert not extra, ( + f"CLI emits profile keys not declared in ModelProfile: {sorted(extra)}. " + f"Add these fields to langchain_core.language_models.model_profile." + f"ModelProfile and release langchain-core before refreshing partner " + f"profiles." + ) + + +class TestWarnUndeclaredProfileKeys: + """Tests for _warn_undeclared_profile_keys.""" + + def test_warns_on_undeclared_keys(self) -> None: + """Extra keys across profiles trigger a single warning.""" + profiles: dict[str, dict[str, Any]] = { + "model-a": {"max_input_tokens": 100, "future_key": True}, + "model-b": {"another_key": "val"}, + } + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_undeclared_profile_keys(profiles) + + assert len(w) == 1 + assert "another_key" in str(w[0].message) + assert "future_key" in str(w[0].message) + + def test_silent_on_declared_keys_only(self) -> None: + """No warning when all keys are declared in ModelProfile.""" + profiles: dict[str, dict[str, Any]] = { + "model-a": {"max_input_tokens": 100, "tool_calling": True}, + } + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_undeclared_profile_keys(profiles) + + assert len(w) == 0 + + def test_silent_when_langchain_core_not_installed(self) -> None: + """Gracefully skips when langchain-core is not importable.""" + import sys + + profiles: dict[str, dict[str, Any]] = { + "model-a": {"unknown": True}, + } + with ( + patch.dict( + sys.modules, + {"langchain_core.language_models.model_profile": None}, + ), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + _warn_undeclared_profile_keys(profiles) + + undeclared_warnings = [x for x in w if "not declared" in str(x.message)] + assert len(undeclared_warnings) == 0 + + def test_survives_get_type_hints_failure(self) -> None: + """Gracefully handles TypeError from get_type_hints.""" + profiles: dict[str, dict[str, Any]] = { + "model-a": {"unknown": True}, + } + with patch( + "langchain_model_profiles.cli.get_type_hints", + side_effect=TypeError("broken"), + ): + _warn_undeclared_profile_keys(profiles) diff --git a/libs/model-profiles/uv.lock b/libs/model-profiles/uv.lock index e9bf34bde89..67ac78f40c1 100644 --- a/libs/model-profiles/uv.lock +++ b/libs/model-profiles/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.12" +version = "1.2.13" source = { editable = "../langchain_v1" } dependencies = [ { name = "langchain-core" }, @@ -477,6 +477,7 @@ requires-dist = [ { name = "langchain-anthropic", marker = "extra == 'anthropic'", editable = "../partners/anthropic" }, { name = "langchain-aws", marker = "extra == 'aws'" }, { name = "langchain-azure-ai", marker = "extra == 'azure-ai'" }, + { name = "langchain-baseten", marker = "extra == 'baseten'", specifier = ">=0.2.0" }, { name = "langchain-community", marker = "extra == 'community'" }, { name = "langchain-core", editable = "../core" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'" }, @@ -494,7 +495,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.1.1,<1.2.0" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] -provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "deepseek", "xai", "perplexity"] +provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity"] [package.metadata.requires-dev] lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }] diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 273f184bdf2..419723e7428 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -55,7 +55,7 @@ from langchain_core.utils.function_calling import ( 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 -from typing_extensions import NotRequired, Self, TypedDict +from typing_extensions import NotRequired, TypedDict from langchain_anthropic import __version__ from langchain_anthropic._client_utils import ( @@ -967,18 +967,11 @@ class ChatAnthropic(BaseChatModel): all_required_field_names = get_pydantic_field_names(cls) return _build_model_kwargs(values, all_required_field_names) - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model) - if ( - self.profile is not None - and self.betas - and "context-1m-2025-08-07" in self.betas - ): - self.profile["max_input_tokens"] = 1_000_000 - return self + def _resolve_model_profile(self) -> ModelProfile | None: + profile = _get_default_model_profile(self.model) or None + if profile is not None and self.betas and "context-1m-2025-08-07" in self.betas: + profile["max_input_tokens"] = 1_000_000 + return profile @cached_property def _client_params(self) -> dict[str, Any]: diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index 6a9e099124f..a20fdee5fef 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10.0, <4.0.0" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -498,7 +498,7 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.12" +version = "1.2.13" source = { editable = "../../langchain_v1" } dependencies = [ { name = "langchain-core" }, @@ -511,6 +511,7 @@ requires-dist = [ { name = "langchain-anthropic", marker = "extra == 'anthropic'", editable = "." }, { name = "langchain-aws", marker = "extra == 'aws'" }, { name = "langchain-azure-ai", marker = "extra == 'azure-ai'" }, + { name = "langchain-baseten", marker = "extra == 'baseten'", specifier = ">=0.2.0" }, { name = "langchain-community", marker = "extra == 'community'" }, { name = "langchain-core", editable = "../../core" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'" }, @@ -528,7 +529,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.1.1,<1.2.0" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] -provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "deepseek", "xai", "perplexity"] +provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity"] [package.metadata.requires-dev] lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }] @@ -646,7 +647,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index 3cec87d6536..a56df1474b4 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -258,12 +258,8 @@ class ChatDeepSeek(BaseChatOpenAI): self.async_client = self.root_async_client.chat.completions return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None def _get_request_payload( self, diff --git a/libs/partners/deepseek/uv.lock b/libs/partners/deepseek/uv.lock index 171b5a54350..436c410cf44 100644 --- a/libs/partners/deepseek/uv.lock +++ b/libs/partners/deepseek/uv.lock @@ -370,7 +370,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/fireworks/langchain_fireworks/chat_models.py b/libs/partners/fireworks/langchain_fireworks/chat_models.py index e0076c9dc22..416a6503ec5 100644 --- a/libs/partners/fireworks/langchain_fireworks/chat_models.py +++ b/libs/partners/fireworks/langchain_fireworks/chat_models.py @@ -424,12 +424,8 @@ class ChatFireworks(BaseChatModel): self.async_client._max_retries = self.max_retries return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None @property def _default_params(self) -> dict[str, Any]: diff --git a/libs/partners/fireworks/uv.lock b/libs/partners/fireworks/uv.lock index b949994011b..75aaef55271 100644 --- a/libs/partners/fireworks/uv.lock +++ b/libs/partners/fireworks/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/groq/langchain_groq/chat_models.py b/libs/partners/groq/langchain_groq/chat_models.py index 841f5c0a14b..d1fb4be8ab5 100644 --- a/libs/partners/groq/langchain_groq/chat_models.py +++ b/libs/partners/groq/langchain_groq/chat_models.py @@ -543,12 +543,8 @@ class ChatGroq(BaseChatModel): raise ImportError(msg) from exc return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None # # Serializable class method overrides diff --git a/libs/partners/groq/uv.lock b/libs/partners/groq/uv.lock index ab2ed36cc16..43f66d831f3 100644 --- a/libs/partners/groq/uv.lock +++ b/libs/partners/groq/uv.lock @@ -314,7 +314,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py b/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py index 31cfb85f1cd..52e9df5d343 100644 --- a/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py +++ b/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py @@ -596,12 +596,10 @@ class ChatHuggingFace(BaseChatModel): raise TypeError(msg) return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None and self.model_id: - self.profile = _get_default_model_profile(self.model_id) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + if self.model_id: + return _get_default_model_profile(self.model_id) or None + return None @classmethod def from_model_id( diff --git a/libs/partners/huggingface/uv.lock b/libs/partners/huggingface/uv.lock index 7924ba03aed..3c9793421a4 100644 --- a/libs/partners/huggingface/uv.lock +++ b/libs/partners/huggingface/uv.lock @@ -943,7 +943,7 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.12" +version = "1.2.13" source = { editable = "../../langchain_v1" } dependencies = [ { name = "langchain-core" }, @@ -956,6 +956,7 @@ requires-dist = [ { name = "langchain-anthropic", marker = "extra == 'anthropic'", editable = "../anthropic" }, { name = "langchain-aws", marker = "extra == 'aws'" }, { name = "langchain-azure-ai", marker = "extra == 'azure-ai'" }, + { name = "langchain-baseten", marker = "extra == 'baseten'", specifier = ">=0.2.0" }, { name = "langchain-community", marker = "extra == 'community'" }, { name = "langchain-core", editable = "../../core" }, { name = "langchain-deepseek", marker = "extra == 'deepseek'" }, @@ -973,7 +974,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.1.1,<1.2.0" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] -provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "deepseek", "xai", "perplexity"] +provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity"] [package.metadata.requires-dev] lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }] @@ -1030,7 +1031,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/mistralai/langchain_mistralai/chat_models.py b/libs/partners/mistralai/langchain_mistralai/chat_models.py index 0dabd2c57f8..fb9bce64c37 100644 --- a/libs/partners/mistralai/langchain_mistralai/chat_models.py +++ b/libs/partners/mistralai/langchain_mistralai/chat_models.py @@ -646,12 +646,8 @@ class ChatMistralAI(BaseChatModel): return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model) or None def _generate( self, diff --git a/libs/partners/mistralai/uv.lock b/libs/partners/mistralai/uv.lock index 19c9e745fb9..5c703f3e086 100644 --- a/libs/partners/mistralai/uv.lock +++ b/libs/partners/mistralai/uv.lock @@ -349,7 +349,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/openai/langchain_openai/chat_models/azure.py b/libs/partners/openai/langchain_openai/chat_models/azure.py index fea2f384868..f034da0da2b 100644 --- a/libs/partners/openai/langchain_openai/chat_models/azure.py +++ b/libs/partners/openai/langchain_openai/chat_models/azure.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import os from collections.abc import AsyncIterator, Awaitable, Callable, Iterator -from typing import Any, Literal, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar import openai from langchain_core.language_models import LanguageModelInput @@ -19,6 +19,9 @@ from typing_extensions import Self from langchain_openai.chat_models.base import BaseChatOpenAI, _get_default_model_profile +if TYPE_CHECKING: + from langchain_core.language_models import ModelProfile + logger = logging.getLogger(__name__) @@ -701,12 +704,10 @@ class AzureChatOpenAI(BaseChatOpenAI): self.async_client = self.root_async_client.chat.completions return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None and self.deployment_name is not None: - self.profile = _get_default_model_profile(self.deployment_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + if self.deployment_name is not None: + return _get_default_model_profile(self.deployment_name) or None + return None @property def _identifying_params(self) -> dict[str, Any]: diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 947c23236de..022365da8da 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -1094,12 +1094,8 @@ class BaseChatOpenAI(BaseChatModel): self.async_client = self.root_async_client.chat.completions return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None @property def _default_params(self) -> dict[str, Any]: diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index b67bc2be236..b82733899ae 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -350,12 +350,8 @@ class ChatOpenRouter(BaseChatModel): self.client = openrouter.OpenRouter(**client_kwargs) return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None # # Serializable class method overrides diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index 5e7bc3a1cef..0bca8c3cfa4 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -2963,3 +2963,9 @@ class TestStreamUsage: assert usage["input_tokens"] == 10 assert usage["output_tokens"] == 5 assert usage["total_tokens"] == 15 + + +def test_profile() -> None: + """Test that the model has a profile.""" + model = _make_model() + assert model.profile diff --git a/libs/partners/openrouter/uv.lock b/libs/partners/openrouter/uv.lock index 6d4650ec099..01f3cd8d534 100644 --- a/libs/partners/openrouter/uv.lock +++ b/libs/partners/openrouter/uv.lock @@ -318,7 +318,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.17" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/perplexity/langchain_perplexity/chat_models.py b/libs/partners/perplexity/langchain_perplexity/chat_models.py index 300250d54e2..99e27ede01d 100644 --- a/libs/partners/perplexity/langchain_perplexity/chat_models.py +++ b/libs/partners/perplexity/langchain_perplexity/chat_models.py @@ -305,12 +305,8 @@ class ChatPerplexity(BaseChatModel): return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model) or None @property def _default_params(self) -> dict[str, Any]: diff --git a/libs/partners/perplexity/uv.lock b/libs/partners/perplexity/uv.lock index a04cae4465c..6d483deb849 100644 --- a/libs/partners/perplexity/uv.lock +++ b/libs/partners/perplexity/uv.lock @@ -422,7 +422,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/xai/langchain_xai/chat_models.py b/libs/partners/xai/langchain_xai/chat_models.py index 6ecc68bef8f..359667376e4 100644 --- a/libs/partners/xai/langchain_xai/chat_models.py +++ b/libs/partners/xai/langchain_xai/chat_models.py @@ -532,12 +532,8 @@ class ChatXAI(BaseChatOpenAI): # type: ignore[override] return self - @model_validator(mode="after") - def _set_model_profile(self) -> Self: - """Set model profile if not overridden.""" - if self.profile is None: - self.profile = _get_default_model_profile(self.model_name) - return self + def _resolve_model_profile(self) -> ModelProfile | None: + return _get_default_model_profile(self.model_name) or None def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]: """Route to Chat Completions or Responses API.""" diff --git a/libs/partners/xai/uv.lock b/libs/partners/xai/uv.lock index ab6ef4bf427..9c6396d8183 100644 --- a/libs/partners/xai/uv.lock +++ b/libs/partners/xai/uv.lock @@ -655,7 +655,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.19" +version = "1.2.20" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },