commit 7406cfee435f11553b3c8ccf702fab4d24dfbd8b Author: wehub-resource-sync Date: Mon Jul 13 11:58:32 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..8f1b9e4 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,49 @@ +# Dev container + +This project includes a [dev container](https://containers.dev/), which lets you use a container as a full-featured dev environment. + +You can use the dev container configuration in this folder to build and run the app without needing to install any of its tools locally! You can use it in [GitHub Codespaces](https://github.com/features/codespaces) or the [VS Code Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). + +## GitHub Codespaces + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/langchain-ai/langchain) + +You may use the button above, or follow these steps to open this repo in a Codespace: + +1. Click the **Code** drop-down menu at the top of . +1. Click on the **Codespaces** tab. +1. Click **Create codespace on master**. + +For more info, check out the [GitHub documentation](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace#creating-a-codespace). + +## VS Code Dev Containers + +[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/langchain-ai/langchain) + +> [!NOTE] +> If you click the link above you will open the main repo (`langchain-ai/langchain`) and *not* your local cloned repo. This is fine if you only want to run and test the library, but if you want to contribute you can use the link below and replace with your username and cloned repo name: + +```txt +https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/<YOUR_USERNAME>/<YOUR_CLONED_REPO_NAME> +``` + +Then you will have a local cloned repo where you can contribute and then create pull requests. + +If you already have VS Code and Docker installed, you can use the button above to get started. This will use VSCode to automatically install the Dev Containers extension if needed, clone the source code into a container volume, and spin up a dev container for use. + +Alternatively you can also follow these steps to open this repo in a container using the VS Code Dev Containers extension: + +1. If this is your first time using a development container, please ensure your system meets the pre-reqs (i.e. have Docker installed) in the [getting started steps](https://aka.ms/vscode-remote/containers/getting-started). + +2. Open a locally cloned copy of the code: + + - Fork and Clone this repository to your local filesystem. + - Press F1 and select the **Dev Containers: Open Folder in Container...** command. + - Select the cloned copy of this folder, wait for the container to start, and try things out! + +You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers). + +## Tips and tricks + +- If you are working with the same repository folder in a container and Windows, you'll want consistent line endings (otherwise you may see hundreds of changes in the SCM view). The `.gitattributes` file in the root of this repo will disable line ending conversion and should prevent this. See [tips and tricks](https://code.visualstudio.com/docs/devcontainers/tips-and-tricks#_resolving-git-line-ending-issues-in-containers-resulting-in-many-modified-files) for more info. +- If you'd like to review the contents of the image used in this dev container, you can check it out in the [devcontainers/images](https://github.com/devcontainers/images/tree/main/src/python) repo. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e4557d6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,58 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + // Name for the dev container + "name": "langchain", + // Point to a Docker Compose file + "dockerComposeFile": "./docker-compose.yaml", + // Required when using Docker Compose. The name of the service to connect to once running + "service": "langchain", + // The optional 'workspaceFolder' property is the path VS Code should open by default when + // connected. This is typically a file mount in .devcontainer/docker-compose.yml + "workspaceFolder": "/workspaces/langchain", + "mounts": [ + "source=langchain-workspaces,target=/workspaces/langchain,type=volume" + ], + // Prevent the container from shutting down + "overrideCommand": true, + // Features to add to the dev container. More info: https://containers.dev/features + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "containerEnv": { + "UV_LINK_MODE": "copy" + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Run commands after the container is created + "postCreateCommand": "cd libs/langchain_v1 && uv sync && echo 'LangChain (Python) dev environment ready!'", + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.debugpy", + "ms-python.mypy-type-checker", + "ms-python.isort", + "unifiedjs.vscode-mdx", + "davidanson.vscode-markdownlint", + "ms-toolsai.jupyter", + "GitHub.copilot", + "GitHub.copilot-chat" + ], + "settings": { + "python.defaultInterpreterPath": "libs/langchain_v1/.venv/bin/python", + "python.formatting.provider": "none", + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + } + } + } + } + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 0000000..1994073 --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,13 @@ +version: '3' +services: + langchain: + build: + dockerfile: libs/langchain/dev.Dockerfile + context: .. + + networks: + - langchain-network + +networks: + langchain-network: + driver: bridge diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec73567 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git +.git +.github + +# Python +__pycache__ +*.pyc +*.pyo +.venv +.mypy_cache +.pytest_cache +.ruff_cache +*.egg-info +.tox + +# IDE +.idea +.vscode + +# Worktree +worktree + +# Test artifacts +.coverage +htmlcov +coverage.xml + +# Build artifacts +dist +build + +# Misc +*.log +.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0d6ddd9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Python files +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 88 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false + +# Configuration files +[*.{toml,ini,cfg}] +indent_style = space +indent_size = 4 + +# Shell scripts +[*.sh] +indent_style = space +indent_size = 2 + +# Makefile +[Makefile] +indent_style = tab +indent_size = 4 + +# Jupyter notebooks +[*.ipynb] +# Jupyter may include trailing whitespace in cell +# outputs that's semantically meaningful +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5dc46e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3812494 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +/.github/ @ccurme @eyurtsev @mdrxy +/libs/core/ @eyurtsev +/libs/partners/ @ccurme @mdrxy diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..b0fbd01 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,153 @@ +name: "\U0001F41B Bug Report" +description: Report a bug in LangChain. To report a security issue, please instead use the security option (below). For questions, please use the LangChain forum (below). +labels: ["bug"] +type: bug +body: + - type: markdown + attributes: + value: | + > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + + Thank you for taking the time to file a bug report. + + For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/). + + Check these before submitting to see if your issue has already been reported, fixed or if there's another way to solve your problem: + + * [Documentation](https://docs.langchain.com/oss/python/langchain/overview), + * [API Reference Documentation](https://reference.langchain.com/python/), + * [LangChain ChatBot](https://chat.langchain.com/) + * [GitHub search](https://github.com/langchain-ai/langchain), + * [LangChain Forum](https://forum.langchain.com/), + - type: checkboxes + id: checks + attributes: + label: Submission checklist + description: Please confirm and check all the following options. + options: + - label: This is a bug, not a usage question. + required: true + - label: I added a clear and descriptive title that summarizes this issue. + required: true + - label: I used the GitHub search to find a similar question and didn't find it. + required: true + - label: I am sure that this is a bug in LangChain rather than my code. + required: true + - label: The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package). + required: true + - label: This is not related to the langchain-community package. + required: true + - label: I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS. + required: true + - type: checkboxes + id: package + attributes: + label: Package (Required) + description: | + Which `langchain` package(s) is this bug related to? Select at least one. + + Note that if the package you are reporting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in [`langchain-ai/langchain-google`](https://github.com/langchain-ai/langchain-google/)). + + Please report issues for other packages to their respective repositories. + options: + - label: langchain + - label: langchain-openai + - label: langchain-anthropic + - label: langchain-classic + - label: langchain-core + - label: langchain-model-profiles + - label: langchain-tests + - label: langchain-text-splitters + - label: langchain-chroma + - label: langchain-deepseek + - label: langchain-exa + - label: langchain-fireworks + - label: langchain-groq + - label: langchain-huggingface + - label: langchain-mistralai + - label: langchain-nomic + - label: langchain-ollama + - label: langchain-openrouter + - label: langchain-perplexity + - label: langchain-qdrant + - label: langchain-xai + - label: Other / not sure / general + - type: textarea + id: related + validations: + required: false + attributes: + label: Related Issues / PRs + description: | + If this bug is related to any existing issues or pull requests, please link them here. + placeholder: | + * e.g. #123, #456 + - type: textarea + id: reproduction + validations: + required: true + attributes: + label: Reproduction Steps / Example Code (Python) + description: | + Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. + + If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help. + + **Important!** + + * Avoid screenshots, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code. + * Reduce your code to the minimum required to reproduce the issue if possible. + + (This will be automatically formatted into code, so no need for backticks.) + render: python + placeholder: | + from langchain_core.runnables import RunnableLambda + + def bad_code(inputs) -> int: + raise NotImplementedError('For demo purpose') + + chain = RunnableLambda(bad_code) + chain.invoke('Hello!') + - type: textarea + attributes: + label: Error Message and Stack Trace (if applicable) + description: | + If you are reporting an error, please copy and paste the full error message and + stack trace. + (This will be automatically formatted into code, so no need for backticks.) + render: shell + - type: textarea + id: description + attributes: + label: Description + description: | + What is the problem, question, or error? + + Write a short description telling what you are doing, what you expect to happen, and what is currently happening. + placeholder: | + * I'm trying to use the `langchain` library to do X. + * I expect to see Y. + * Instead, it does Z. + validations: + required: true + - type: textarea + id: system-info + attributes: + label: System Info + description: | + Please share your system info with us. + + Run the following command in your terminal and paste the output here: + + `python -m langchain_core.sys_info` + + or if you have an existing python interpreter running: + + ```python + from langchain_core import sys_info + sys_info.print_sys_info() + ``` + placeholder: | + python -m langchain_core.sys_info + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3390df9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +blank_issues_enabled: false +version: 2.1 +contact_links: + - name: 💬 LangChain Forum + url: https://forum.langchain.com/ + about: General community discussions and support + - name: 📚 LangChain Documentation + url: https://docs.langchain.com/oss/python/langchain/overview + about: View the official LangChain documentation + - name: 📚 API Reference Documentation + url: https://reference.langchain.com/python/ + about: View the official LangChain API reference documentation + - name: 📚 Documentation issue + url: https://github.com/langchain-ai/docs/issues/new?template=01-langchain.yml + about: Report an issue related to the LangChain documentation diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..39e1759 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,155 @@ +name: "✨ Feature Request" +description: Request a new feature or enhancement for LangChain. For questions, please use the LangChain forum (below). +labels: ["feature request"] +type: feature +body: + - type: markdown + attributes: + value: | + > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + + Thank you for taking the time to request a new feature. + + Use this to request NEW FEATURES or ENHANCEMENTS in LangChain. For bug reports, please use the bug report template. For usage questions and general design questions, please use the [LangChain Forum](https://forum.langchain.com/). + + Relevant links to check before filing a feature request to see if your request has already been made or + if there's another way to achieve what you want: + + * [Documentation](https://docs.langchain.com/oss/python/langchain/overview), + * [API Reference Documentation](https://reference.langchain.com/python/), + * [LangChain ChatBot](https://chat.langchain.com/) + * [GitHub search](https://github.com/langchain-ai/langchain), + * [LangChain Forum](https://forum.langchain.com/), + + **Note:** Do not begin work on a PR unless explicitly assigned to this issue by a maintainer. + - type: checkboxes + id: checks + attributes: + label: Submission checklist + description: Please confirm and check all the following options. + options: + - label: This is a feature request, not a bug report or usage question. + required: true + - label: I added a clear and descriptive title that summarizes the feature request. + required: true + - label: I used the GitHub search to find a similar feature request and didn't find it. + required: true + - label: I checked the LangChain documentation and API reference to see if this feature already exists. + required: true + - label: This is not related to the langchain-community package. + required: true + - type: checkboxes + id: package + attributes: + label: Package (Required) + description: | + Which `langchain` package(s) is this request related to? Select at least one. + + Note that if the package you are requesting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in `langchain-ai/langchain`). + + Please submit feature requests for other packages to their respective repositories. + options: + - label: langchain + - label: langchain-openai + - label: langchain-anthropic + - label: langchain-classic + - label: langchain-core + - label: langchain-model-profiles + - label: langchain-tests + - label: langchain-text-splitters + - label: langchain-chroma + - label: langchain-deepseek + - label: langchain-exa + - label: langchain-fireworks + - label: langchain-groq + - label: langchain-huggingface + - label: langchain-mistralai + - label: langchain-nomic + - label: langchain-ollama + - label: langchain-openrouter + - label: langchain-perplexity + - label: langchain-qdrant + - label: langchain-xai + - label: Other / not sure / general + - type: textarea + id: feature-description + validations: + required: true + attributes: + label: Feature Description + description: | + Please provide a clear and concise description of the feature you would like to see added to LangChain. + + What specific functionality are you requesting? Be as detailed as possible. + placeholder: | + I would like LangChain to support... + + This feature would allow users to... + - type: textarea + id: use-case + validations: + required: true + attributes: + label: Use Case + description: | + Describe the specific use case or problem this feature would solve. + + Why do you need this feature? What problem does it solve for you or other users? + placeholder: | + I'm trying to build an application that... + + Currently, I have to work around this by... + + This feature would help me/users to... + - type: textarea + id: proposed-solution + validations: + required: false + attributes: + label: Proposed Solution + description: | + If you have ideas about how this feature could be implemented, please describe them here. + + This is optional but can be helpful for maintainers to understand your vision. + placeholder: | + I think this could be implemented by... + + The API could look like... + + ```python + # Example of how the feature might work + ``` + - type: textarea + id: alternatives + validations: + required: false + attributes: + label: Alternatives Considered + description: | + Have you considered any alternative solutions or workarounds? + + What other approaches have you tried or considered? + placeholder: | + I've tried using... + + Alternative approaches I considered: + 1. ... + 2. ... + + But these don't work because... + - type: textarea + id: additional-context + validations: + required: false + attributes: + label: Additional Context + description: | + Add any other context, screenshots, examples, or references that would help explain your feature request. + placeholder: | + Related issues: #... + + Similar features in other libraries: + - ... + + Additional context or examples: + - ... diff --git a/.github/ISSUE_TEMPLATE/privileged.yml b/.github/ISSUE_TEMPLATE/privileged.yml new file mode 100644 index 0000000..f4754c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/privileged.yml @@ -0,0 +1,49 @@ +name: 🔒 Privileged +description: You are a LangChain maintainer, or was asked directly by a maintainer to create an issue here. If not, check the other options. +body: + - type: markdown + attributes: + value: | + If you are not a LangChain maintainer, employee, or were not asked directly by a maintainer to create an issue, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead. + - type: checkboxes + id: privileged + attributes: + label: Privileged issue + description: Confirm that you are allowed to create an issue here. + options: + - label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create an issue here. + required: true + - type: textarea + id: content + attributes: + label: Issue Content + description: Add the content of the issue here. + - type: checkboxes + id: package + attributes: + label: Package (Required) + description: | + Please select package(s) that this issue is related to. + options: + - label: langchain + - label: langchain-openai + - label: langchain-anthropic + - label: langchain-classic + - label: langchain-core + - label: langchain-model-profiles + - label: langchain-tests + - label: langchain-text-splitters + - label: langchain-chroma + - label: langchain-deepseek + - label: langchain-exa + - label: langchain-fireworks + - label: langchain-groq + - label: langchain-huggingface + - label: langchain-mistralai + - label: langchain-nomic + - label: langchain-ollama + - label: langchain-openrouter + - label: langchain-perplexity + - label: langchain-qdrant + - label: langchain-xai + - label: Other / not sure / general diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 0000000..a04d389 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,120 @@ +name: "📋 Task" +description: Create a task for project management and tracking by LangChain maintainers. If you are not a maintainer, please use other templates or the forum. +labels: ["task"] +type: task +body: + - type: markdown + attributes: + value: | + Thanks for creating a task to help organize LangChain development. + + This template is for **maintainer tasks** such as project management, development planning, refactoring, documentation updates, and other organizational work. + + If you are not a LangChain maintainer or were not asked directly by a maintainer to create a task, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead or use the appropriate bug report or feature request templates on the previous page. + - type: checkboxes + id: maintainer + attributes: + label: Maintainer task + description: Confirm that you are allowed to create a task here. + options: + - label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create a task here. + required: true + - type: textarea + id: task-description + attributes: + label: Task Description + description: | + Provide a clear and detailed description of the task. + + What needs to be done? Be specific about the scope and requirements. + placeholder: | + This task involves... + + The goal is to... + + Specific requirements: + - ... + - ... + validations: + required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: | + Define the criteria that must be met for this task to be considered complete. + + What are the specific deliverables or outcomes expected? + placeholder: | + This task will be complete when: + - [ ] ... + - [ ] ... + - [ ] ... + validations: + required: true + - type: textarea + id: context + attributes: + label: Context and Background + description: | + Provide any relevant context, background information, or links to related issues/PRs. + + Why is this task needed? What problem does it solve? + placeholder: | + Background: + - ... + + Related issues/PRs: + - #... + + Additional context: + - ... + validations: + required: false + - type: textarea + id: dependencies + attributes: + label: Dependencies + description: | + List any dependencies or blockers for this task. + + Are there other tasks, issues, or external factors that need to be completed first? + placeholder: | + This task depends on: + - [ ] Issue #... + - [ ] PR #... + - [ ] External dependency: ... + + Blocked by: + - ... + validations: + required: false + - type: checkboxes + id: package + attributes: + label: Package (Required) + description: | + Please select package(s) that this task is related to. + options: + - label: langchain + - label: langchain-openai + - label: langchain-anthropic + - label: langchain-classic + - label: langchain-core + - label: langchain-model-profiles + - label: langchain-tests + - label: langchain-text-splitters + - label: langchain-chroma + - label: langchain-deepseek + - label: langchain-exa + - label: langchain-fireworks + - label: langchain-groq + - label: langchain-huggingface + - label: langchain-mistralai + - label: langchain-nomic + - label: langchain-ollama + - label: langchain-openrouter + - label: langchain-perplexity + - label: langchain-qdrant + - label: langchain-xai + - label: Other / not sure / general diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a1f0f2b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,48 @@ +Fixes # + +--- + + + +Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview + +> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). + +If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED! + +Thank you for contributing to LangChain! Follow these steps to have your pull request considered as ready for review. + +1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION + + - Examples: + - fix(anthropic): resolve flag parsing error + - feat(core): add multi-tenant support + - test(openai): update API usage tests + - Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langchain/blob/master/.github/workflows/pr_lint.yml#L15-L33 + +2. PR description: + + - Write 1-2 sentences that make the change easy to understand: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary. + - The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge. + - If there are any breaking changes, please clearly describe them. + - If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description. + +## Release note + + +3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. + + - We will not consider a PR unless these three are passing in CI. + +4. How did you verify your code works? + +Additional guidelines: + + - All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed. + - PRs should not touch more than one package unless absolutely necessary. + - Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer. + +## Social handles (optional) + +Twitter: @ +LinkedIn: https://linkedin.com/in/ diff --git a/.github/actions/uv_setup/action.yml b/.github/actions/uv_setup/action.yml new file mode 100644 index 0000000..3f1dbf0 --- /dev/null +++ b/.github/actions/uv_setup/action.yml @@ -0,0 +1,39 @@ +# Helper to set up Python and uv with caching + +name: uv-install +description: Set up Python and uv with caching + +inputs: + python-version: + description: Python version, supporting MAJOR.MINOR only + required: true + enable-cache: + description: Enable caching for uv dependencies + required: false + default: "true" + cache-suffix: + description: Custom cache key suffix for cache invalidation + required: false + default: "" + working-directory: + description: Working directory for cache glob scoping + required: false + default: "**" + +env: + UV_VERSION: "0.11.26" + +runs: + using: composite + steps: + - name: Install uv and set the python version + uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7 + with: + version: ${{ env.UV_VERSION }} + python-version: ${{ inputs.python-version }} + enable-cache: ${{ inputs.enable-cache }} + cache-dependency-glob: | + ${{ inputs.working-directory }}/pyproject.toml + ${{ inputs.working-directory }}/uv.lock + ${{ inputs.working-directory }}/requirements*.txt + cache-suffix: ${{ inputs.cache-suffix }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dd8862e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,119 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# and +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directories: + - "/libs/core/" + - "/libs/langchain/" + - "/libs/langchain_v1/" + schedule: + interval: "monthly" + versioning-strategy: increase + ignore: + - dependency-name: "langchain-core" + - dependency-name: "langchain" + - dependency-name: "langchain-classic" + - dependency-name: "langchain-text-splitters" + - dependency-name: "langchain-tests" + - dependency-name: "langchain-model-profiles" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directories: + - "/libs/partners/anthropic/" + - "/libs/partners/chroma/" + - "/libs/partners/deepseek/" + - "/libs/partners/exa/" + - "/libs/partners/fireworks/" + - "/libs/partners/groq/" + - "/libs/partners/huggingface/" + - "/libs/partners/mistralai/" + - "/libs/partners/nomic/" + - "/libs/partners/ollama/" + - "/libs/partners/openai/" + - "/libs/partners/openrouter/" + - "/libs/partners/perplexity/" + - "/libs/partners/qdrant/" + - "/libs/partners/xai/" + schedule: + interval: "monthly" + versioning-strategy: increase + ignore: + - dependency-name: "langchain-core" + - dependency-name: "langchain" + - dependency-name: "langchain-classic" + - dependency-name: "langchain-text-splitters" + - dependency-name: "langchain-tests" + - dependency-name: "langchain-model-profiles" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" + + - package-ecosystem: "uv" + directories: + - "/libs/text-splitters/" + - "/libs/standard-tests/" + - "/libs/model-profiles/" + schedule: + interval: "monthly" + versioning-strategy: increase + ignore: + - dependency-name: "langchain-core" + - dependency-name: "langchain" + - dependency-name: "langchain-classic" + - dependency-name: "langchain-text-splitters" + - dependency-name: "langchain-tests" + - dependency-name: "langchain-model-profiles" + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + major: + patterns: + - "*" + update-types: + - "major" diff --git a/.github/images/logo-dark.svg b/.github/images/logo-dark.svg new file mode 100644 index 0000000..ba0aa4c --- /dev/null +++ b/.github/images/logo-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/.github/images/logo-light.svg b/.github/images/logo-light.svg new file mode 100644 index 0000000..b4371b0 --- /dev/null +++ b/.github/images/logo-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/.github/scripts/check_diff.py b/.github/scripts/check_diff.py new file mode 100644 index 0000000..8a24b87 --- /dev/null +++ b/.github/scripts/check_diff.py @@ -0,0 +1,398 @@ +"""Analyze git diffs to determine which directories need to be tested. + +Intelligently determines which LangChain packages and directories need to be tested, +linted, or built based on the changes. Handles dependency relationships between +packages, maps file changes to appropriate CI job configurations, and outputs JSON +configurations for GitHub Actions. + +- Maps changed files to affected package directories (libs/core, libs/partners/*, etc.) +- Builds dependency graph to include dependent packages when core components change +- Generates test matrix configurations with appropriate Python versions +- Handles special cases for Pydantic version testing and performance benchmarks + +Used as part of the check_diffs workflow. +""" + +import glob +import json +import os +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Set + +import tomllib +from get_min_versions import get_min_version_from_toml +from packaging.requirements import Requirement + +LANGCHAIN_DIRS = [ + "libs/core", + "libs/text-splitters", + "libs/langchain", + "libs/langchain_v1", + "libs/model-profiles", +] + +# Packages with VCR cassette-backed integration tests. +# These get a playback-only CI check to catch stale cassettes. +VCR_PACKAGES = { + "libs/partners/openai", +} + +# When set to True, we are ignoring core dependents +# in order to be able to get CI to pass for each individual +# package that depends on core +# e.g. if you touch core, we don't then add textsplitters/etc to CI +IGNORE_CORE_DEPENDENTS = False + +# Ignored partners are removed from dependents but still run if directly edited +IGNORED_PARTNERS = [ + # remove huggingface from dependents because of CI instability + # specifically in huggingface jobs + "huggingface", +] + + +def all_package_dirs() -> Set[str]: + return { + "/".join(path.split("/")[:-1]).lstrip("./") + for path in glob.glob("./libs/**/pyproject.toml", recursive=True) + if "libs/standard-tests" not in path + } + + +def dependents_graph() -> dict: + """Construct a mapping of package -> dependents + + Done such that we can run tests on all dependents of a package when a change is made. + """ + dependents = defaultdict(set) + + for path in glob.glob("./libs/**/pyproject.toml", recursive=True): + if "template" in path: + continue + + # load regular and test deps from pyproject.toml + with open(path, "rb") as f: + pyproject = tomllib.load(f) + + pkg_dir = "libs" + "/".join(path.split("libs")[1].split("/")[:-1]) + for dep in [ + *pyproject["project"]["dependencies"], + *pyproject["dependency-groups"]["test"], + ]: + requirement = Requirement(dep) + package_name = requirement.name + if "langchain" in dep: + dependents[package_name].add(pkg_dir) + continue + + # load extended deps from extended_testing_deps.txt + package_path = Path(path).parent + extended_requirement_path = package_path / "extended_testing_deps.txt" + if extended_requirement_path.exists(): + with open(extended_requirement_path, "r") as f: + extended_deps = f.read().splitlines() + for depline in extended_deps: + if depline.startswith("-e "): + # editable dependency + assert depline.startswith("-e ../partners/"), ( + "Extended test deps should only editable install partner packages" + ) + partner = depline.split("partners/")[1] + dep = f"langchain-{partner}" + else: + dep = depline.split("==")[0] + + if "langchain" in dep: + dependents[dep].add(pkg_dir) + + for k in dependents: + for partner in IGNORED_PARTNERS: + if f"libs/partners/{partner}" in dependents[k]: + dependents[k].remove(f"libs/partners/{partner}") + return dependents + + +def add_dependents(dirs_to_eval: Set[str], dependents: dict) -> List[str]: + updated = set() + for dir_ in dirs_to_eval: + # handle core manually because it has so many dependents + if "core" in dir_: + updated.add(dir_) + continue + pkg = "langchain-" + dir_.split("/")[-1] + updated.update(dependents[pkg]) + updated.add(dir_) + return list(updated) + + +def _get_configs_for_single_dir(job: str, dir_: str) -> List[Dict[str, str]]: + if job == "test-pydantic": + return _get_pydantic_test_configs(dir_) + + if job == "codspeed": + # CPU simulation (<1% variance, Valgrind-based) is the default. + # Partners with heavy SDK inits use walltime instead to keep CI fast. + CODSPEED_WALLTIME_DIRS = { + "libs/core", + "libs/partners/fireworks", # ~328s under simulation + "libs/partners/openai", # 6 benchmarks, ~6 min under simulation + } + mode = "walltime" if dir_ in CODSPEED_WALLTIME_DIRS else "simulation" + return [ + { + "working-directory": dir_, + "python-version": "3.13", + "codspeed-mode": mode, + } + ] + if dir_ == "libs/core": + py_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"] + else: + py_versions = ["3.10", "3.14"] + + return [{"working-directory": dir_, "python-version": py_v} for py_v in py_versions] + + +def _get_pydantic_test_configs( + dir_: str, *, python_version: str = "3.12" +) -> List[Dict[str, str]]: + with open("./libs/core/uv.lock", "rb") as f: + core_uv_lock_data = tomllib.load(f) + for package in core_uv_lock_data["package"]: + if package["name"] == "pydantic": + core_max_pydantic_minor = package["version"].split(".")[1] + break + + with open(f"./{dir_}/uv.lock", "rb") as f: + dir_uv_lock_data = tomllib.load(f) + + for package in dir_uv_lock_data["package"]: + if package["name"] == "pydantic": + dir_max_pydantic_minor = package["version"].split(".")[1] + break + + core_min_pydantic_version = get_min_version_from_toml( + "./libs/core/pyproject.toml", "release", python_version, include=["pydantic"] + )["pydantic"] + core_min_pydantic_minor = ( + core_min_pydantic_version.split(".")[1] + if "." in core_min_pydantic_version + else "0" + ) + dir_min_pydantic_version = get_min_version_from_toml( + f"./{dir_}/pyproject.toml", "release", python_version, include=["pydantic"] + ).get("pydantic", "0.0.0") + dir_min_pydantic_minor = ( + dir_min_pydantic_version.split(".")[1] + if "." in dir_min_pydantic_version + else "0" + ) + + max_pydantic_minor = min( + int(dir_max_pydantic_minor), + int(core_max_pydantic_minor), + ) + min_pydantic_minor = max( + int(dir_min_pydantic_minor), + int(core_min_pydantic_minor), + ) + + configs = [ + { + "working-directory": dir_, + "pydantic-version": f"2.{v}.0", + "python-version": python_version, + } + for v in range(min_pydantic_minor, max_pydantic_minor + 1) + ] + return configs + + +def _get_configs_for_multi_dirs( + job: str, dirs_to_run: Dict[str, Set[str]], dependents: dict +) -> List[Dict[str, str]]: + if job == "lint": + dirs = add_dependents( + dirs_to_run["lint"] | dirs_to_run["test"] | dirs_to_run["extended-test"], + dependents, + ) + elif job in ["test", "compile-integration-tests", "dependencies", "test-pydantic"]: + dirs = add_dependents( + dirs_to_run["test"] | dirs_to_run["extended-test"], dependents + ) + elif job == "extended-tests": + dirs = list(dirs_to_run["extended-test"]) + elif job == "codspeed": + dirs = list(dirs_to_run["codspeed"]) + elif job == "vcr-tests": + # Only run VCR tests for packages that have cassettes and are affected + all_affected = set( + add_dependents( + dirs_to_run["test"] | dirs_to_run["extended-test"], dependents + ) + ) + dirs = [d for d in VCR_PACKAGES if d in all_affected] + else: + raise ValueError(f"Unknown job: {job}") + + return [ + config for dir_ in dirs for config in _get_configs_for_single_dir(job, dir_) + ] + + +def _get_changed_files(args: list[str]) -> list[str]: + """Parse changed files from command-line arguments. + + Args: + args: Either a legacy list of filename arguments or a single JSON array + produced by `Ana06/get-changed-files` with `format: json`. + + Returns: + List of changed files. + + Raises: + ValueError: If a single argument looks like JSON but is not a string array. + """ + if len(args) != 1: + return args + + value = args[0].strip() + if not value.startswith("[") or not value.endswith("]"): + return args + + try: + parsed = json.loads(value) + except json.JSONDecodeError as e: + msg = "Expected changed files JSON to be a list of strings." + raise ValueError(msg) from e + + if not isinstance(parsed, list) or not all( + isinstance(file, str) for file in parsed + ): + msg = "Expected changed files JSON to be a list of strings." + raise ValueError(msg) + return parsed + + +if __name__ == "__main__": + files = _get_changed_files(sys.argv[1:]) + + dirs_to_run: Dict[str, set] = { + "lint": set(), + "test": set(), + "extended-test": set(), + "codspeed": set(), + } + docs_edited = False + + if len(files) >= 300: + # max diff length is 300 files - there are likely files missing + dirs_to_run["lint"] = all_package_dirs() + dirs_to_run["test"] = all_package_dirs() + dirs_to_run["extended-test"] = set(LANGCHAIN_DIRS) + + for file in files: + if any( + file.startswith(dir_) + for dir_ in ( + ".github/workflows", + ".github/tools", + ".github/actions", + ".github/scripts/check_diff.py", + ) + ): + # Infrastructure changes (workflows, actions, CI scripts) trigger tests on + # all core packages as a safety measure. This ensures that changes to CI/CD + # infrastructure don't inadvertently break package testing, even if the change + # appears unrelated (e.g., documentation build workflows). This is intentionally + # conservative to catch unexpected side effects from workflow modifications. + # + # Example: A PR modifying .github/workflows/api_doc_build.yml will trigger + # lint/test jobs for libs/core, libs/text-splitters, libs/langchain, and + # libs/langchain_v1, even though the workflow may only affect documentation. + dirs_to_run["extended-test"].update(LANGCHAIN_DIRS) + + if file.startswith("libs/core"): + dirs_to_run["codspeed"].add("libs/core") + if file.startswith("libs/langchain_v1"): + dirs_to_run["codspeed"].add("libs/langchain_v1") + if any(file.startswith(dir_) for dir_ in LANGCHAIN_DIRS): + # add that dir and all dirs after in LANGCHAIN_DIRS + # for extended testing + + found = False + for dir_ in LANGCHAIN_DIRS: + if dir_ == "libs/core" and IGNORE_CORE_DEPENDENTS: + dirs_to_run["extended-test"].add(dir_) + continue + if file.startswith(dir_): + found = True + if found: + dirs_to_run["extended-test"].add(dir_) + elif file.startswith("libs/standard-tests"): + # TODO: update to include all packages that rely on standard-tests (all partner packages) + # Note: won't run on external repo partners + dirs_to_run["lint"].add("libs/standard-tests") + dirs_to_run["test"].add("libs/standard-tests") + dirs_to_run["test"].add("libs/partners/mistralai") + dirs_to_run["test"].add("libs/partners/openai") + dirs_to_run["test"].add("libs/partners/anthropic") + dirs_to_run["test"].add("libs/partners/fireworks") + dirs_to_run["test"].add("libs/partners/groq") + + elif file.startswith("libs/partners"): + partner_dir = file.split("/")[2] + if os.path.isdir(f"libs/partners/{partner_dir}") and [ + filename + for filename in os.listdir(f"libs/partners/{partner_dir}") + if not filename.startswith(".") + ] != ["README.md"]: + dirs_to_run["test"].add(f"libs/partners/{partner_dir}") + # Only add to codspeed if the partner has benchmarks and is not ignored + if ( + partner_dir not in IGNORED_PARTNERS + and os.path.isdir( + f"libs/partners/{partner_dir}/tests/benchmarks" + ) + ): + dirs_to_run["codspeed"].add(f"libs/partners/{partner_dir}") + # Skip if the directory was deleted or is just a tombstone readme + elif file.startswith("libs/"): + # Check if this is a root-level file in libs/ (e.g., libs/README.md) + file_parts = file.split("/") + if len(file_parts) == 2: + # Root-level file in libs/, skip it (no tests needed) + continue + raise ValueError( + f"Unknown lib: {file}. check_diff.py likely needs " + "an update for this new library!" + ) + elif file in [ + "pyproject.toml", + "uv.lock", + ]: # root uv files + docs_edited = True + + dependents = dependents_graph() + + # we now have dirs_by_job + # todo: clean this up + map_job_to_configs = { + job: _get_configs_for_multi_dirs(job, dirs_to_run, dependents) + for job in [ + "lint", + "test", + "extended-tests", + "compile-integration-tests", + "dependencies", + "test-pydantic", + "codspeed", + "vcr-tests", + ] + } + + for key, value in map_job_to_configs.items(): + json_output = json.dumps(value) + print(f"{key}={json_output}") diff --git a/.github/scripts/check_extras_sync.py b/.github/scripts/check_extras_sync.py new file mode 100644 index 0000000..8d2c4bd --- /dev/null +++ b/.github/scripts/check_extras_sync.py @@ -0,0 +1,118 @@ +"""Check that optional extras stay in sync with required dependencies. + +When a package appears in both [project.dependencies] and +[project.optional-dependencies], we ensure their version constraints match. +This prevents silent version drift (e.g. bumping a required dep but +forgetting the corresponding extra). +""" + +import sys +import tomllib +from pathlib import Path +from re import compile as re_compile + +# Matches the package name at the start of a PEP 508 dependency string. +# Stops at the first non-name character; downstream code is responsible for +# stripping extras (`[...]`) and env markers (`; ...`) from the remainder. +_NAME_RE = re_compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)") + + +def _normalize(name: str) -> str: + """Normalize a package name for equality comparison. + + Lowercases and maps `-` and `.` to `_`. Looser than PEP 503 + (which uses `-` and collapses runs), but sufficient for matching the + same package across two PEP 508 strings. + + Returns: + Lowercased, underscore-normalized package name. + """ + return name.lower().replace("-", "_").replace(".", "_") + + +def _parse_dep(dep: str) -> tuple[str, str]: + """Return `(normalized_name, version_spec)` from a PEP 508 string. + + Strips extras (`pkg[async]`), environment markers (`; python_version ...`), + URL specifiers (`pkg @ git+...`), and whitespace so the returned + `version_spec` is directly comparable between a required and optional dep. + + Returns: + Tuple of normalized package name and bare version specifier. + + Raises: + ValueError: If the dependency string cannot be parsed. + """ + match = _NAME_RE.match(dep) + if not match: + msg = f"Cannot parse dependency: {dep!r}" + raise ValueError(msg) + name = match.group(1) + rest = dep[match.end() :].strip() + + if rest.startswith("["): + close = rest.find("]") + if close == -1: + msg = f"Unclosed extras bracket in dependency: {dep!r}" + raise ValueError(msg) + rest = rest[close + 1 :].strip() + + if ";" in rest: + rest = rest.split(";", 1)[0].strip() + + # URL specifiers have no comparable version; treat as unconstrained. + if rest.startswith("@"): + rest = "" + + rest = " ".join(rest.split()) + return _normalize(name), rest + + +def main(pyproject_path: Path) -> int: + """Check extras sync and return `0` on pass, `1` on mismatch or parse error.""" + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + + required: dict[str, str] = {} + for dep in data.get("project", {}).get("dependencies", []): + try: + name, spec = _parse_dep(dep) + except ValueError as e: + print(f"::error file={pyproject_path}::{e}") + return 1 + required[name] = spec + + optional = data.get("project", {}).get("optional-dependencies", {}) + if not optional: + return 0 + + mismatches: list[str] = [] + for group, deps in optional.items(): + for dep in deps: + try: + name, spec = _parse_dep(dep) + except ValueError as e: + print(f"::error file={pyproject_path}::{e}") + return 1 + if name in required and spec != required[name]: + mismatches.append( + f" [{group}] {name}: extra has '{spec}' " + f"but required dep has '{required[name]}'" + ) + + if mismatches: + print(f"Extra / required dependency version mismatch in {pyproject_path}:") + print("\n".join(mismatches)) + print( + "\nUpdate the optional extras in [project.optional-dependencies] " + "to match [project.dependencies]." + ) + return 1 + + print(f"All extras in {pyproject_path} are in sync with required dependencies.") + return 0 + + +if __name__ == "__main__": + path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("pyproject.toml") + raise SystemExit(main(path)) diff --git a/.github/scripts/check_prerelease_dependencies.py b/.github/scripts/check_prerelease_dependencies.py new file mode 100644 index 0000000..f445ede --- /dev/null +++ b/.github/scripts/check_prerelease_dependencies.py @@ -0,0 +1,36 @@ +"""Check that no dependencies allow prereleases unless we're releasing a prerelease.""" + +import sys + +import tomllib + +if __name__ == "__main__": + # Get the TOML file path from the command line argument + toml_file = sys.argv[1] + + with open(toml_file, "rb") as file: + toml_data = tomllib.load(file) + + # See if we're releasing an rc or dev version + version = toml_data["project"]["version"] + releasing_rc = "rc" in version or "dev" in version + + # If not, iterate through dependencies and make sure none allow prereleases + if not releasing_rc: + dependencies = toml_data["project"]["dependencies"] + for dep_version in dependencies: + dep_version_string = ( + dep_version["version"] if isinstance(dep_version, dict) else dep_version + ) + + if "rc" in dep_version_string: + raise ValueError( + f"Dependency {dep_version} has a prerelease version. Please remove this." + ) + + if isinstance(dep_version, dict) and dep_version.get( + "allow-prereleases", False + ): + raise ValueError( + f"Dependency {dep_version} has allow-prereleases set to true. Please remove this." + ) diff --git a/.github/scripts/get_min_versions.py b/.github/scripts/get_min_versions.py new file mode 100644 index 0000000..acdfa4a --- /dev/null +++ b/.github/scripts/get_min_versions.py @@ -0,0 +1,215 @@ +"""Get minimum versions of dependencies from a pyproject.toml file.""" + +import sys +from collections import defaultdict + +if sys.version_info >= (3, 11): + import tomllib +else: + # For Python 3.10 and below, which doesnt have stdlib tomllib + import tomli as tomllib + +import re +from typing import List + +import requests +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version, parse + +MIN_VERSION_LIBS = [ + "langchain-core", + "langchain", + "langchain-text-splitters", + "numpy", + "SQLAlchemy", +] + +# some libs only get checked on release because of simultaneous changes in +# multiple libs +SKIP_IF_PULL_REQUEST = [ + "langchain-core", + "langchain-text-splitters", + "langchain", +] + + +def get_pypi_versions(package_name: str) -> List[str]: + """Fetch all available versions for a package from PyPI. + + Args: + package_name: Name of the package + + Returns: + List of all available versions + + Raises: + requests.exceptions.RequestException: If PyPI API request fails + KeyError: If package not found or response format unexpected + """ + pypi_url = f"https://pypi.org/pypi/{package_name}/json" + response = requests.get(pypi_url, timeout=10.0) + response.raise_for_status() + return list(response.json()["releases"].keys()) + + +def get_minimum_version(package_name: str, spec_string: str) -> str | None: + """Find the minimum published version that satisfies the given constraints. + + Args: + package_name: Name of the package + spec_string: Version specification string (e.g., ">=0.2.43,<0.4.0,!=0.3.0") + + Returns: + Minimum compatible version or None if no compatible version found + """ + # Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string) + spec_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", spec_string) + # Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1 (can be anywhere in constraint string) + for y in range(1, 10): + spec_string = re.sub( + rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}", spec_string + ) + # Rewrite occurrences of ^x.y.z to >=x.y.z,={x}.\1.\2,<{x + 1}", spec_string + ) + + spec_set = SpecifierSet(spec_string) + all_versions = get_pypi_versions(package_name) + + valid_versions = [] + for version_str in all_versions: + try: + version = parse(version_str) + if spec_set.contains(version): + valid_versions.append(version) + except ValueError: + continue + + return str(min(valid_versions)) if valid_versions else None + + +def _check_python_version_from_requirement( + requirement: Requirement, python_version: str +) -> bool: + if not requirement.marker: + return True + else: + marker_str = str(requirement.marker) + if "python_version" in marker_str or "python_full_version" in marker_str: + python_version_str = "".join( + char + for char in marker_str + if char.isdigit() or char in (".", "<", ">", "=", ",") + ) + return check_python_version(python_version, python_version_str) + return True + + +def get_min_version_from_toml( + toml_path: str, + versions_for: str, + python_version: str, + *, + include: list | None = None, +): + # Parse the TOML file + with open(toml_path, "rb") as file: + toml_data = tomllib.load(file) + + dependencies = defaultdict(list) + for dep in toml_data["project"]["dependencies"]: + requirement = Requirement(dep) + dependencies[requirement.name].append(requirement) + + # Initialize a dictionary to store the minimum versions + min_versions = {} + + # Iterate over the libs in MIN_VERSION_LIBS + for lib in set(MIN_VERSION_LIBS + (include or [])): + if versions_for == "pull_request" and lib in SKIP_IF_PULL_REQUEST: + # some libs only get checked on release because of simultaneous + # changes in multiple libs + continue + # Check if the lib is present in the dependencies + if lib in dependencies: + if include and lib not in include: + continue + requirements = dependencies[lib] + for requirement in requirements: + if _check_python_version_from_requirement(requirement, python_version): + version_string = str(requirement.specifier) + break + + # Use parse_version to get the minimum supported version from version_string + min_version = get_minimum_version(lib, version_string) + + # Store the minimum version in the min_versions dictionary + min_versions[lib] = min_version + + return min_versions + + +def check_python_version(version_string, constraint_string): + """Check if the given Python version matches the given constraints. + + Args: + version_string: A string representing the Python version (e.g. "3.8.5"). + constraint_string: A string representing the package's Python version + constraints (e.g. ">=3.6, <4.0"). + + Returns: + True if the version matches the constraints + """ + + # Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string) + constraint_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", constraint_string) + # Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1.0 (can be anywhere in constraint string) + for y in range(1, 10): + constraint_string = re.sub( + rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}.0", constraint_string + ) + # Rewrite occurrences of ^x.y.z to >=x.y.z,={x}.0.\1,<{x + 1}.0.0", constraint_string + ) + + try: + version = Version(version_string) + constraints = SpecifierSet(constraint_string) + return version in constraints + except Exception as e: + print(f"Error: {e}") + return False + + +if __name__ == "__main__": + # Get the TOML file path from the command line argument + toml_file = sys.argv[1] + versions_for = sys.argv[2] + python_version = sys.argv[3] + assert versions_for in ["release", "pull_request"] + + # Call the function to get the minimum versions + min_versions = get_min_version_from_toml(toml_file, versions_for, python_version) + + # A `None` value means no *published* version on PyPI satisfies the declared + # constraint, e.g. a `release(...)` PR bumped a minimum pin to a version that + # has not shipped yet. Emitting `pkg==None` would be passed verbatim to + # `uv pip install` in the release workflow's minimum-version test step, + # producing a cryptic install failure, so fail loudly here instead. + unresolved = [lib for lib, version in min_versions.items() if version is None] + if unresolved: + print( + "ERROR: no published version on PyPI satisfies the declared constraint " + f"for: {', '.join(sorted(unresolved))}. A release likely pinned a " + "dependency to a version that is not yet published. Release the " + "dependency first, or relax the pin.", + file=sys.stderr, + ) + sys.exit(1) + + print(" ".join([f"{lib}=={version}" for lib, version in min_versions.items()])) diff --git a/.github/scripts/pr-labeler-config.json b/.github/scripts/pr-labeler-config.json new file mode 100644 index 0000000..71f67a3 --- /dev/null +++ b/.github/scripts/pr-labeler-config.json @@ -0,0 +1,84 @@ +{ + "trustedThreshold": 5, + "labelColor": "b76e79", + "sizeThresholds": [ + { "label": "size: XS", "max": 50 }, + { "label": "size: S", "max": 200 }, + { "label": "size: M", "max": 500 }, + { "label": "size: L", "max": 1000 }, + { "label": "size: XL" } + ], + "excludedFiles": ["uv.lock"], + "excludedPaths": ["docs/"], + "typeToLabel": { + "feat": "feature", + "fix": "fix", + "docs": "documentation", + "style": "linting", + "refactor": "refactor", + "perf": "performance", + "test": "tests", + "build": "infra", + "ci": "infra", + "chore": "infra", + "revert": "revert", + "release": "release", + "hotfix": "hotfix", + "breaking": "breaking" + }, + "scopeToLabel": { + "core": "core", + "langchain": "langchain", + "langchain-classic": "langchain-classic", + "model-profiles": "model-profiles", + "standard-tests": "standard-tests", + "text-splitters": "text-splitters", + "anthropic": "anthropic", + "chroma": "chroma", + "deepseek": "deepseek", + "exa": "exa", + "fireworks": "fireworks", + "groq": "groq", + "huggingface": "huggingface", + "mistralai": "mistralai", + "nomic": "nomic", + "ollama": "ollama", + "openai": "openai", + "openrouter": "openrouter", + "perplexity": "perplexity", + "qdrant": "qdrant", + "xai": "xai", + "deps": "dependencies", + "docs": "documentation", + "infra": "infra" + }, + "fileRules": [ + { "label": "core", "prefix": "libs/core/", "skipExcludedFiles": true }, + { "label": "langchain-classic", "prefix": "libs/langchain/", "skipExcludedFiles": true }, + { "label": "langchain", "prefix": "libs/langchain_v1/", "skipExcludedFiles": true }, + { "label": "standard-tests", "prefix": "libs/standard-tests/", "skipExcludedFiles": true }, + { "label": "model-profiles", "prefix": "libs/model-profiles/", "skipExcludedFiles": true }, + { "label": "text-splitters", "prefix": "libs/text-splitters/", "skipExcludedFiles": true }, + { "label": "integration", "prefix": "libs/partners/", "skipExcludedFiles": true }, + { "label": "anthropic", "prefix": "libs/partners/anthropic/", "skipExcludedFiles": true }, + { "label": "chroma", "prefix": "libs/partners/chroma/", "skipExcludedFiles": true }, + { "label": "deepseek", "prefix": "libs/partners/deepseek/", "skipExcludedFiles": true }, + { "label": "exa", "prefix": "libs/partners/exa/", "skipExcludedFiles": true }, + { "label": "fireworks", "prefix": "libs/partners/fireworks/", "skipExcludedFiles": true }, + { "label": "groq", "prefix": "libs/partners/groq/", "skipExcludedFiles": true }, + { "label": "huggingface", "prefix": "libs/partners/huggingface/", "skipExcludedFiles": true }, + { "label": "mistralai", "prefix": "libs/partners/mistralai/", "skipExcludedFiles": true }, + { "label": "nomic", "prefix": "libs/partners/nomic/", "skipExcludedFiles": true }, + { "label": "ollama", "prefix": "libs/partners/ollama/", "skipExcludedFiles": true }, + { "label": "openai", "prefix": "libs/partners/openai/", "skipExcludedFiles": true }, + { "label": "openrouter", "prefix": "libs/partners/openrouter/", "skipExcludedFiles": true }, + { "label": "perplexity", "prefix": "libs/partners/perplexity/", "skipExcludedFiles": true }, + { "label": "qdrant", "prefix": "libs/partners/qdrant/", "skipExcludedFiles": true }, + { "label": "xai", "prefix": "libs/partners/xai/", "skipExcludedFiles": true }, + { "label": "github_actions", "prefix": ".github/workflows/" }, + { "label": "github_actions", "prefix": ".github/actions/" }, + { "label": "dependencies", "suffix": "pyproject.toml" }, + { "label": "dependencies", "exact": "uv.lock" }, + { "label": "dependencies", "pattern": "(?:^|/)requirements[^/]*\\.txt$" } + ] +} diff --git a/.github/scripts/pr-labeler.js b/.github/scripts/pr-labeler.js new file mode 100644 index 0000000..f2f252d --- /dev/null +++ b/.github/scripts/pr-labeler.js @@ -0,0 +1,278 @@ +// Shared helpers for pr_labeler.yml and tag-external-issues.yml. +// +// Usage from actions/github-script (requires actions/checkout first): +// const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + +const fs = require('fs'); +const path = require('path'); + +function loadConfig() { + const configPath = path.join(__dirname, 'pr-labeler-config.json'); + let raw; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch (e) { + throw new Error(`Failed to read ${configPath}: ${e.message}`); + } + let config; + try { + config = JSON.parse(raw); + } catch (e) { + throw new Error(`Failed to parse pr-labeler-config.json: ${e.message}`); + } + const required = [ + 'labelColor', 'sizeThresholds', 'fileRules', + 'typeToLabel', 'scopeToLabel', 'trustedThreshold', + 'excludedFiles', 'excludedPaths', + ]; + const missing = required.filter(k => !(k in config)); + if (missing.length > 0) { + throw new Error(`pr-labeler-config.json missing required keys: ${missing.join(', ')}`); + } + return config; +} + +function init(github, owner, repo, config, core) { + if (!core) { + throw new Error('init() requires a `core` parameter (e.g., from actions/github-script)'); + } + const { + trustedThreshold, + labelColor, + sizeThresholds, + scopeToLabel, + typeToLabel, + fileRules: fileRulesDef, + excludedFiles, + excludedPaths, + } = config; + + const sizeLabels = sizeThresholds.map(t => t.label); + const allTypeLabels = [...new Set(Object.values(typeToLabel))]; + const tierLabels = ['new-contributor', 'trusted-contributor']; + + // ── Label management ────────────────────────────────────────────── + + async function ensureLabel(name, color = labelColor) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name, color }); + } catch (createErr) { + // 422 = label created by a concurrent run between our get and create + if (createErr.status !== 422) throw createErr; + core.info(`Label "${name}" creation returned 422 (likely already exists)`); + } + } + } + + // ── Size calculation ────────────────────────────────────────────── + + function getSizeLabel(totalChanged) { + for (const t of sizeThresholds) { + if (t.max != null && totalChanged < t.max) return t.label; + } + // Last entry has no max — it's the catch-all + return sizeThresholds[sizeThresholds.length - 1].label; + } + + function computeSize(files) { + const excluded = new Set(excludedFiles); + const totalChanged = files.reduce((sum, f) => { + const p = f.filename ?? ''; + const base = p.split('/').pop(); + if (excluded.has(base)) return sum; + for (const prefix of excludedPaths) { + if (p.startsWith(prefix)) return sum; + } + return sum + (f.additions ?? 0) + (f.deletions ?? 0); + }, 0); + return { totalChanged, sizeLabel: getSizeLabel(totalChanged) }; + } + + // ── File-based labels ───────────────────────────────────────────── + + function buildFileRules() { + return fileRulesDef.map((rule, i) => { + let test; + if (rule.prefix) test = p => p.startsWith(rule.prefix); + else if (rule.suffix) test = p => p.endsWith(rule.suffix); + else if (rule.exact) test = p => p === rule.exact; + else if (rule.pattern) { + const re = new RegExp(rule.pattern); + test = p => re.test(p); + } else { + throw new Error( + `fileRules[${i}] (label: "${rule.label}") has no recognized matcher ` + + `(expected one of: prefix, suffix, exact, pattern)` + ); + } + return { label: rule.label, test, skipExcluded: !!rule.skipExcludedFiles }; + }); + } + + function matchFileLabels(files, fileRules) { + const rules = fileRules || buildFileRules(); + const excluded = new Set(excludedFiles); + const labels = new Set(); + for (const rule of rules) { + // skipExcluded: ignore files whose basename is in the top-level + // "excludedFiles" list (e.g. uv.lock) so lockfile-only changes + // don't trigger package labels. + const candidates = rule.skipExcluded + ? files.filter(f => !excluded.has((f.filename ?? '').split('/').pop())) + : files; + if (candidates.some(f => rule.test(f.filename ?? ''))) { + labels.add(rule.label); + } + } + return labels; + } + + // ── Title-based labels ──────────────────────────────────────────── + + function matchTitleLabels(title) { + const labels = new Set(); + const m = (title ?? '').match(/^(\w+)(?:\(([^)]+)\))?(!)?:/); + if (!m) return { labels, type: null, typeLabel: null, scopes: [], breaking: false }; + + const type = m[1].toLowerCase(); + const scopeStr = m[2] ?? ''; + const breaking = !!m[3]; + + const typeLabel = typeToLabel[type] || null; + if (typeLabel) labels.add(typeLabel); + if (breaking) labels.add('breaking'); + + const scopes = scopeStr.split(',').map(s => s.trim()).filter(Boolean); + for (const scope of scopes) { + const sl = scopeToLabel[scope]; + if (sl) labels.add(sl); + } + + return { labels, type, typeLabel, scopes, breaking }; + } + + // ── Org membership ──────────────────────────────────────────────── + + async function checkMembership(author, userType) { + if (userType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + return { isExternal: false }; + } + + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + return { isExternal }; + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + return { isExternal: true }; + } + // Non-404 errors (rate limit, auth failure, server error) must not + // silently default to external — rethrow to fail the step. + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + // ── Contributor analysis ────────────────────────────────────────── + + async function getContributorInfo(contributorCache, author, userType) { + if (contributorCache.has(author)) return contributorCache.get(author); + + const { isExternal } = await checkMembership(author, userType); + + let mergedCount = null; + if (isExternal) { + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count ?? null; + } catch (e) { + if (e?.status !== 422) throw e; + core.warning(`Search failed for ${author}; skipping tier.`); + } + } + + const info = { isExternal, mergedCount }; + contributorCache.set(author, info); + return info; + } + + // ── Tier label resolution ─────────────────────────────────────────── + + async function applyTierLabel(issueNumber, author, { skipNewContributor = false } = {}) { + let mergedCount; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count; + } catch (error) { + if (error?.status !== 422) throw error; + core.warning(`Search failed for ${author}; skipping tier label.`); + return; + } + + if (mergedCount == null) { + core.warning(`Search response missing total_count for ${author}; skipping tier label.`); + return; + } + + let tierLabel = null; + if (mergedCount >= trustedThreshold) tierLabel = 'trusted-contributor'; + else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor'; + + if (tierLabel) { + await ensureLabel(tierLabel); + await github.rest.issues.addLabels({ + owner, repo, issue_number: issueNumber, labels: [tierLabel], + }); + console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`); + } else { + console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); + } + + return tierLabel; + } + + return { + ensureLabel, + getSizeLabel, + computeSize, + buildFileRules, + matchFileLabels, + matchTitleLabels, + allTypeLabels, + checkMembership, + getContributorInfo, + applyTierLabel, + sizeLabels, + tierLabels, + trustedThreshold, + labelColor, + }; +} + +function loadAndInit(github, owner, repo, core) { + const config = loadConfig(); + return { config, h: init(github, owner, repo, config, core) }; +} + +module.exports = { loadConfig, init, loadAndInit }; diff --git a/.github/scripts/test_release_options.py b/.github/scripts/test_release_options.py new file mode 100644 index 0000000..47cc424 --- /dev/null +++ b/.github/scripts/test_release_options.py @@ -0,0 +1,66 @@ +"""Verify _release.yml dropdown options match actual package directories. + +Dropdown options are short names (e.g. `openai`, `core`). The workflow's +`EFFECTIVE_WORKING_DIR` expression re-adds the `libs/` prefix for top-level +packages and `libs/partners/` for everything else. This test reconstructs the +full path for each short name and compares against packages on disk. +""" + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Keep in sync with the non-partner allowlist in `EFFECTIVE_WORKING_DIR` +# in `.github/workflows/_release.yml`. +TOP_LEVEL_PACKAGES = frozenset( + {"core", "langchain", "langchain_v1", "text-splitters", "standard-tests", "model-profiles"} +) + + +def _get_release_options() -> list[str]: + workflow = REPO_ROOT / ".github" / "workflows" / "_release.yml" + with open(workflow) as f: + data = yaml.safe_load(f) + try: + # PyYAML (YAML 1.1) parses the bare key `on` as boolean True + return data[True]["workflow_dispatch"]["inputs"]["working-directory"]["options"] + except (KeyError, TypeError) as e: + msg = f"Could not find workflow_dispatch options in {workflow}: {e}" + raise AssertionError(msg) from e + + +def _expand_option(option: str) -> str: + if option in TOP_LEVEL_PACKAGES: + return f"libs/{option}" + return f"libs/partners/{option}" + + +def _get_package_dirs() -> set[str]: + libs = REPO_ROOT / "libs" + dirs: set[str] = set() + # Top-level packages (libs/core, libs/langchain, etc.) + for p in libs.iterdir(): + if p.is_dir() and (p / "pyproject.toml").exists(): + dirs.add(f"libs/{p.name}") + # Partner packages (libs/partners/*) + partners = libs / "partners" + if partners.exists(): + for p in partners.iterdir(): + if p.is_dir() and (p / "pyproject.toml").exists(): + dirs.add(f"libs/partners/{p.name}") + return dirs + + +def test_release_options_match_packages() -> None: + options = {_expand_option(o) for o in _get_release_options()} + packages = _get_package_dirs() + missing_from_dropdown = packages - options + extra_in_dropdown = options - packages + assert not missing_from_dropdown, ( + f"Packages on disk missing from _release.yml dropdown: {missing_from_dropdown}" + ) + assert not extra_in_dropdown, ( + f"Dropdown options with no matching package directory: {extra_in_dropdown}" + ) diff --git a/.github/tools/git-restore-mtime b/.github/tools/git-restore-mtime new file mode 100755 index 0000000..05c25ed --- /dev/null +++ b/.github/tools/git-restore-mtime @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 +# +# git-restore-mtime - Change mtime of files based on commit date of last change +# +# Copyright (C) 2012 Rodrigo Silva (MestreLion) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. See +# +# Source: https://github.com/MestreLion/git-tools +# Version: July 13, 2023 (commit hash 5f832e72453e035fccae9d63a5056918d64476a2) +""" +Change the modification time (mtime) of files in work tree, based on the +date of the most recent commit that modified the file, including renames. + +Ignores untracked files and uncommitted deletions, additions and renames, and +by default modifications too. +--- +Useful prior to generating release tarballs, so each file is archived with a +date that is similar to the date when the file was actually last modified, +assuming the actual modification date and its commit date are close. +""" + +# TODO: +# - Add -z on git whatchanged/ls-files, so we don't deal with filename decoding +# - When Python is bumped to 3.7, use text instead of universal_newlines on subprocess +# - Update "Statistics for some large projects" with modern hardware and repositories. +# - Create a README.md for git-restore-mtime alone. It deserves extensive documentation +# - Move Statistics there +# - See git-extras as a good example on project structure and documentation + +# FIXME: +# - When current dir is outside the worktree, e.g. using --work-tree, `git ls-files` +# assume any relative pathspecs are to worktree root, not the current dir. As such, +# relative pathspecs may not work. +# - Renames are tricky: +# - R100 should not change mtime, but original name is not on filelist. Should +# track renames until a valid (A, M) mtime found and then set on current name. +# - Should set mtime for both current and original directories. +# - Check mode changes with unchanged blobs? +# - Check file (A, D) for the directory mtime is not sufficient: +# - Renames also change dir mtime, unless rename was on a parent dir +# - If most recent change of all files in a dir was a Modification (M), +# dir might not be touched at all. +# - Dirs containing only subdirectories but no direct files will also +# not be touched. They're files' [grand]parent dir, but never their dirname(). +# - Some solutions: +# - After files done, perform some dir processing for missing dirs, finding latest +# file (A, D, R) +# - Simple approach: dir mtime is the most recent child (dir or file) mtime +# - Use a virtual concept of "created at most at" to fill missing info, bubble up +# to parents and grandparents +# - When handling [grand]parent dirs, stay inside +# - Better handling of merge commits. `-m` is plain *wrong*. `-c/--cc` is perfect, but +# painfully slow. First pass without merge commits is not accurate. Maybe add a new +# `--accurate` mode for `--cc`? + +if __name__ != "__main__": + raise ImportError("{} should not be used as a module.".format(__name__)) + +import argparse +import datetime +import logging +import os.path +import shlex +import signal +import subprocess +import sys +import time + +__version__ = "2022.12+dev" + +# Update symlinks only if the platform supports not following them +UPDATE_SYMLINKS = bool(os.utime in getattr(os, "supports_follow_symlinks", [])) + +# Call os.path.normpath() only if not in a POSIX platform (Windows) +NORMALIZE_PATHS = os.path.sep != "/" + +# How many files to process in each batch when re-trying merge commits +STEPMISSING = 100 + +# (Extra) keywords for the os.utime() call performed by touch() +UTIME_KWS = {} if not UPDATE_SYMLINKS else {"follow_symlinks": False} + + +# Command-line interface ###################################################### + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__.split("\n---")[0]) + + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--quiet", + "-q", + dest="loglevel", + action="store_const", + const=logging.WARNING, + default=logging.INFO, + help="Suppress informative messages and summary statistics.", + ) + group.add_argument( + "--verbose", + "-v", + action="count", + help=""" + Print additional information for each processed file. + Specify twice to further increase verbosity. + """, + ) + + parser.add_argument( + "--cwd", + "-C", + metavar="DIRECTORY", + help=""" + Run as if %(prog)s was started in directory %(metavar)s. + This affects how --work-tree, --git-dir and PATHSPEC arguments are handled. + See 'man 1 git' or 'git --help' for more information. + """, + ) + + parser.add_argument( + "--git-dir", + dest="gitdir", + metavar="GITDIR", + help=""" + Path to the git repository, by default auto-discovered by searching + the current directory and its parents for a .git/ subdirectory. + """, + ) + + parser.add_argument( + "--work-tree", + dest="workdir", + metavar="WORKTREE", + help=""" + Path to the work tree root, by default the parent of GITDIR if it's + automatically discovered, or the current directory if GITDIR is set. + """, + ) + + parser.add_argument( + "--force", + "-f", + default=False, + action="store_true", + help=""" + Force updating files with uncommitted modifications. + Untracked files and uncommitted deletions, renames and additions are + always ignored. + """, + ) + + parser.add_argument( + "--merge", + "-m", + default=False, + action="store_true", + help=""" + Include merge commits. + Leads to more recent times and more files per commit, thus with the same + time, which may or may not be what you want. + Including merge commits may lead to fewer commits being evaluated as files + are found sooner, which can improve performance, sometimes substantially. + But as merge commits are usually huge, processing them may also take longer. + By default, merge commits are only used for files missing from regular commits. + """, + ) + + parser.add_argument( + "--first-parent", + default=False, + action="store_true", + help=""" + Consider only the first parent, the "main branch", when evaluating merge commits. + Only effective when merge commits are processed, either when --merge is + used or when finding missing files after the first regular log search. + See --skip-missing. + """, + ) + + parser.add_argument( + "--skip-missing", + "-s", + dest="missing", + default=True, + action="store_false", + help=""" + Do not try to find missing files. + If merge commits were not evaluated with --merge and some files were + not found in regular commits, by default %(prog)s searches for these + files again in the merge commits. + This option disables this retry, so files found only in merge commits + will not have their timestamp updated. + """, + ) + + parser.add_argument( + "--no-directories", + "-D", + dest="dirs", + default=True, + action="store_false", + help=""" + Do not update directory timestamps. + By default, use the time of its most recently created, renamed or deleted file. + Note that just modifying a file will NOT update its directory time. + """, + ) + + parser.add_argument( + "--test", + "-t", + default=False, + action="store_true", + help="Test run: do not actually update any file timestamp.", + ) + + parser.add_argument( + "--commit-time", + "-c", + dest="commit_time", + default=False, + action="store_true", + help="Use commit time instead of author time.", + ) + + parser.add_argument( + "--oldest-time", + "-o", + dest="reverse_order", + default=False, + action="store_true", + help=""" + Update times based on the oldest, instead of the most recent commit of a file. + This reverses the order in which the git log is processed to emulate a + file "creation" date. Note this will be inaccurate for files deleted and + re-created at later dates. + """, + ) + + parser.add_argument( + "--skip-older-than", + metavar="SECONDS", + type=int, + help=""" + Ignore files that are currently older than %(metavar)s. + Useful in workflows that assume such files already have a correct timestamp, + as it may improve performance by processing fewer files. + """, + ) + + parser.add_argument( + "--skip-older-than-commit", + "-N", + default=False, + action="store_true", + help=""" + Ignore files older than the timestamp it would be updated to. + Such files may be considered "original", likely in the author's repository. + """, + ) + + parser.add_argument( + "--unique-times", + default=False, + action="store_true", + help=""" + Set the microseconds to a unique value per commit. + Allows telling apart changes that would otherwise have identical timestamps, + as git's time accuracy is in seconds. + """, + ) + + parser.add_argument( + "pathspec", + nargs="*", + metavar="PATHSPEC", + help=""" + Only modify paths matching %(metavar)s, relative to current directory. + By default, update all but untracked files and submodules. + """, + ) + + parser.add_argument( + "--version", + "-V", + action="version", + version="%(prog)s version {version}".format(version=get_version()), + ) + + args_ = parser.parse_args() + if args_.verbose: + args_.loglevel = max(logging.TRACE, logging.DEBUG // args_.verbose) + args_.debug = args_.loglevel <= logging.DEBUG + return args_ + + +def get_version(version=__version__): + if not version.endswith("+dev"): + return version + try: + cwd = os.path.dirname(os.path.realpath(__file__)) + return Git(cwd=cwd, errors=False).describe().lstrip("v") + except Git.Error: + return "-".join((version, "unknown")) + + +# Helper functions ############################################################ + + +def setup_logging(): + """Add TRACE logging level and corresponding method, return the root logger""" + logging.TRACE = TRACE = logging.DEBUG // 2 + logging.Logger.trace = lambda _, m, *a, **k: _.log(TRACE, m, *a, **k) + return logging.getLogger() + + +def normalize(path): + r"""Normalize paths from git, handling non-ASCII characters. + + Git stores paths as UTF-8 normalization form C. + If path contains non-ASCII or non-printable characters, git outputs the UTF-8 + in octal-escaped notation, escaping double-quotes and backslashes, and then + double-quoting the whole path. + https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath + + This function reverts this encoding, so: + normalize(r'"Back\\slash_double\"quote_a\303\247a\303\255"') => + r'Back\slash_double"quote_açaí') + + Paths with invalid UTF-8 encoding, such as single 0x80-0xFF bytes (e.g, from + Latin1/Windows-1251 encoding) are decoded using surrogate escape, the same + method used by Python for filesystem paths. So 0xE6 ("æ" in Latin1, r'\\346' + from Git) is decoded as "\udce6". See https://peps.python.org/pep-0383/ and + https://vstinner.github.io/painful-history-python-filesystem-encoding.html + + Also see notes on `windows/non-ascii-paths.txt` about path encodings on + non-UTF-8 platforms and filesystems. + """ + if path and path[0] == '"': + # Python 2: path = path[1:-1].decode("string-escape") + # Python 3: https://stackoverflow.com/a/46650050/624066 + path = ( + path[1:-1] # Remove enclosing double quotes + .encode("latin1") # Convert to bytes, required by 'unicode-escape' + .decode("unicode-escape") # Perform the actual octal-escaping decode + .encode("latin1") # 1:1 mapping to bytes, UTF-8 encoded + .decode("utf8", "surrogateescape") + ) # Decode from UTF-8 + if NORMALIZE_PATHS: + # Make sure the slash matches the OS; for Windows we need a backslash + path = os.path.normpath(path) + return path + + +def dummy(*_args, **_kwargs): + """No-op function used in dry-run tests""" + + +def touch(path, mtime): + """The actual mtime update""" + os.utime(path, (mtime, mtime), **UTIME_KWS) + + +def touch_ns(path, mtime_ns): + """The actual mtime update, using nanoseconds for unique timestamps""" + os.utime(path, None, ns=(mtime_ns, mtime_ns), **UTIME_KWS) + + +def isodate(secs: int): + # time.localtime() accepts floats, but discards fractional part + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(secs)) + + +def isodate_ns(ns: int): + # for integers fromtimestamp() is equivalent and ~16% slower than isodate() + return datetime.datetime.fromtimestamp(ns / 1000000000).isoformat(sep=" ") + + +def get_mtime_ns(secs: int, idx: int): + # Time resolution for filesystems and functions: + # ext-4 and other POSIX filesystems: 1 nanosecond + # NTFS (Windows default): 100 nanoseconds + # datetime.datetime() (due to 64-bit float epoch): 1 microsecond + us = idx % 1000000 # 10**6 + return 1000 * (1000000 * secs + us) + + +def get_mtime_path(path): + return os.path.getmtime(path) + + +# Git class and parse_log(), the heart of the script ########################## + + +class Git: + def __init__(self, workdir=None, gitdir=None, cwd=None, errors=True): + self.gitcmd = ["git"] + self.errors = errors + self._proc = None + if workdir: + self.gitcmd.extend(("--work-tree", workdir)) + if gitdir: + self.gitcmd.extend(("--git-dir", gitdir)) + if cwd: + self.gitcmd.extend(("-C", cwd)) + self.workdir, self.gitdir = self._get_repo_dirs() + + def ls_files(self, paths: list = None): + return (normalize(_) for _ in self._run("ls-files --full-name", paths)) + + def ls_dirty(self, force=False): + return ( + normalize(_[3:].split(" -> ", 1)[-1]) + for _ in self._run("status --porcelain") + if _[:2] != "??" and (not force or (_[0] in ("R", "A") or _[1] == "D")) + ) + + def log( + self, + merge=False, + first_parent=False, + commit_time=False, + reverse_order=False, + paths: list = None, + ): + cmd = "whatchanged --pretty={}".format("%ct" if commit_time else "%at") + if merge: + cmd += " -m" + if first_parent: + cmd += " --first-parent" + if reverse_order: + cmd += " --reverse" + return self._run(cmd, paths) + + def describe(self): + return self._run("describe --tags", check=True)[0] + + def terminate(self): + if self._proc is None: + return + try: + self._proc.terminate() + except OSError: + # Avoid errors on OpenBSD + pass + + def _get_repo_dirs(self): + return ( + os.path.normpath(_) + for _ in self._run( + "rev-parse --show-toplevel --absolute-git-dir", check=True + ) + ) + + def _run(self, cmdstr: str, paths: list = None, output=True, check=False): + cmdlist = self.gitcmd + shlex.split(cmdstr) + if paths: + cmdlist.append("--") + cmdlist.extend(paths) + popen_args = dict(universal_newlines=True, encoding="utf8") + if not self.errors: + popen_args["stderr"] = subprocess.DEVNULL + log.trace("Executing: %s", " ".join(cmdlist)) + if not output: + return subprocess.call(cmdlist, **popen_args) + if check: + try: + stdout: str = subprocess.check_output(cmdlist, **popen_args) + return stdout.splitlines() + except subprocess.CalledProcessError as e: + raise self.Error(e.returncode, e.cmd, e.output, e.stderr) + self._proc = subprocess.Popen(cmdlist, stdout=subprocess.PIPE, **popen_args) + return (_.rstrip() for _ in self._proc.stdout) + + def __del__(self): + self.terminate() + + class Error(subprocess.CalledProcessError): + """Error from git executable""" + + +def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): + mtime = 0 + datestr = isodate(0) + for line in git.log( + merge, args.first_parent, args.commit_time, args.reverse_order, filterlist + ): + stats["loglines"] += 1 + + # Blank line between Date and list of files + if not line: + continue + + # Date line + if line[0] != ":": # Faster than `not line.startswith(':')` + stats["commits"] += 1 + mtime = int(line) + if args.unique_times: + mtime = get_mtime_ns(mtime, stats["commits"]) + if args.debug: + datestr = isodate(mtime) + continue + + # File line: three tokens if it describes a renaming, otherwise two + tokens = line.split("\t") + + # Possible statuses: + # M: Modified (content changed) + # A: Added (created) + # D: Deleted + # T: Type changed: to/from regular file, symlinks, submodules + # R099: Renamed (moved), with % of unchanged content. 100 = pure rename + # Not possible in log: C=Copied, U=Unmerged, X=Unknown, B=pairing Broken + status = tokens[0].split(" ")[-1] + file = tokens[-1] + + # Handles non-ASCII chars and OS path separator + file = normalize(file) + + def do_file(): + if args.skip_older_than_commit and get_mtime_path(file) <= mtime: + stats["skip"] += 1 + return + if args.debug: + log.debug( + "%d\t%d\t%d\t%s\t%s", + stats["loglines"], + stats["commits"], + stats["files"], + datestr, + file, + ) + try: + touch(os.path.join(git.workdir, file), mtime) + stats["touches"] += 1 + except Exception as e: + log.error("ERROR: %s: %s", e, file) + stats["errors"] += 1 + + def do_dir(): + if args.debug: + log.debug( + "%d\t%d\t-\t%s\t%s", + stats["loglines"], + stats["commits"], + datestr, + "{}/".format(dirname or "."), + ) + try: + touch(os.path.join(git.workdir, dirname), mtime) + stats["dirtouches"] += 1 + except Exception as e: + log.error("ERROR: %s: %s", e, dirname) + stats["direrrors"] += 1 + + if file in filelist: + stats["files"] -= 1 + filelist.remove(file) + do_file() + + if args.dirs and status in ("A", "D"): + dirname = os.path.dirname(file) + if dirname in dirlist: + dirlist.remove(dirname) + do_dir() + + # All files done? + if not stats["files"]: + git.terminate() + return + + +# Main Logic ################################################################## + + +def main(): + start = time.time() # yes, Wall time. CPU time is not realistic for users. + stats = { + _: 0 + for _ in ( + "loglines", + "commits", + "touches", + "skip", + "errors", + "dirtouches", + "direrrors", + ) + } + + logging.basicConfig(level=args.loglevel, format="%(message)s") + log.trace("Arguments: %s", args) + + # First things first: Where and Who are we? + if args.cwd: + log.debug("Changing directory: %s", args.cwd) + try: + os.chdir(args.cwd) + except OSError as e: + log.critical(e) + return e.errno + # Using both os.chdir() and `git -C` is redundant, but might prevent side effects + # `git -C` alone could be enough if we make sure that: + # - all paths, including args.pathspec, are processed by git: ls-files, rev-parse + # - touch() / os.utime() path argument is always prepended with git.workdir + try: + git = Git(workdir=args.workdir, gitdir=args.gitdir, cwd=args.cwd) + except Git.Error as e: + # Not in a git repository, and git already informed user on stderr. So we just... + return e.returncode + + # Get the files managed by git and build file list to be processed + if UPDATE_SYMLINKS and not args.skip_older_than: + filelist = set(git.ls_files(args.pathspec)) + else: + filelist = set() + for path in git.ls_files(args.pathspec): + fullpath = os.path.join(git.workdir, path) + + # Symlink (to file, to dir or broken - git handles the same way) + if not UPDATE_SYMLINKS and os.path.islink(fullpath): + log.warning( + "WARNING: Skipping symlink, no OS support for updates: %s", path + ) + continue + + # skip files which are older than given threshold + if ( + args.skip_older_than + and start - get_mtime_path(fullpath) > args.skip_older_than + ): + continue + + # Always add files relative to worktree root + filelist.add(path) + + # If --force, silently ignore uncommitted deletions (not in the filesystem) + # and renames / additions (will not be found in log anyway) + if args.force: + filelist -= set(git.ls_dirty(force=True)) + # Otherwise, ignore any dirty files + else: + dirty = set(git.ls_dirty()) + if dirty: + log.warning( + "WARNING: Modified files in the working directory were ignored." + "\nTo include such files, commit your changes or use --force." + ) + filelist -= dirty + + # Build dir list to be processed + dirlist = set(os.path.dirname(_) for _ in filelist) if args.dirs else set() + + stats["totalfiles"] = stats["files"] = len(filelist) + log.info("{0:,} files to be processed in work dir".format(stats["totalfiles"])) + + if not filelist: + # Nothing to do. Exit silently and without errors, just like git does + return + + # Process the log until all files are 'touched' + log.debug("Line #\tLog #\tF.Left\tModification Time\tFile Name") + parse_log(filelist, dirlist, stats, git, args.merge, args.pathspec) + + # Missing files + if filelist: + # Try to find them in merge logs, if not done already + # (usually HUGE, thus MUCH slower!) + if args.missing and not args.merge: + filterlist = list(filelist) + missing = len(filterlist) + log.info( + "{0:,} files not found in log, trying merge commits".format(missing) + ) + for i in range(0, missing, STEPMISSING): + parse_log( + filelist, + dirlist, + stats, + git, + merge=True, + filterlist=filterlist[i : i + STEPMISSING], + ) + + # Still missing some? + for file in filelist: + log.warning("WARNING: not found in the log: %s", file) + + # Final statistics + # Suggestion: use git-log --before=mtime to brag about skipped log entries + def log_info(msg, *a, width=13): + ifmt = "{:%d,}" % (width,) # not using 'n' for consistency with ffmt + ffmt = "{:%d,.2f}" % (width,) + # %-formatting lacks a thousand separator, must pre-render with .format() + log.info(msg.replace("%d", ifmt).replace("%f", ffmt).format(*a)) + + log_info( + "Statistics:\n%f seconds\n%d log lines processed\n%d commits evaluated", + time.time() - start, + stats["loglines"], + stats["commits"], + ) + + if args.dirs: + if stats["direrrors"]: + log_info("%d directory update errors", stats["direrrors"]) + log_info("%d directories updated", stats["dirtouches"]) + + if stats["touches"] != stats["totalfiles"]: + log_info("%d files", stats["totalfiles"]) + if stats["skip"]: + log_info("%d files skipped", stats["skip"]) + if stats["files"]: + log_info("%d files missing", stats["files"]) + if stats["errors"]: + log_info("%d file update errors", stats["errors"]) + + log_info("%d files updated", stats["touches"]) + + if args.test: + log.info("TEST RUN - No files modified!") + + +# Keep only essential, global assignments here. Any other logic must be in main() +log = setup_logging() +args = parse_args() + +# Set the actual touch() and other functions based on command-line arguments +if args.unique_times: + touch = touch_ns + isodate = isodate_ns + +# Make sure this is always set last to ensure --test behaves as intended +if args.test: + touch = dummy + +# UI done, it's showtime! +try: + sys.exit(main()) +except KeyboardInterrupt: + log.info("\nAborting") + signal.signal(signal.SIGINT, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGINT) diff --git a/.github/workflows/_compile_integration_test.yml b/.github/workflows/_compile_integration_test.yml new file mode 100644 index 0000000..7d0b366 --- /dev/null +++ b/.github/workflows/_compile_integration_test.yml @@ -0,0 +1,65 @@ +# Validates that a package's integration tests compile without syntax or import errors. +# +# (If an integration test fails to compile, it won't run.) +# +# Called as part of check_diffs.yml workflow +# +# Runs pytest with compile marker to check syntax/imports. + +name: "🔗 Compile Integration Tests" + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + python-version: + required: true + type: string + description: "Python version to use" + +permissions: + contents: read + +env: + UV_FROZEN: "true" + +jobs: + build: + defaults: + run: + working-directory: ${{ inputs.working-directory }} + runs-on: ubuntu-latest + timeout-minutes: 20 + name: "Python ${{ inputs.python-version }}" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ inputs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ inputs.python-version }} + cache-suffix: compile-integration-tests-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + - name: "📦 Install Integration Dependencies" + shell: bash + run: uv sync --group test --group test_integration + + - name: "🔗 Check Integration Tests Compile" + shell: bash + run: uv run pytest -m compile tests/integration_tests + + - name: "🧹 Verify Clean Working Directory" + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml new file mode 100644 index 0000000..fb7900f --- /dev/null +++ b/.github/workflows/_lint.yml @@ -0,0 +1,81 @@ +# Runs linting. +# +# Uses the package's Makefile to run the checks, specifically the +# `lint_package` and `lint_tests` targets. +# +# Called as part of check_diffs.yml workflow. + +name: "🧹 Linting" + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + python-version: + required: true + type: string + description: "Python version to use" + +permissions: + contents: read + +env: + WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }} + + # This env var allows us to get inline annotations when ruff has complaints. + RUFF_OUTPUT_FORMAT: github + + UV_FROZEN: "true" + +jobs: + # Linting job - runs quality checks on package and test code + build: + name: "Python ${{ inputs.python-version }}" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ inputs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ inputs.python-version }} + cache-suffix: lint-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + # - name: "🔒 Verify Lockfile is Up-to-Date" + # working-directory: ${{ inputs.working-directory }} + # run: | + # unset UV_FROZEN + # uv lock --check + + - name: "📦 Install Lint & Typing Dependencies" + working-directory: ${{ inputs.working-directory }} + run: | + uv sync --group lint --group typing + + - name: "🔠Analyze Package Code with Linters" + working-directory: ${{ inputs.working-directory }} + run: | + make lint_package + + - name: "📦 Install Test Dependencies (non-partners)" + # (For directories NOT starting with libs/partners/) + if: ${{ ! startsWith(inputs.working-directory, 'libs/partners/') }} + working-directory: ${{ inputs.working-directory }} + run: | + uv sync --inexact --group test + - name: "📦 Install Test Dependencies" + if: ${{ startsWith(inputs.working-directory, 'libs/partners/') }} + working-directory: ${{ inputs.working-directory }} + run: | + uv sync --inexact --group test --group test_integration + + - name: "🔠Analyze Test Code with Linters" + working-directory: ${{ inputs.working-directory }} + run: | + make lint_tests diff --git a/.github/workflows/_refresh_model_profiles.yml b/.github/workflows/_refresh_model_profiles.yml new file mode 100644 index 0000000..95b7d1b --- /dev/null +++ b/.github/workflows/_refresh_model_profiles.yml @@ -0,0 +1,228 @@ +# Reusable workflow: refreshes model profile data for any repo that uses the +# `langchain-profiles` CLI. Creates (or updates) a pull request with the +# resulting changes. +# +# Callers MUST set `permissions: { contents: write, pull-requests: write }` — +# reusable workflows cannot escalate the caller's token permissions. +# +# ── Example: external repo (langchain-google) ────────────────────────── +# +# jobs: +# refresh-profiles: +# uses: langchain-ai/langchain/.github/workflows/_refresh_model_profiles.yml@master +# with: +# providers: >- +# [ +# {"provider":"google", "data_dir":"libs/genai/langchain_google_genai/data"}, +# ] +# secrets: +# MODEL_PROFILE_BOT_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }} +# MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }} + +name: "Refresh Model Profiles (reusable)" + +on: + workflow_call: + inputs: + providers: + description: >- + JSON array of objects, each with `provider` (models.dev provider ID) + and `data_dir` (path relative to repo root where `_profiles.py` and + `profile_augmentations.toml` live). + required: true + type: string + cli-path: + description: >- + Path (relative to workspace) to an existing `libs/model-profiles` + checkout. When set the workflow skips cloning the langchain repo and + uses this directory for the CLI instead. Useful when the caller IS + the langchain monorepo. + required: false + type: string + default: "" + cli-ref: + description: >- + Git ref of langchain-ai/langchain to checkout for the CLI. + Ignored when `cli-path` is set. + required: false + type: string + default: master + add-paths: + description: "Glob for files to stage in the PR commit." + required: false + type: string + default: "**/_profiles.py" + pr-branch: + description: "Branch name for the auto-created PR." + required: false + type: string + default: bot/refresh-model-profiles + pr-title: + description: "PR / commit title." + required: false + type: string + default: "chore(model-profiles): refresh model profile data" + pr-body: + description: "PR body." + required: false + type: string + default: | + Automated refresh of model profile data via `langchain-profiles refresh`. + + 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). + pr-labels: + description: "Comma-separated labels to apply to the PR." + required: false + type: string + default: bot + secrets: + MODEL_PROFILE_BOT_CLIENT_ID: + required: true + MODEL_PROFILE_BOT_PRIVATE_KEY: + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + refresh-profiles: + name: refresh model profiles + runs-on: ubuntu-latest + steps: + - name: "📋 Checkout" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "📋 Checkout langchain-profiles CLI" + if: inputs.cli-path == '' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: langchain-ai/langchain + ref: ${{ inputs.cli-ref }} + sparse-checkout: libs/model-profiles + path: _langchain-cli + + - name: "🔧 Resolve CLI directory" + id: cli + env: + CLI_PATH: ${{ inputs.cli-path }} + run: | + if [ -n "${CLI_PATH}" ]; then + resolved="${GITHUB_WORKSPACE}/${CLI_PATH}" + if [ ! -d "${resolved}" ]; then + echo "::error::cli-path '${CLI_PATH}' does not exist at ${resolved}" + exit 1 + fi + echo "dir=${CLI_PATH}" >> "$GITHUB_OUTPUT" + else + echo "dir=_langchain-cli/libs/model-profiles" >> "$GITHUB_OUTPUT" + fi + + - name: "ðŸ Set up Python + uv" + uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7 + with: + version: "0.5.25" + python-version: "3.12" + enable-cache: true + cache-dependency-glob: "**/model-profiles/uv.lock" + + - name: "📦 Install langchain-profiles CLI" + working-directory: ${{ steps.cli.outputs.dir }} + run: uv sync --frozen --no-group test --no-group dev --no-group lint + + - name: "✅ Validate providers input" + env: + PROVIDERS_JSON: ${{ inputs.providers }} + run: | + echo "${PROVIDERS_JSON}" | jq -e 'type == "array" and length > 0' > /dev/null || { + echo "::error::providers input must be a non-empty JSON array" + exit 1 + } + echo "${PROVIDERS_JSON}" | jq -e 'all(has("provider") and has("data_dir"))' > /dev/null || { + echo "::error::every entry in providers must have 'provider' and 'data_dir' keys" + exit 1 + } + + - name: "🔄 Refresh profiles" + env: + PROVIDERS_JSON: ${{ inputs.providers }} + run: | + cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}" + failed="" + mapfile -t rows < <(echo "${PROVIDERS_JSON}" | jq -c '.[]') + for row in "${rows[@]}"; do + provider=$(echo "${row}" | jq -r '.provider') + data_dir=$(echo "${row}" | jq -r '.data_dir') + echo "--- Refreshing ${provider} -> ${data_dir} ---" + if ! echo y | uv run --frozen --project "${cli_dir}" \ + langchain-profiles refresh \ + --provider "${provider}" \ + --data-dir "${GITHUB_WORKSPACE}/${data_dir}"; then + echo "::error::Failed to refresh provider: ${provider}" + failed="${failed} ${provider}" + fi + done + if [ -n "${failed}" ]; then + echo "::error::The following providers failed:${failed}" + exit 1 + fi + + - name: "📠Build PR body with change summary" + id: pr-body + env: + PROVIDERS_JSON: ${{ inputs.providers }} + PR_BODY: ${{ inputs.pr-body }} + run: | + # The refresh step modified the working tree without committing, so + # comparing against HEAD yields exactly the refresh's changes. + cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}" + body_file="${RUNNER_TEMP}/pr_body.md" + printf '%s\n\n' "${PR_BODY}" > "${body_file}" + # `summarize` builds the whole summary in memory and prints it once, + # so a failure exits non-zero before any stdout reaches the append — + # the body keeps only the static note, never a half-written summary. + if ! uv run --frozen --project "${cli_dir}" \ + langchain-profiles summarize \ + --providers "${PROVIDERS_JSON}" \ + --base-ref HEAD \ + --repo-root "${GITHUB_WORKSPACE}" >> "${body_file}"; then + echo "::warning::Could not generate change summary; see job log." + # Surface the degradation in the PR body too: the warning above only + # lands in the Actions log, which a PR reviewer won't see. + printf '\n> [!NOTE]\n> Automated change summary unavailable — see the workflow run log.\n' >> "${body_file}" + fi + echo "path=${body_file}" >> "$GITHUB_OUTPUT" + + - name: "🔑 Generate GitHub App token" + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }} + private-key: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }} + + - name: "🔀 Create pull request" + id: create-pr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ inputs.pr-branch }} + commit-message: ${{ inputs.pr-title }} + title: ${{ inputs.pr-title }} + body-path: ${{ steps.pr-body.outputs.path }} + labels: ${{ inputs.pr-labels }} + add-paths: ${{ inputs.add-paths }} + + - name: "📠Summary" + if: always() + env: + PR_OP: ${{ steps.create-pr.outputs.pull-request-operation }} + PR_URL: ${{ steps.create-pr.outputs.pull-request-url }} + JOB_STATUS: ${{ job.status }} + run: | + if [ "${PR_OP}" = "created" ] || [ "${PR_OP}" = "updated" ]; then + echo "### ✅ PR ${PR_OP}: ${PR_URL}" >> "$GITHUB_STEP_SUMMARY" + elif [ -z "${PR_OP}" ] && [ "${JOB_STATUS}" = "success" ]; then + echo "### â­ï¸ Skipped: profiles already up to date" >> "$GITHUB_STEP_SUMMARY" + elif [ "${JOB_STATUS}" = "failure" ]; then + echo "### ⌠Job failed — check step logs for details" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml new file mode 100644 index 0000000..ecdb14b --- /dev/null +++ b/.github/workflows/_release.yml @@ -0,0 +1,914 @@ +# Builds and publishes LangChain packages to PyPI. +# +# Manually triggered, though can be used as a reusable workflow (workflow_call). +# +# Handles version bumping, building, and publishing to PyPI with authentication. + +name: "🚀 Package Release" +# Run title resolves dropdown values to the published package name (e.g. +# `core` -> `langchain-core`, `openai` -> `langchain-openai`). Falls back to +# the raw input for override and `workflow_call` cases, which already pass +# a full path. Three dropdown values don't follow `langchain-{name}`: +# `langchain` -> `langchain-classic`, `langchain_v1` -> `langchain`, +# `standard-tests` -> `langchain-tests`. +run-name: >- + Release ${{ inputs.working-directory-override || + (startsWith(inputs.working-directory, 'libs/') && inputs.working-directory) || + (inputs.working-directory == 'langchain' && 'langchain-classic') || + (inputs.working-directory == 'langchain_v1' && 'langchain') || + (inputs.working-directory == 'standard-tests' && 'langchain-tests') || + format('langchain-{0}', inputs.working-directory) }} ${{ + inputs.release-version }} +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + release-version: + required: false + type: string + default: "" + description: "Expected package version. If provided, must match pyproject.toml." + allow-prereleases: + required: false + type: boolean + default: false + description: "Pass `--prerelease=allow` to wheel-install steps so + transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled + in by an alpha langgraph) resolve. Use only when the release itself + is a prerelease and at least one dep is also a prerelease." + # `workflow_call` callers must pass an exact lowercase value: `none` or a + # partner name from the `test-prior-published-packages-against-new-core` + # matrix (or `all`). Unrecognized values fail safe (the check still runs). + # Keep this list in sync with that matrix and the `workflow_dispatch` + # `options` below. + skip-prior-published-package-checks: + required: false + type: string + default: "none" + description: "Prior published partner check to skip for core releases: + none, anthropic, openai, or all." + workflow_dispatch: + inputs: + working-directory: + required: true + type: choice + description: "From which folder this pipeline executes" + default: "langchain_v1" + # Short names only — `EFFECTIVE_WORKING_DIR` below re-adds the `libs/` + # or `libs/partners/` prefix. When adding a new option, also update the + # non-partner allowlist in `EFFECTIVE_WORKING_DIR` if it isn't a partner + # package (partners are the default branch). + options: + - core + - langchain + - langchain_v1 + - text-splitters + - standard-tests + - model-profiles + - anthropic + - chroma + - deepseek + - exa + - fireworks + - groq + - huggingface + - mistralai + - nomic + - ollama + - openai + - openrouter + - perplexity + - qdrant + - xai + working-directory-override: + required: false + type: string + description: "Manual override — takes precedence over dropdown (e.g. + libs/partners/partner-xyz)" + release-version: + required: true + type: string + default: "0.1.0" + description: "New version of package being released" + dangerous-nonmaster-release: + required: false + type: boolean + default: false + description: "Release from a non-master branch (danger!) - Only use for hotfixes" + allow-prereleases: + required: false + type: boolean + default: false + description: "Pass `--prerelease=allow` to wheel-install steps so + transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled + in by an alpha langgraph) resolve. Use only when the release itself + is a prerelease and at least one dep is also a prerelease." + skip-prior-published-package-checks: + required: false + type: choice + default: none + description: "Prior published partner check to skip for core releases" + options: + - none + - anthropic + - openai + - all + +env: + PYTHON_VERSION: "3.11" + UV_FROZEN: "true" + UV_NO_SYNC: "true" + # Resolves to a full path. Accepts either: + # - `working-directory-override` as a full path (e.g. `libs/partners/partner-xyz`) + # - `working-directory` as a full path (from `workflow_call` callers) + # - `working-directory` as a short dropdown name (from `workflow_dispatch`) + EFFECTIVE_WORKING_DIR: >- + ${{ + inputs.working-directory-override + || (startsWith(inputs.working-directory, 'libs/') && inputs.working-directory) + || (contains(fromJSON('["core","langchain","langchain_v1","text-splitters","standard-tests","model-profiles"]'), inputs.working-directory) && format('libs/{0}', inputs.working-directory)) + || format('libs/partners/{0}', inputs.working-directory) + }} + +permissions: + contents: read # Job-level overrides grant write only where needed (mark-release) + +jobs: + # Build the distribution package and extract version info + # Runs in isolated environment with minimal permissions for security + build: + name: 📦 Build distribution + if: github.repository_owner == 'langchain-ai' && (github.ref == 'refs/heads/master' || inputs.dangerous-nonmaster-release) + environment: Release + runs-on: ubuntu-latest + permissions: + contents: read + + outputs: + pkg-name: ${{ steps.check-version.outputs.pkg-name }} + version: ${{ steps.check-version.outputs.version }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Set up Python + uv + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: "false" + + - name: Summarize release bypasses + if: >- + inputs.dangerous-nonmaster-release || inputs.allow-prereleases || + inputs.skip-prior-published-package-checks != 'none' + env: + ALLOW_PRERELEASES: ${{ inputs.allow-prereleases }} + DANGEROUS_NONMASTER_RELEASE: ${{ inputs.dangerous-nonmaster-release }} + SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS: ${{ inputs.skip-prior-published-package-checks }} + run: | + echo "::warning::Release bypass input(s) enabled. See job summary." + { + echo "## âš ï¸ Release bypasses enabled" + echo + echo "One or more release safety bypasses were selected for this run:" + echo + if [ "$DANGEROUS_NONMASTER_RELEASE" = "true" ]; then + echo "- \`dangerous-nonmaster-release\`: release jobs may run from a non-\`master\` ref." + fi + if [ "$ALLOW_PRERELEASES" = "true" ]; then + echo "- \`allow-prereleases\`: install checks use \`--prerelease=allow\`." + fi + if [ -n "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" ] && [ "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" != "none" ]; then + echo "- \`skip-prior-published-package-checks\`: \`$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Check version + id: check-version + shell: python + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + env: + RELEASE_VERSION_INPUT: ${{ inputs.release-version }} + run: | + import os + import re + import sys + import tomllib + import urllib.error + import urllib.request + + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + + pkg_name = data["project"]["name"] + version = data["project"]["version"] + requested_version = os.environ.get("RELEASE_VERSION_INPUT", "").strip() + + + def normalize(v): + # Lightweight PEP 440 comparison key: lowercase and drop the `-`, + # `_`, or `.` separators that precede a pre/post/dev segment so that + # e.g. `0.1.0-rc1` and `0.1.0rc1` compare equal. Full canonicalization + # lives in `packaging`, which isn't installed in this bare release step. + return re.sub(r"[-_.]+(?=[a-z])", "", v.lower()) + + + if requested_version and normalize(requested_version) != normalize(version): + print( + f"::error::Requested release version {requested_version!r} does " + f"not match {pkg_name} pyproject.toml version {version!r}." + ) + sys.exit(1) + + # Query the per-version endpoint so PyPI applies PEP 440 normalization + # (e.g. `0.1.0-rc1` and `0.1.0rc1` resolve to the same release): HTTP 200 + # means the version is already published, 404 means it's available + # (including the first-ever release of a new package). Only the status + # code is used, so a malicious or malformed response body can't mislead us. + url = f"https://pypi.org/pypi/{pkg_name}/{version}/json" + try: + with urllib.request.urlopen(url, timeout=10): + already_published = True + except urllib.error.HTTPError as err: + if err.code == 404: + already_published = False + else: + # Fail closed: an unexpected status means we can't verify. + print( + f"::error::PyPI returned HTTP {err.code} checking whether " + f"{pkg_name}=={version} exists; cannot verify, aborting." + ) + sys.exit(1) + except urllib.error.URLError as err: + # Fail closed: if PyPI is unreachable we must not assume the version + # is free, or we risk re-publishing an existing release. + print( + f"::error::Could not reach PyPI to verify {pkg_name}=={version} " + f"({err.reason}); cannot verify, aborting." + ) + sys.exit(1) + + if already_published: + print(f"::error::{pkg_name}=={version} already exists on PyPI.") + sys.exit(1) + + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"pkg-name={pkg_name}\n") + f.write(f"version={version}\n") + + # We want to keep this build stage *separate* from the release stage, + # so that there's no sharing of permissions between them. + # (Release stage has trusted publishing and GitHub repo contents write access, + # which the build stage must not have access to.) + # + # Otherwise, a malicious `build` step (e.g. via a compromised dependency) + # could get access to our GitHub or PyPI credentials. + # + # Per the trusted publishing GitHub Action: + # > It is strongly advised to separate jobs for building [...] + # > from the publish job. + # https://github.com/pypa/gh-action-pypi-publish#non-goals + - name: Build project for distribution + run: uv build + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + - name: Upload build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + release-notes: + name: 📠Generate release notes + # release-notes must run before publishing because its check-tags step + # validates version/tag state — do not remove this dependency. + needs: + - build + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release-body: ${{ steps.generate-release-body.outputs.release-body }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: langchain-ai/langchain + path: langchain + sparse-checkout: | # this only grabs files for relevant dir + ${{ env.EFFECTIVE_WORKING_DIR }} + ref: ${{ github.ref }} # this scopes to just ref'd branch + fetch-depth: 0 # this fetches entire commit history + - name: Check tags + id: check-tags + shell: bash + working-directory: langchain/${{ env.EFFECTIVE_WORKING_DIR }} + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + run: | + # Handle regular versions and pre-release versions differently + if [[ "$VERSION" == *"-"* ]]; then + # This is a pre-release version (contains a hyphen) + # Extract the base version without the pre-release suffix + BASE_VERSION=${VERSION%%-*} + # Look for the latest release of the same base version + REGEX="^$PKG_NAME==$BASE_VERSION\$" + PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1) + + # If no exact base version match, look for the latest release of any kind + if [ -z "$PREV_TAG" ]; then + REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$" + PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1) + fi + else + # Regular version handling + PREV_TAG="$PKG_NAME==${VERSION%.*}.$(( ${VERSION##*.} - 1 ))"; [[ "${VERSION##*.}" -eq 0 ]] && PREV_TAG="" + + # backup case if releasing e.g. 0.3.0, looks up last release + # note if last release (chronologically) was e.g. 0.1.47 it will get + # that instead of the last 0.2 release + if [ -z "$PREV_TAG" ]; then + REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$" + echo $REGEX + PREV_TAG=$(git tag --sort=-creatordate | (grep -P $REGEX || true) | head -1) + fi + fi + + # if PREV_TAG is empty or came out to 0.0.0, let it be empty + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then + echo "No previous tag found - first release" + else + # confirm prev-tag actually exists in git repo with git tag + GIT_TAG_RESULT=$(git tag -l "$PREV_TAG") + if [ -z "$GIT_TAG_RESULT" ]; then + echo "Previous tag $PREV_TAG not found in git repo" + exit 1 + fi + fi + + + TAG="${PKG_NAME}==${VERSION}" + if [ "$TAG" == "$PREV_TAG" ]; then + echo "No new version to release" + exit 1 + fi + echo tag="$TAG" >> $GITHUB_OUTPUT + echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT + - name: Generate release body + id: generate-release-body + working-directory: langchain + env: + WORKING_DIR: ${{ env.EFFECTIVE_WORKING_DIR }} + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + TAG: ${{ steps.check-tags.outputs.tag }} + PREV_TAG: ${{ steps.check-tags.outputs.prev-tag }} + run: | + PREAMBLE="Changes since $PREV_TAG" + # if PREV_TAG is empty or 0.0.0, then we are releasing the first version + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then + PREAMBLE="Initial release" + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + { + echo 'release-body<> "$GITHUB_OUTPUT" + + pre-release-checks: + name: ✅ Pre-release checks + needs: + - build + - release-notes + environment: Release + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + # We explicitly *don't* set up caching here. This ensures our tests are + # maximally sensitive to catching breakage. + # + # For example, here's a way that caching can cause a falsely-passing test: + # - Make the langchain package manifest no longer list a dependency package + # as a requirement. This means it won't be installed by `pip install`, + # and attempting to use it would cause a crash. + # - That dependency used to be required, so it may have been cached. + # When restoring the venv packages from cache, that dependency gets included. + # - Tests pass, because the dependency is present even though it wasn't specified. + # - The package is published, and it breaks on the missing dependency when + # used in the real world. + + - name: Set up Python + uv + uses: "./.github/actions/uv_setup" + id: setup-python + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: "false" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + + - name: Import dist package + shell: bash + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }} + # Install directly from the locally-built wheel (no index resolution needed). + # `PRERELEASE_FLAG` is empty by default; opt-in via the `allow-prereleases` + # workflow input lets transitive prerelease deps resolve during alpha + # release cycles. Stable-release safety is still enforced by the + # `Check for prerelease versions` step below. + run: | + uv venv + VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG dist/*.whl + + # Replace all dashes in the package name with underscores, + # since that's how Python imports packages with dashes in the name. + # also remove _official suffix + IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g | sed s/_official//g)" + + uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))" + + - name: Import test dependencies + run: uv sync --group test + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + # Overwrite the local version of the package with the built version + - name: Import published package (again) + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + shell: bash + env: + PKG_NAME: ${{ needs.build.outputs.pkg-name }} + VERSION: ${{ needs.build.outputs.version }} + PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }} + run: | + VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG dist/*.whl + + - name: Check for prerelease versions + # Block release if any dependencies allow prerelease versions + # (unless this is itself a prerelease version) + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + run: | + uv run python $GITHUB_WORKSPACE/.github/scripts/check_prerelease_dependencies.py pyproject.toml + + - name: Run unit tests + run: make tests + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + - name: Get minimum versions + # Find the minimum published versions that satisfies the given constraints + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + id: min-version + run: | + VIRTUAL_ENV=.venv uv pip install packaging requests + python_version="$(uv run python --version | awk '{print $2}')" + min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml release $python_version)" + echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT" + echo "min-versions=$min_versions" + + - name: Run unit tests with minimum dependency versions + if: ${{ steps.min-version.outputs.min-versions != '' }} + env: + MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }} + PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }} + run: | + VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall --editable . + VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall $MIN_VERSIONS + make tests PYTEST_EXTRA="-q -k 'not test_serdes'" + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + - name: Import integration test dependencies + run: uv sync --group test --group test_integration + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + - name: Run integration tests + # Uses the Makefile's `integration_tests` target for the specified package + if: ${{ startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/partners/') }} + env: + AI21_API_KEY: ${{ secrets.AI21_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} + AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }} + AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }} + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }} + GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }} + EXA_API_KEY: ${{ secrets.EXA_API_KEY }} + NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }} + WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }} + WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }} + ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }} + ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }} + ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }} + ES_URL: ${{ secrets.ES_URL }} + ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }} + ES_API_KEY: ${{ secrets.ES_API_KEY }} + MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }} + UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }} + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }} + run: make integration_tests + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + test-pypi-publish: + name: 🧪 Publish to TestPyPI + # release-notes must run before publishing because its check-tags step + # validates version/tag state — do not remove this dependency. + needs: + - build + - release-notes + - pre-release-checks + environment: Release + runs-on: ubuntu-latest + permissions: + # This permission is used for trusted publishing: + # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ + # + # Trusted publishing has to also be configured on PyPI for each package: + # https://docs.pypi.org/trusted-publishers/adding-a-publisher/ + id-token: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + + - name: Publish to test PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + verbose: true + print-hash: true + repository-url: https://test.pypi.org/legacy/ + # We overwrite any existing distributions with the same name and version. + # This is *only for CI use* and is *extremely dangerous* otherwise! + # https://github.com/pypa/gh-action-pypi-publish#tolerating-release-package-file-duplicates + skip-existing: true + # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0 + attestations: false + + # Test select published packages against new core + # Done when code changes are made to langchain-core + test-prior-published-packages-against-new-core: + name: 🔄 Test prior partners against new core + # Installs the new core with old partners: Installs the new unreleased core + # alongside the previously published partner packages and runs unit and integration tests + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + environment: Release + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + # When adding a partner, also update the `skip-prior-published-package-checks` + # input (the `workflow_dispatch` `options` list and the `workflow_call` + # description) so the per-partner skip remains selectable. + partner: [ anthropic, openai ] + fail-fast: false # Continue testing other partners if one fails + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }} + ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} + AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }} + AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }} + LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + # We implement this conditional as Github Actions does not have good support + # for conditionally needing steps. https://github.com/actions/runner/issues/491 + # TODO: this seems to be resolved upstream, so we can probably remove this workaround + - name: Check if libs/core + run: | + if [ "${{ startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') }}" != "true" ]; then + echo "Not in libs/core. Exiting successfully." + exit 0 + fi + + - name: Set up Python + uv + if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: "false" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + + - name: Skip prior published ${{ matrix.partner }} check + if: >- + startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') && + (inputs.skip-prior-published-package-checks == matrix.partner || + inputs.skip-prior-published-package-checks == 'all') + run: | + echo "Skipping prior published ${{ matrix.partner }} check as requested." + + - name: Test against ${{ matrix.partner }} + if: >- + startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') && + inputs.skip-prior-published-package-checks != matrix.partner && + inputs.skip-prior-published-package-checks != 'all' + env: + PARTNER: ${{ matrix.partner }} + PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }} + run: | + PACKAGE_NAME="langchain-$PARTNER" + + # Identify the latest non-yanked published package release, excluding pre-releases. + # Fail closed (matching the `Check version` step) so a PyPI outage or a + # missing release aborts with a clear message rather than an empty version. + LATEST_PACKAGE_VERSION="$(PACKAGE_NAME="$PACKAGE_NAME" python - <<'PY' + import json + import os + import re + import sys + import urllib.error + import urllib.request + + package_name = os.environ["PACKAGE_NAME"] + url = f"https://pypi.org/pypi/{package_name}/json" + try: + with urllib.request.urlopen(url, timeout=10) as response: + data = json.load(response) + except urllib.error.HTTPError as err: + print( + f"::error::PyPI returned HTTP {err.code} listing {package_name} " + f"releases; cannot determine latest version, aborting.", + file=sys.stderr, + ) + sys.exit(1) + except urllib.error.URLError as err: + print( + f"::error::Could not reach PyPI to list {package_name} releases " + f"({err.reason}); cannot determine latest version, aborting.", + file=sys.stderr, + ) + sys.exit(1) + + versions: list[tuple[int, int, int, str]] = [] + for version, files in data["releases"].items(): + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + continue + if not files or all(file.get("yanked", False) for file in files): + continue + versions.append((*map(int, version.split(".")), version)) + + if not versions: + print(f"::error::No non-yanked final releases found for {package_name}", file=sys.stderr) + sys.exit(1) + + print(max(versions)[3]) + PY + )" + + # Belt-and-suspenders: a bare assignment masks the heredoc's exit status + # in some shells, so guard explicitly rather than relying on `set -e`. + if [ -z "$LATEST_PACKAGE_VERSION" ]; then + echo "::error::Could not determine latest published $PACKAGE_NAME version; aborting." + exit 1 + fi + + LATEST_PACKAGE_TAG="$PACKAGE_NAME==$LATEST_PACKAGE_VERSION" + echo "Latest non-yanked package tag: $LATEST_PACKAGE_TAG" + + # Ensure the PyPI release maps to a source tag before running tests. + git ls-remote --exit-code --tags origin "refs/tags/$LATEST_PACKAGE_TAG" + + # Shallow-fetch just that single tag + git fetch --depth=1 origin tag "$LATEST_PACKAGE_TAG" + + # Checkout the latest package files + rm -rf "$GITHUB_WORKSPACE/libs/partners/$PARTNER"/* + rm -rf $GITHUB_WORKSPACE/libs/standard-tests/* + cd $GITHUB_WORKSPACE/libs/ + git checkout "$LATEST_PACKAGE_TAG" -- standard-tests/ + git checkout "$LATEST_PACKAGE_TAG" -- "partners/$PARTNER/" + cd "partners/$PARTNER" + + # Print as a sanity check + echo "Version number from pyproject.toml: " + cat pyproject.toml | grep "version = " + + # Run tests + uv sync --group test --group test_integration + uv pip install $PRERELEASE_FLAG ../../core/dist/*.whl + make test + make integration_tests + + # Test external packages that depend on langchain-core/langchain against the new release + # Only runs for core and langchain_v1 releases to catch breaking changes before publish + test-dependents: + name: "ðŸ Test dependent: ${{ matrix.package.path }} (Python ${{ + matrix.python-version }})" + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + runs-on: ubuntu-latest + permissions: + contents: read + # Only run for core or langchain_v1 releases. + # Job-level 'if' does not support env context, so EFFECTIVE_WORKING_DIR is + # unavailable; must use inputs directly and match both forms: short dropdown + # names (workflow_dispatch, e.g. 'core') and full 'libs/' paths + # (workflow_call / working-directory-override). + if: >- + contains(fromJSON('["core","langchain_v1"]'), + inputs.working-directory-override || inputs.working-directory) || + startsWith(inputs.working-directory-override || inputs.working-directory, + 'libs/core') || startsWith(inputs.working-directory-override || + inputs.working-directory, 'libs/langchain_v1') + strategy: + fail-fast: false + matrix: + python-version: [ "3.11", "3.13" ] + package: + - name: deepagents + repo: langchain-ai/deepagents + path: libs/deepagents + # No API keys needed for now - deepagents `make test` only runs unit tests + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + path: langchain + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: ${{ matrix.package.repo }} + path: ${{ matrix.package.name }} + + - name: Set up Python + uv + uses: "./langchain/.github/actions/uv_setup" + with: + python-version: ${{ matrix.python-version }} + enable-cache: "false" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: dist/ + + - name: Install ${{ matrix.package.name }} with local packages + # External dependents don't have [tool.uv.sources] pointing to this repo, + # so we install the package normally then override with the built wheel. + env: + PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }} + run: | + cd ${{ matrix.package.name }}/${{ matrix.package.path }} + + # Install the package with test dependencies + uv sync --group test + + # Override with the built wheel from this release + uv pip install $PRERELEASE_FLAG $GITHUB_WORKSPACE/dist/*.whl + + - name: Run ${{ matrix.package.name }} tests + run: | + cd ${{ matrix.package.name }}/${{ matrix.package.path }} + make test + + publish: + name: 🚀 Publish to PyPI + # Publishes the package to PyPI + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + - test-dependents + - test-prior-published-packages-against-new-core + # Run if all needed jobs succeeded or were skipped (test-dependents and + # test-prior-published-packages-against-new-core only run for core/langchain_v1) + if: ${{ !cancelled() && !failure() }} + environment: Release + runs-on: ubuntu-latest + permissions: + # This permission is used for trusted publishing: + # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ + # + # Trusted publishing has to also be configured on PyPI for each package: + # https://docs.pypi.org/trusted-publishers/adding-a-publisher/ + id-token: write + + defaults: + run: + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Set up Python + uv + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: "false" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + verbose: true + print-hash: true + # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0 + attestations: false + + mark-release: + name: ðŸ·ï¸ Tag GitHub release + # Marks the GitHub release with the new version tag + needs: + - build + - release-notes + - test-pypi-publish + - pre-release-checks + - publish + # Run if all needed jobs succeeded or were skipped + if: ${{ !cancelled() && !failure() }} + environment: Release + runs-on: ubuntu-latest + permissions: + # This permission is needed by `ncipollo/release-action` to + # create the GitHub release/tag + contents: write + + defaults: + run: + working-directory: ${{ env.EFFECTIVE_WORKING_DIR }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Set up Python + uv + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: "false" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: dist + path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/ + + - name: Create Tag + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 + with: + # JS actions ignore `defaults.run.working-directory`, so this glob is + # resolved from the repo root. Point it at the package's `dist/` + # (where `download-artifact` placed the wheels) instead of a bare + # `dist/*`, which never matched and attached no assets to releases. + artifacts: "${{ env.EFFECTIVE_WORKING_DIR }}/dist/*" + token: ${{ secrets.GITHUB_TOKEN }} + generateReleaseNotes: false + tag: ${{needs.build.outputs.pkg-name}}==${{ needs.build.outputs.version }} + body: ${{ needs.release-notes.outputs.release-body }} + commit: ${{ github.sha }} + makeLatest: ${{ needs.build.outputs.pkg-name == 'langchain-core'}} diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml new file mode 100644 index 0000000..7869f67 --- /dev/null +++ b/.github/workflows/_test.yml @@ -0,0 +1,85 @@ +# Runs unit tests with both current and minimum supported dependency versions +# to ensure compatibility across the supported range. + +name: "🧪 Unit Testing" + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + python-version: + required: true + type: string + description: "Python version to use" + +permissions: + contents: read + +env: + UV_FROZEN: "true" + UV_NO_SYNC: "true" + +jobs: + # Main test job - runs unit tests with current deps, then retests with minimum versions + build: + defaults: + run: + working-directory: ${{ inputs.working-directory }} + runs-on: ubuntu-latest + timeout-minutes: 20 + name: "Python ${{ inputs.python-version }}" + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ inputs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + id: setup-python + with: + python-version: ${{ inputs.python-version }} + cache-suffix: test-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + - name: "📦 Install Test Dependencies" + shell: bash + run: uv sync --group test --dev + + - name: "🧪 Run Core Unit Tests" + shell: bash + run: | + make test PYTEST_EXTRA=-q + + - name: "🔠Calculate Minimum Dependency Versions" + working-directory: ${{ inputs.working-directory }} + id: min-version + shell: bash + run: | + VIRTUAL_ENV=.venv uv pip install packaging tomli requests + python_version="$(uv run python --version | awk '{print $2}')" + min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml pull_request $python_version)" + echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT" + echo "min-versions=$min_versions" + + - name: "🧪 Run Tests with Minimum Dependencies" + if: ${{ steps.min-version.outputs.min-versions != '' }} + env: + MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }} + run: | + VIRTUAL_ENV=.venv uv pip install $MIN_VERSIONS + make tests PYTEST_EXTRA=-q + working-directory: ${{ inputs.working-directory }} + + - name: "🧹 Verify Clean Working Directory" + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/_test_pydantic.yml b/.github/workflows/_test_pydantic.yml new file mode 100644 index 0000000..6d72a37 --- /dev/null +++ b/.github/workflows/_test_pydantic.yml @@ -0,0 +1,73 @@ +# Facilitate unit testing against different Pydantic versions for a provided package. + +name: "ðŸ Pydantic Version Testing" + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + python-version: + required: false + type: string + description: "Python version to use" + default: "3.12" + pydantic-version: + required: true + type: string + description: "Pydantic version to test." + +permissions: + contents: read + +env: + UV_FROZEN: "true" + UV_NO_SYNC: "true" + +jobs: + build: + defaults: + run: + working-directory: ${{ inputs.working-directory }} + runs-on: ubuntu-latest + timeout-minutes: 20 + name: "Pydantic ~=${{ inputs.pydantic-version }}" + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ inputs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ inputs.python-version }} + cache-suffix: test-pydantic-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + - name: "📦 Install Test Dependencies" + shell: bash + run: uv sync --group test + + - name: "🔄 Install Specific Pydantic Version" + shell: bash + env: + PYDANTIC_VERSION: ${{ inputs.pydantic-version }} + run: VIRTUAL_ENV=.venv uv pip install "pydantic~=$PYDANTIC_VERSION" + + - name: "🧪 Run Core Tests" + shell: bash + run: | + make test + + - name: "🧹 Verify Clean Working Directory" + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/_test_vcr.yml b/.github/workflows/_test_vcr.yml new file mode 100644 index 0000000..2948014 --- /dev/null +++ b/.github/workflows/_test_vcr.yml @@ -0,0 +1,66 @@ +# Runs VCR cassette-backed integration tests in playback-only mode. +# +# No API keys needed — catches stale cassettes caused by test input +# changes without re-recording. +# +# Called as part of check_diffs.yml workflow. + +name: "📼 VCR Cassette Tests" + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + description: "From which folder this pipeline executes" + python-version: + required: true + type: string + description: "Python version to use" + +permissions: + contents: read + +env: + UV_FROZEN: "true" + +jobs: + build: + defaults: + run: + working-directory: ${{ inputs.working-directory }} + runs-on: ubuntu-latest + timeout-minutes: 20 + name: "Python ${{ inputs.python-version }}" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ inputs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ inputs.python-version }} + cache-suffix: test-vcr-${{ inputs.working-directory }} + working-directory: ${{ inputs.working-directory }} + + - name: "📦 Install Test Dependencies" + shell: bash + run: uv sync --group test + + - name: "📼 Run VCR Cassette Tests (playback-only)" + shell: bash + env: + OPENAI_API_KEY: sk-fake + run: make test_vcr + + - name: "🧹 Verify Clean Working Directory" + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' diff --git a/.github/workflows/auto-label-by-package.yml b/.github/workflows/auto-label-by-package.yml new file mode 100644 index 0000000..3e9eafc --- /dev/null +++ b/.github/workflows/auto-label-by-package.yml @@ -0,0 +1,116 @@ +name: Auto Label Issues by Package + +on: + issues: + types: [opened, edited] + +permissions: + contents: read + +jobs: + label-by-package: + if: github.repository_owner == 'langchain-ai' + permissions: + issues: write + runs-on: ubuntu-latest + + steps: + - name: Sync package labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const body = context.payload.issue.body || ""; + + // Extract text under "## Package" or "### Package" (handles " (Required)" suffix and being last section) + const match = body.match(/#{2,3} Package[^\n]*\n([\s\S]*?)(?:\n#{2,3} |$)/i); + if (!match) { + core.setFailed( + `Could not find "## Package" section in issue #${context.issue.number} body. ` + + `The issue template may have changed — update the regex in this workflow.` + ); + return; + } + + const packageSection = match[1].trim(); + + // Mapping table for package names to labels + const mapping = { + "langchain": "langchain", + "langchain-openai": "openai", + "langchain-anthropic": "anthropic", + "langchain-classic": "langchain-classic", + "langchain-core": "core", + "langchain-model-profiles": "model-profiles", + "langchain-tests": "standard-tests", + "langchain-text-splitters": "text-splitters", + "langchain-chroma": "chroma", + "langchain-deepseek": "deepseek", + "langchain-exa": "exa", + "langchain-fireworks": "fireworks", + "langchain-groq": "groq", + "langchain-huggingface": "huggingface", + "langchain-mistralai": "mistralai", + "langchain-nomic": "nomic", + "langchain-ollama": "ollama", + "langchain-openrouter": "openrouter", + "langchain-perplexity": "perplexity", + "langchain-qdrant": "qdrant", + "langchain-xai": "xai", + }; + + // All possible package labels we manage + const allPackageLabels = Object.values(mapping); + const selectedLabels = []; + + // Check if this is checkbox format (multiple selection) + const checkboxMatches = packageSection.match(/- \[x\]\s+([^\n\r]+)/gi); + if (checkboxMatches) { + // Handle checkbox format + for (const match of checkboxMatches) { + const packageName = match.replace(/- \[x\]\s+/i, '').trim(); + const label = mapping[packageName]; + if (label && !selectedLabels.includes(label)) { + selectedLabels.push(label); + } + } + } else { + // Handle dropdown format (single selection) + const label = mapping[packageSection]; + if (label) { + selectedLabels.push(label); + } + } + + // Get current issue labels + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const currentLabels = issue.data.labels.map(label => label.name); + const currentPackageLabels = currentLabels.filter(label => allPackageLabels.includes(label)); + + // Determine labels to add and remove + const labelsToAdd = selectedLabels.filter(label => !currentPackageLabels.includes(label)); + const labelsToRemove = currentPackageLabels.filter(label => !selectedLabels.includes(label)); + + // Add new labels + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: labelsToAdd + }); + } + + // Remove old labels + for (const label of labelsToRemove) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: label + }); + } diff --git a/.github/workflows/block_fork_main_prs.yml b/.github/workflows/block_fork_main_prs.yml new file mode 100644 index 0000000..5ff6b9a --- /dev/null +++ b/.github/workflows/block_fork_main_prs.yml @@ -0,0 +1,146 @@ +# Block PRs whose head ref is `main` (or `master`) from a fork. This topology +# (`:master -> langchain-ai/langchain:master`) lets contributors click +# "Update branch" on the PR, producing a `Merge branch 'master' into master` +# commit on the source side that — under admin merge override — can land +# directly on `master` as a 2-parent merge commit, bypassing the repo's +# squash-only policy and polluting the changelog. +# +# `pull_request_target` is required so the job receives a token scoped to +# write PR labels/comments on fork PRs (the standard `pull_request` token is +# read-only for forks). This also means the job MUST NOT check out PR code — +# see the inline warning in the trigger block below. +# +# Maintainer bypass: add the `bypass-fork-main-check` label to the PR. + +name: Block fork main PRs + +on: + pull_request_target: + # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB. + # Doing so would allow attackers to execute arbitrary code in the context of your repository. + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + +jobs: + guard: + if: >- + github.repository_owner == 'langchain-ai' && + github.event.pull_request.head.repo.fork == true && + (github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') && + !contains(github.event.pull_request.labels.*.name, 'bypass-fork-main-check') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Close PR and post guidance + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const headRef = context.payload.pull_request.head.ref; + const marker = ''; + + // Ensure the warning label exists and apply it + const labelName = 'fork-main-head'; + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) { + throw new Error(`getLabel(${labelName}) failed: ${e.message}`); + } + try { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: 'b76e79', + }); + } catch (createErr) { + // A 422 with code `already_exists` means a race created the + // label between getLabel and createLabel — safe to ignore. + // Any other 422 (bad color, name too long) indicates a real + // bug introduced by editing this step, so rethrow. + const alreadyExists = + createErr.status === 422 && + Array.isArray(createErr.errors) && + createErr.errors.some(e => e.code === 'already_exists'); + if (!alreadyExists) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + + const defaultBranch = context.payload.repository.default_branch; + const lines = [ + marker, + `**This PR has been automatically closed** because its head branch is \`${headRef}\` on a fork.`, + '', + 'PRs opened from a fork\'s `main` (or `master`) branch can produce a `Merge branch \'main\' into main` commit on the source side. Under an admin merge override that commit can land directly on this repo\'s default branch, bypassing the squash-only policy and polluting the changelog.', + '', + 'To fix:', + `1. Sync your fork's \`${defaultBranch}\` first (\`git fetch upstream && git switch ${defaultBranch} && git merge --ff-only upstream/${defaultBranch}\`)`, + '2. Create a feature branch: `git switch -c feat/my-change`', + '3. Push it: `git push -u origin feat/my-change`', + `4. Open a new PR from \`feat/my-change\` → \`langchain-ai/langchain:${defaultBranch}\``, + '', + '*Maintainers: add the `bypass-fork-main-check` label to override.*', + ]; + const body = lines.join('\n'); + + // Dedup: update existing marker comment instead of stacking. + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (!existing) { + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body, + }); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + } + + if (context.payload.pull_request.state === 'open') { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'closed', + }); + } + + // Cancel still-queued/in-progress checks on this PR head. + // Best-effort: new runs may still queue after this loop (e.g., other + // pull_request triggers fanning out). The PR is already closed above, + // so leftover runs are wasted compute, not a correctness issue. + // We track the cancel ratio so a wholesale failure (token-scope + // regression making EVERY cancel return 403) is surfaced rather + // than silently producing N warnings + green job. + const headSha = context.payload.pull_request.head.sha; + let attempted = 0; + let cancelled = 0; + for (const status of ['in_progress', 'queued']) { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { owner, repo, head_sha: headSha, status, per_page: 100 }, + ); + for (const run of runs) { + if (run.id === context.runId) continue; + attempted++; + try { + await github.rest.actions.cancelWorkflowRun({ + owner, repo, run_id: run.id, + }); + cancelled++; + } catch (err) { + core.warning(`Could not cancel run ${run.id}: ${err.message}`); + } + } + } + if (attempted > 0 && cancelled === 0) { + core.warning(`Attempted to cancel ${attempted} run(s) on head ${headSha} but none succeeded — check token scope.`); + } + + core.setFailed(`PR head ref is \`${headRef}\` on a fork — open from a feature branch instead.`); diff --git a/.github/workflows/bump_uv_pin.yml b/.github/workflows/bump_uv_pin.yml new file mode 100644 index 0000000..571c2b6 --- /dev/null +++ b/.github/workflows/bump_uv_pin.yml @@ -0,0 +1,205 @@ +# Monthly bump of the uv pin in `.github/actions/uv_setup/action.yml`. +# +# We pin uv (rather than letting setup-uv resolve latest) because +# `releases.astral.sh` lags GitHub Releases on new uv versions, causing CI +# to flap on fresh-release days. This workflow keeps the pin fresh without +# exposing that race. +# +# Dependabot's `github-actions` ecosystem only updates `uses:` SHA pins, not +# the `UV_VERSION` env value the action passes to `astral-sh/setup-uv`, so we +# open the PR ourselves. Idempotent: if a PR for the target version already +# exists, the workflow exits without creating a duplicate. + +name: "Bump uv pin" + +on: + schedule: + - cron: "0 9 1 * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bump-uv-pin + cancel-in-progress: false + +jobs: + bump: + if: github.repository_owner == 'langchain-ai' + name: "Open PR if uv has a newer release" + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Resolve current and latest uv versions + id: versions + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + action_file=".github/actions/uv_setup/action.yml" + current=$(grep -oE 'UV_VERSION: "[0-9]+\.[0-9]+\.[0-9]+"' "$action_file" \ + | sed -E 's/UV_VERSION: "([^"]+)"/\1/' | head -n1) + latest=$(gh api repos/astral-sh/uv/releases/latest --jq .tag_name) + semver='^[0-9]+\.[0-9]+\.[0-9]+$' + if [[ ! "$current" =~ $semver ]]; then + echo "::error::Could not parse current uv pin from $action_file (got '$current')" + exit 1 + fi + if [[ ! "$latest" =~ $semver ]]; then + echo "::error::Unexpected uv tag from GitHub API (got '$latest')" + exit 1 + fi + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "latest=$latest" >> "$GITHUB_OUTPUT" + echo "branch=chore/bump-uv-$latest" >> "$GITHUB_OUTPUT" + echo "Current pin: $current" + echo "Latest uv: $latest" + + - name: Log if already up to date + # The actual skip is implemented by the `if:` guards on every + # subsequent step; this step only emits a log line so the run + # history shows why no PR was opened. + if: steps.versions.outputs.current == steps.versions.outputs.latest + run: echo "uv pin already at ${{ steps.versions.outputs.latest }}; nothing to do." + + - name: Skip if PR already open for this version + id: existing + if: steps.versions.outputs.current != steps.versions.outputs.latest + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.versions.outputs.branch }} + run: | + set -euo pipefail + count=$(gh pr list --head "$BRANCH" --state open --json number --jq 'length') + echo "count=$count" >> "$GITHUB_OUTPUT" + if [ "$count" -gt 0 ]; then + echo "Open PR already exists for $BRANCH; skipping." + fi + + - name: Wait for astral mirror to replicate + id: mirror + if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0' + env: + LATEST: ${{ steps.versions.outputs.latest }} + run: | + set -euo pipefail + # The mirror can lag GitHub Releases. If it hasn't replicated yet, + # defer the bump rather than landing a pin that races the mirror + # on every CI run. We probe several arches because partial + # replication (linux ready, macOS/aarch64 not) would still race + # CI on other runners. + assets=( + "uv-x86_64-unknown-linux-gnu.tar.gz" + "uv-aarch64-unknown-linux-gnu.tar.gz" + "uv-x86_64-apple-darwin.tar.gz" + "uv-aarch64-apple-darwin.tar.gz" + ) + ready=true + for asset in "${assets[@]}"; do + url="https://releases.astral.sh/github/uv/releases/download/${LATEST}/${asset}" + # `curl -sI` returns nothing on stderr at -s; capture exit code so a + # permanently broken DNS/TLS path is surfaced instead of collapsing + # to an opaque "000". + set +e + status=$(curl -sIo /dev/null -w '%{http_code}' --max-time 30 "$url" 2>/tmp/curl.err) + curl_rc=$? + set -e + echo "Mirror HEAD $url -> HTTP $status (curl exit=$curl_rc)" + if [ "$status" != "200" ]; then + ready=false + if [ "$curl_rc" -ne 0 ]; then + echo "::warning::curl failed for $asset (exit=$curl_rc): $(cat /tmp/curl.err 2>/dev/null || true)" + else + echo "::warning::astral mirror has not replicated $asset for uv $LATEST yet (HTTP $status)." + fi + fi + done + if [ "$ready" = "true" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::warning::Deferring uv bump to $LATEST until all probed arches are mirrored." + fi + + - name: Open bump PR + if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0' && steps.mirror.outputs.ready == 'true' + env: + GH_TOKEN: ${{ github.token }} + CURRENT: ${{ steps.versions.outputs.current }} + LATEST: ${{ steps.versions.outputs.latest }} + BRANCH: ${{ steps.versions.outputs.branch }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + action_file=".github/actions/uv_setup/action.yml" + + # `grep -c` returns 1 on no-match and 2 on read errors. We want + # "no match" surfaced as the explicit count-of-zero check below; + # read errors must abort. Capture the exit code separately so + # `set -e` doesn't swallow either case. + set +e + before=$(grep -cE "UV_VERSION: \"${CURRENT}\"" "$action_file") + before_rc=$? + set -e + if [ "$before_rc" -gt 1 ]; then + echo "::error::grep read error on $action_file (exit=$before_rc)" + exit 1 + fi + if [ "$before" -ne 1 ]; then + echo "::error::Expected exactly 1 'UV_VERSION: \"$CURRENT\"' in $action_file, found $before" + exit 1 + fi + sed -i -E "s/UV_VERSION: \"${CURRENT}\"/UV_VERSION: \"${LATEST}\"/" "$action_file" + set +e + after=$(grep -cE "UV_VERSION: \"${LATEST}\"" "$action_file") + after_rc=$? + set -e + if [ "$after_rc" -gt 1 ]; then + echo "::error::grep read error on $action_file (exit=$after_rc)" + exit 1 + fi + if [ "$after" -ne 1 ]; then + echo "::error::Expected exactly 1 'UV_VERSION: \"$LATEST\"' after sed, found $after" + exit 1 + fi + if git diff --quiet "$action_file"; then + echo "No changes after sed; bailing out (current=$CURRENT, latest=$LATEST)." + exit 1 + fi + + # Reuse-or-recreate orphan branch from a prior run that pushed + # but failed before `gh pr create` (no open PR sits on it). + # The delete can race a concurrent run (manual workflow_dispatch + # firing while the cron is mid-flight, since concurrency group + # does not cancel-in-progress); fall through with a warning so a + # losing race does not kill an otherwise-clean job mid-state. + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "::warning::Branch $BRANCH exists on origin without an open PR; deleting before recreating." + if ! git push origin --delete "$BRANCH"; then + echo "::warning::Delete of $BRANCH failed (concurrent run, or branch already gone); the subsequent push will surface any real conflict." + fi + fi + + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add "$action_file" + git commit -m "chore(deps): bump uv to $LATEST" + git push --set-upstream origin "$BRANCH" + + body_file="$(mktemp)" + { + printf 'Bumps the uv pin in `.github/actions/uv_setup/action.yml` from `%s` to [`%s`](https://github.com/astral-sh/uv/releases/tag/%s).\n\n' "$CURRENT" "$LATEST" "$LATEST" + printf 'Opened automatically by `bump_uv_pin.yml`. Mirror availability on `releases.astral.sh` was verified before this PR was created, so CI should not race the fallback.\n' + } > "$body_file" + + gh pr create \ + --head "$BRANCH" \ + --base "$DEFAULT_BRANCH" \ + --title "chore(deps): bump uv to $LATEST" \ + --body-file "$body_file" diff --git a/.github/workflows/check_agents_sync.yml b/.github/workflows/check_agents_sync.yml new file mode 100644 index 0000000..851a33a --- /dev/null +++ b/.github/workflows/check_agents_sync.yml @@ -0,0 +1,43 @@ +# Ensures CLAUDE.md and AGENTS.md stay synchronized. +# +# These files contain the same development guidelines but are named differently +# for compatibility with different AI coding assistants (Claude Code uses CLAUDE.md, +# other tools may use AGENTS.md). + +name: "🔄 Check CLAUDE.md / AGENTS.md Sync" + +on: + push: + branches: [master] + paths: + - "CLAUDE.md" + - "AGENTS.md" + pull_request: + paths: + - "CLAUDE.md" + - "AGENTS.md" + +permissions: + contents: read + +jobs: + check-sync: + name: "verify files are identical" + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "🔠Check CLAUDE.md and AGENTS.md are in sync" + run: | + if ! diff -q CLAUDE.md AGENTS.md > /dev/null 2>&1; then + echo "⌠CLAUDE.md and AGENTS.md are out of sync!" + echo "" + echo "These files must contain identical content." + echo "Differences:" + echo "" + diff --color=always CLAUDE.md AGENTS.md || true + exit 1 + fi + echo "✅ CLAUDE.md and AGENTS.md are in sync" diff --git a/.github/workflows/check_diffs.yml b/.github/workflows/check_diffs.yml new file mode 100644 index 0000000..949c20a --- /dev/null +++ b/.github/workflows/check_diffs.yml @@ -0,0 +1,235 @@ +# Primary CI workflow. +# +# Only runs against packages that have changed files. +# +# Runs: +# - Linting (_lint.yml) +# - Unit Tests (_test.yml) +# - Pydantic compatibility tests (_test_pydantic.yml) +# - Integration test compilation checks (_compile_integration_test.yml) +# - Extended test suites that require additional dependencies +# +# Reports status to GitHub checks and PR status. + +name: "🔧 CI" + +on: + push: + branches: [master] + pull_request: + merge_group: + +# Optimizes CI performance by canceling redundant workflow runs +# If another push to the same PR or branch happens while this workflow is still running, +# cancel the earlier run in favor of the next run. +# +# There's no point in testing an outdated version of the code. GitHub only allows +# a limited number of job runners to be active at the same time, so it's better to +# cancel pointless jobs early so that more useful jobs can run sooner. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + UV_FROZEN: "true" + UV_NO_SYNC: "true" + +jobs: + # This job analyzes which files changed and creates a dynamic test matrix + # to only run tests/lints for the affected packages, improving CI efficiency + build: + name: "Detect Changes & Set Matrix" + runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.labels.*.name, 'ci-ignore') }} + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - name: "ðŸ Setup Python 3.11" + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: "📂 Get Changed Files" + id: files + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + format: json + - name: "🔠Analyze Changed Files & Generate Build Matrix" + id: set-matrix + env: + ALL_CHANGED_FILES: ${{ steps.files.outputs.all }} + run: | + python -m pip install packaging requests + python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $GITHUB_OUTPUT + outputs: + lint: ${{ steps.set-matrix.outputs.lint }} + test: ${{ steps.set-matrix.outputs.test }} + extended-tests: ${{ steps.set-matrix.outputs.extended-tests }} + compile-integration-tests: ${{ steps.set-matrix.outputs.compile-integration-tests }} + dependencies: ${{ steps.set-matrix.outputs.dependencies }} + test-pydantic: ${{ steps.set-matrix.outputs.test-pydantic }} + vcr-tests: ${{ steps.set-matrix.outputs.vcr-tests }} + # Run linting only on packages that have changed files + lint: + needs: [build] + if: ${{ needs.build.outputs.lint != '[]' }} + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.lint) }} + fail-fast: false + uses: ./.github/workflows/_lint.yml + with: + working-directory: ${{ matrix.job-configs.working-directory }} + python-version: ${{ matrix.job-configs.python-version }} + secrets: inherit + + # Run unit tests only on packages that have changed files + test: + needs: [build] + if: ${{ needs.build.outputs.test != '[]' }} + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.test) }} + fail-fast: false + uses: ./.github/workflows/_test.yml + with: + working-directory: ${{ matrix.job-configs.working-directory }} + python-version: ${{ matrix.job-configs.python-version }} + secrets: inherit + + # Test compatibility with different Pydantic versions for affected packages + test-pydantic: + needs: [build] + if: ${{ needs.build.outputs.test-pydantic != '[]' }} + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.test-pydantic) }} + fail-fast: false + uses: ./.github/workflows/_test_pydantic.yml + with: + working-directory: ${{ matrix.job-configs.working-directory }} + pydantic-version: ${{ matrix.job-configs.pydantic-version }} + secrets: inherit + + # Verify integration tests compile without actually running them (faster feedback) + compile-integration-tests: + name: "Compile Integration Tests" + needs: [build] + if: ${{ needs.build.outputs.compile-integration-tests != '[]' }} + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.compile-integration-tests) }} + fail-fast: false + uses: ./.github/workflows/_compile_integration_test.yml + with: + working-directory: ${{ matrix.job-configs.working-directory }} + python-version: ${{ matrix.job-configs.python-version }} + secrets: inherit + + # Run VCR cassette-backed integration tests in playback-only mode (no API keys) + vcr-tests: + name: "VCR Cassette Tests" + needs: [build] + if: ${{ needs.build.outputs.vcr-tests != '[]' }} + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.vcr-tests) }} + fail-fast: false + uses: ./.github/workflows/_test_vcr.yml + with: + working-directory: ${{ matrix.job-configs.working-directory }} + python-version: ${{ matrix.job-configs.python-version }} + secrets: inherit + + # Run extended test suites that require additional dependencies + extended-tests: + name: "Extended Tests" + needs: [build] + if: ${{ needs.build.outputs.extended-tests != '[]' }} + strategy: + matrix: + # note different variable for extended test dirs + job-configs: ${{ fromJson(needs.build.outputs.extended-tests) }} + fail-fast: false + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ${{ matrix.job-configs.working-directory }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python ${{ matrix.job-configs.python-version }} + UV" + uses: "./.github/actions/uv_setup" + with: + python-version: ${{ matrix.job-configs.python-version }} + cache-suffix: extended-tests-${{ matrix.job-configs.working-directory }} + working-directory: ${{ matrix.job-configs.working-directory }} + + - name: "📦 Install Dependencies & Run Extended Tests" + shell: bash + run: | + echo "Running extended tests, installing dependencies with uv..." + uv venv + uv sync --group test + VIRTUAL_ENV=.venv uv pip install -r extended_testing_deps.txt + VIRTUAL_ENV=.venv make extended_tests + + - name: "🧹 Verify Clean Working Directory" + shell: bash + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' + + # Verify _release.yml dropdown options stay in sync with package directories + check-release-options: + name: "Validate Release Options" + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - name: "ðŸ Setup Python 3.11" + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: "📦 Install Dependencies" + run: python -m pip install pyyaml pytest + - name: "🔠Check release dropdown matches packages" + run: python -m pytest .github/scripts/test_release_options.py -v + + # Final status check - ensures all required jobs passed before allowing merge + ci_success: + name: "✅ CI Success" + needs: + [ + build, + lint, + test, + compile-integration-tests, + vcr-tests, + extended-tests, + test-pydantic, + check-release-options, + ] + if: | + always() + runs-on: ubuntu-latest + env: + JOBS_JSON: ${{ toJSON(needs) }} + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}} + steps: + - name: "🎉 All Checks Passed" + run: | + echo $JOBS_JSON + echo $RESULTS_JSON + echo "Exiting with $EXIT_CODE" + exit $EXIT_CODE diff --git a/.github/workflows/check_extras_sync.yml b/.github/workflows/check_extras_sync.yml new file mode 100644 index 0000000..f76bfe4 --- /dev/null +++ b/.github/workflows/check_extras_sync.yml @@ -0,0 +1,73 @@ +# See `.github/scripts/check_extras_sync.py` for the rationale. + +name: "🔠Check Extras Sync" + +on: + pull_request: + paths: + - "libs/**/pyproject.toml" + - ".github/scripts/check_extras_sync.py" + - ".github/workflows/check_extras_sync.yml" + push: + branches: [master] + paths: + - "libs/**/pyproject.toml" + - ".github/scripts/check_extras_sync.py" + - ".github/workflows/check_extras_sync.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-extras-sync: + if: github.repository_owner == 'langchain-ai' + name: "Verify extras match required deps" + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "ðŸ Set up Python and uv" + uses: "./.github/actions/uv_setup" + with: + python-version: "3.13" + enable-cache: "false" + + - name: "🔠Check extras sync" + # Iterate every package pyproject.toml under libs/. The script + # no-ops on packages without [project.optional-dependencies], so + # this is harmless on packages without extras and automatically + # picks up new partners as they're added. No `-maxdepth` cap so + # deeper future restructures (e.g. `libs/partners///`) + # are picked up automatically. + run: | + set -euo pipefail + mapfile -t files < <( + find libs -name pyproject.toml \ + -not -path "*/.venv/*" \ + -not -path "*/node_modules/*" \ + -not -path "*/build/*" \ + -not -path "*/dist/*" \ + -not -path "*/.tox/*" \ + | sort + ) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::No pyproject.toml files found under libs/" + exit 1 + fi + failed=() + for f in "${files[@]}"; do + if ! python .github/scripts/check_extras_sync.py "$f"; then + failed+=("$f") + fi + done + if [ ${#failed[@]} -gt 0 ]; then + echo "::error::Extras-sync check failed for ${#failed[@]} package(s):" + printf '::error:: %s\n' "${failed[@]}" + exit 1 + fi diff --git a/.github/workflows/check_release_deps.yml b/.github/workflows/check_release_deps.yml new file mode 100644 index 0000000..aef6b9b --- /dev/null +++ b/.github/workflows/check_release_deps.yml @@ -0,0 +1,288 @@ +# Validate that a release PR's declared dependencies are actually published on +# PyPI *before* the package itself is released. +# +# WHY: `release(scope): x.y.z` PRs frequently bump intra-monorepo minimum pins +# (e.g. `langchain-core>=1.4.4`). The regular PR test suite deliberately SKIPS +# minimum-version resolution for langchain-core / langchain / langchain-text-splitters +# (see `SKIP_IF_PULL_REQUEST` in `.github/scripts/get_min_versions.py`) because normal +# feature PRs may bump those in lockstep with an as-yet-unpublished sibling release. +# +# For a `release` PR, though, every runtime dependency should already be on PyPI +# unless that dependency is another package version introduced by the same PR. +# If a pin points at any other version that does not exist yet, the published wheel's +# metadata is unresolvable and `pip install ==x.y.z` breaks for end users. Without this +# workflow, that is only caught at release-trigger time, when `get_min_versions.py` +# resolves the pins against PyPI (its companion change in this PR now exits loudly on +# an unpublished pin instead of emitting `pkg==None`). This workflow adds a second, +# earlier guard: it shifts the same check left onto the release PR, so the author +# finds out before merge rather than when the release job runs. +# +# HOW: for each changed manifest, diff it against the PR base to find packages whose +# own version is bumped by this PR, then resolve each package's runtime dependencies +# against real PyPI with `uv pip compile --no-sources` — which ignores the editable +# `[tool.uv.sources]` workspace overrides so intra-monorepo deps resolve from the index +# exactly as an end user's installer would see them. Dependencies that a same-PR version +# bump satisfies are stripped first (their wheels are not published until merge); +# everything else must resolve. This reads package index, git, and TOML metadata only — +# it does not build or run the PR's own project code. + +name: "🚀 Check Release Dependencies" + +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + paths: + - "libs/**/pyproject.toml" + +permissions: + contents: read + +jobs: + check-release-deps: + name: "✅ Verify release dependencies exist on PyPI" + # Only run for release PRs (`release(scope): x.y.z`). Other PRs may bump + # intra-monorepo pins ahead of a sibling release on purpose. Maintainers can + # acknowledge an unusual coordinated release with the bypass label. + if: >- + github.repository_owner == 'langchain-ai' && + startsWith(github.event.pull_request.title, 'release') && + !contains(github.event.pull_request.labels.*.name, 'release-deps: acknowledged') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + fetch-depth: 0 + + - name: "ðŸ Set up Python + uv" + uses: "./.github/actions/uv_setup" + with: + python-version: "3.12" + enable-cache: "false" + + - name: "🔠Resolve runtime dependencies against PyPI" + shell: bash + env: + # Sourced from env so the `${{ }}` expansion never lands in the `run:` + # block; the SHAs reach git only via list-form subprocess (no shell). + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + # pyproject.toml manifests changed by this PR. + mapfile -t changed < <( + git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'libs/**/pyproject.toml' + ) + + if [ "${#changed[@]}" -eq 0 ]; then + # The `paths:` filter should prevent this, so an empty list is + # surprising — surface it loudly rather than passing silently. + echo "::notice::No libs/**/pyproject.toml changed in this PR; nothing to validate." + exit 0 + fi + + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + # Step 1: detect packages whose own version is bumped by this PR. Their + # wheels are not on PyPI until merge, so dependencies the new version + # satisfies are stripped before resolving. Emits one TSV row per bump: + # `\t\t`. `uv run --with packaging` + # guarantees the PEP 508/440 parser is available on the runner. + printf '%s\n' "${changed[@]}" > "$tmp_dir/changed.txt" + uv run -q --no-project --with packaging python - "$BASE_SHA" "$tmp_dir/changed.txt" "$tmp_dir/released.txt" <<'PY' + import subprocess + import sys + import tomllib + from pathlib import Path + + from packaging.utils import canonicalize_name + + base_sha = sys.argv[1] + changed_path = Path(sys.argv[2]) + released_path = Path(sys.argv[3]) + + def project_table(text: str) -> dict: + return tomllib.loads(text).get("project") or {} + + released = [] + for manifest in changed_path.read_text(encoding="utf-8").splitlines(): + current = project_table(Path(manifest).read_text(encoding="utf-8")) + name, version = current.get("name"), current.get("version") + if name is None or version is None: + # No static name/version (e.g. a dynamic version) — it cannot be + # compared against the base, so cannot be a same-PR bump. + print(f"::warning file={manifest}::no static project.name/version; skipping bump detection") + continue + shown = subprocess.run( + ["git", "show", f"{base_sha}:{manifest}"], + capture_output=True, + text=True, + ) + if shown.returncode != 0: + stderr = shown.stderr.strip() + if "does not exist" in stderr or "exists on disk, but not in" in stderr: + # New manifest: absent at the base ref, so not a version bump. + continue + print(f"::error file={manifest}::failed to read base manifest: {stderr}") + sys.exit(1) + base = project_table(shown.stdout) + if base.get("name") == name and base.get("version") not in (None, version): + released.append(f"{canonicalize_name(name)}\t{name}\t{version}") + + released_path.write_text("".join(f"{row}\n" for row in released), encoding="utf-8") + PY + + if [ -s "$tmp_dir/released.txt" ]; then + echo "The following package versions are introduced by this PR and may be referenced before PyPI publication:" + cut -f2- "$tmp_dir/released.txt" | sed 's/^/ - /' + else + echo "No package version bumps found in changed manifests." + fi + + failed=0 + transient=0 + for manifest in "${changed[@]}"; do + pkg_dir="$(dirname "$manifest")" + filtered_dir="$tmp_dir/${manifest//\//__}.dir" + mkdir -p "$filtered_dir" + filtered_manifest="$filtered_dir/pyproject.toml" + # Step 2: rebuild a resolver-equivalent manifest that drops only the + # dependencies a same-PR version bump satisfies. `[tool.uv]` keys that + # affect resolution (prerelease, constraint/override deps) are preserved + # — e.g. `langchain-fireworks` needs `prerelease = "allow"` to resolve + # its prerelease-only `fireworks-ai` pin. Skipped deps print here, before + # the resolver group opens, so the exclusions stay visible on a green run. + uv run -q --no-project --with packaging python - "$manifest" "$filtered_manifest" "$tmp_dir/released.txt" <<'PY' + import json + import sys + import tomllib + from pathlib import Path + + from packaging.requirements import InvalidRequirement, Requirement + from packaging.utils import canonicalize_name + + manifest_path = Path(sys.argv[1]) + filtered_path = Path(sys.argv[2]) + released_path = Path(sys.argv[3]) + + released_versions = {} + for line in released_path.read_text(encoding="utf-8").splitlines(): + canonical_name, _name, version = line.split("\t", maxsplit=2) + released_versions[canonical_name] = version + + def is_same_pr_bump(dependency: str) -> bool: + try: + requirement = Requirement(dependency) + except InvalidRequirement: + # Keep anything we cannot parse so the resolver judges it. + return False + version = released_versions.get(canonicalize_name(requirement.name)) + if version is None: + return False + # Strip only when the just-bumped version satisfies the pin; a pin to + # any other (still unpublished) version must keep resolving. + return requirement.specifier.contains(version, prereleases=True) + + data = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + project = data.get("project") + if project is None: + print(f"::error file={manifest_path}::no [project] table to resolve") + sys.exit(1) + + filtered_dependencies, skipped_dependencies = [], [] + for dependency in project.get("dependencies", []): + bucket = skipped_dependencies if is_same_pr_bump(dependency) else filtered_dependencies + bucket.append(dependency) + + def toml_string(value: str) -> str: + return json.dumps(value) + + lines = ["[project]"] + lines.append(f"name = {toml_string(project.get('name') or 'release-deps-check')}") + lines.append(f"version = {toml_string(project.get('version') or '0.0.0')}") + if "requires-python" in project: + lines.append(f"requires-python = {toml_string(project['requires-python'])}") + lines.append("dependencies = [") + lines += [f" {toml_string(dependency)}," for dependency in filtered_dependencies] + lines.append("]") + + # Preserve the `[tool.uv]` keys that change PyPI resolution. `[tool.uv.sources]` + # is intentionally dropped — `uv pip compile --no-sources` ignores it anyway. + tool_uv = (data.get("tool") or {}).get("uv") or {} + uv_lines = [] + if isinstance(tool_uv.get("prerelease"), str): + uv_lines.append(f"prerelease = {toml_string(tool_uv['prerelease'])}") + for key in ("constraint-dependencies", "override-dependencies"): + values = tool_uv.get(key) or [] + if values: + uv_lines.append(f"{key} = [") + uv_lines += [f" {toml_string(value)}," for value in values] + uv_lines.append("]") + if uv_lines: + lines.append("") + lines.append("[tool.uv]") + lines += uv_lines + + filtered_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + if skipped_dependencies: + print("Ignoring dependencies satisfied by package versions introduced by this PR:") + for dependency in skipped_dependencies: + print(f" - {dependency}") + PY + + echo "::group::Resolving ${manifest} against PyPI" + # --no-sources ignores [tool.uv.sources] editable workspace overrides, + # so intra-monorepo deps resolve from PyPI like an end-user install. + # --universal resolves across the full requires-python range, so deps + # gated behind Python-version markers are validated too. + if uv pip compile --no-sources --universal "$filtered_manifest" > "$filtered_dir/compile.log" 2>&1; then + echo "✅ ${pkg_dir}: all runtime dependencies resolve on PyPI or are released by this PR" + else + # Surface the resolver's reason (stdout+stderr were captured) and tell a + # likely-transient index/network error apart from a genuinely bad pin. + cat "$filtered_dir/compile.log" + if grep -qiE 'error sending request|failed to fetch|error trying to connect|connection|timed out|temporarily unavailable|status code (429|50[0-9])' "$filtered_dir/compile.log"; then + echo "⌠${pkg_dir}: resolver hit a possible transient PyPI/index error" + transient=1 + else + echo "⌠${pkg_dir}: a dependency pin is not satisfiable on PyPI" + fi + failed=1 + fi + echo "::endgroup::" + done + + if [ "$failed" -ne 0 ]; then + if [ "$transient" -ne 0 ]; then + echo "::warning::A failure looked like a network/index error rather than an unsatisfiable pin — re-running the job may clear it." + fi + cat >&2 <<'EOF' + + ┌──────────────────────────────────────────────────────────────────┠+ │ One or more dependency pins could not be resolved from PyPI. │ + │ See the per-package resolver output above for the exact reason. │ + └──────────────────────────────────────────────────────────────────┘ + + Dependencies on package versions introduced by this PR are ignored, + because coordinated release metadata may point at wheels that are not + published until this PR merges. Any remaining failure means the released + wheel metadata may be unresolvable for end users. + + Fix by: + • Releasing the dependency package first so the pinned version exists + on PyPI, then re-running this check; or + • Relaxing the version pin to a published version. + + If this is an intentional coordinated release outside the detected + package version bumps, a maintainer may add the label + `release-deps: acknowledged` to bypass this check after reviewing the + install risk. + + If the resolver output above shows a network/index error (rather than + "No solution found"), this may be a transient PyPI issue — re-run the job. + EOF + exit 1 + fi diff --git a/.github/workflows/check_versions.yml b/.github/workflows/check_versions.yml new file mode 100644 index 0000000..1b69843 --- /dev/null +++ b/.github/workflows/check_versions.yml @@ -0,0 +1,55 @@ +# Ensures version numbers in pyproject.toml and _version.py stay in sync. +# +# (Prevents releases with mismatched version numbers) + +name: "Check Version Equality" + +on: + pull_request: + paths: + - "libs/core/pyproject.toml" + - "libs/core/langchain_core/version.py" + - "libs/langchain_v1/pyproject.toml" + - "libs/langchain_v1/langchain/__init__.py" + - "libs/partners/*/pyproject.toml" + - "libs/partners/**/_version.py" + +permissions: + contents: read + +jobs: + check_version_equality: + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7 + with: + python-version: "3.12" + + - name: "Verify pyproject.toml & version files match" + run: | + FAILED=0 + + for dir in libs/core libs/langchain_v1 libs/partners/*; do + [ -f "$dir/Makefile" ] || continue + if grep -q '^check_version:' "$dir/Makefile"; then + echo "--- $dir ---" + make -C "$dir" check_version || FAILED=1 + elif find "$dir" -maxdepth 2 -name '_version.py' -not -path '*/tests/*' \ + | grep -q .; then + # A package ships a _version.py but has no way to verify it stays + # in sync with pyproject.toml. Don't let it pass unchecked. + echo "--- $dir ---" + echo "Error: $dir has a _version.py but no 'check_version' Makefile target" + FAILED=1 + fi + done + + if [ "$FAILED" -ne 0 ]; then + echo "" + echo "One or more version checks failed!" + exit 1 + fi diff --git a/.github/workflows/close_unchecked_issues.yml b/.github/workflows/close_unchecked_issues.yml new file mode 100644 index 0000000..b980eef --- /dev/null +++ b/.github/workflows/close_unchecked_issues.yml @@ -0,0 +1,197 @@ +# Auto-close issues that bypass or ignore the issue template checkboxes. +# +# GitHub issue forms enforce `required: true` checkboxes in the web UI, +# but the API bypasses form validation entirely — bots/scripts can open +# issues with every box unchecked or skip the template altogether. +# +# Rules: +# 0. No issue type -> close unless author is an org member +# 1. No checkboxes at all -> close unless author is an org member or bot +# 2. Checkboxes present but none checked -> close +# 3. "Submission checklist" section incomplete -> close +# 4. "Package (Required)" section has no selection -> close +# +# Org membership check reuses the shared helper from pr-labeler.js and +# the same GitHub App used by tag-external-issues.yml. + +name: Close Unchecked Issues + +on: + issues: + types: [opened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + check-boxes: + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Validate issue checkboxes + if: steps.app-token.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + const body = context.payload.issue.body ?? ''; + const allChecked = (body.match(/- \[x\]/gi) || []).length; + const allUnchecked = (body.match(/- \[ \]/g) || []).length; + const total = allChecked + allUnchecked; + + // ── Helpers ───────────────────────────────────────────────── + // Extract checkboxes under a markdown H2/H3 heading. + // Returns { checked, unchecked } counts, or null if the + // section heading is not found in the body. + function parseSection(heading) { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Find the heading line + const headingRe = new RegExp(`^#{2,3}\\s+${escaped}\\s*$`, 'm'); + const headingMatch = headingRe.exec(body); + if (!headingMatch) return null; + // Slice from after the heading to the next heading or end + const rest = body.slice(headingMatch.index + headingMatch[0].length); + const nextHeading = rest.search(/\n#{2,3}\s/); + const block = nextHeading === -1 ? rest : rest.slice(0, nextHeading); + return { + checked: (block.match(/- \[x\]/gi) || []).length, + unchecked: (block.match(/- \[ \]/g) || []).length, + }; + } + + let _cachedMember; + async function isOrgMember() { + if (_cachedMember) return _cachedMember; + const { h } = require('./.github/scripts/pr-labeler.js') + .loadAndInit(github, owner, repo, core); + const author = context.payload.sender.login; + const { isExternal } = await h.checkMembership( + author, context.payload.sender.type, + ); + _cachedMember = { internal: !isExternal, author }; + return _cachedMember; + } + + async function closeWithComment(lines) { + const templateUrl = `https://github.com/${owner}/${repo}/issues/new/choose`; + lines.push( + '', + `Please use one of the [issue templates](${templateUrl}).`, + ); + + // Post comment first so the author sees the reason even if + // the subsequent close call fails. + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: lines.join('\n'), + }); + + await github.rest.issues.update({ + owner, repo, issue_number, + state: 'closed', + state_reason: 'not_planned', + }); + } + + // ── Rule 0: no issue type (API/CLI bypass) ────────────────── + // Issue types are set automatically when using web UI templates. + // External users cannot set issue types via the API (requires + // write/triage permissions), so a missing type reliably indicates + // programmatic submission. + if (!context.payload.issue.type) { + let membership; + try { + membership = await isOrgMember(); + } catch (e) { + // Org membership check failed — skip Rule 0 and let + // Rules 1-4 handle validation via checkboxes. + core.warning(`Rule 0: org membership check failed, skipping: ${e.message}`); + } + if (membership?.internal) { + console.log(`No issue type, but ${membership.author} is internal — OK`); + } else if (membership) { + console.log(`No issue type and ${membership.author} is external — closing`); + await closeWithComment([ + 'This issue was automatically closed because it appears to have been submitted programmatically — issue types are automatically set when using the GitHub web interface, and this issue has none.', + '', + 'We do not allow automated issue submission at this time.', + ]); + return; + } + } + + // ── Rule 1: no checkboxes at all ──────────────────────────── + if (total === 0) { + const { internal, author } = await isOrgMember(); + if (internal) { + console.log(`No checkboxes, but ${author} is internal — OK`); + return; + } + console.log(`No checkboxes and ${author} is external — closing`); + await closeWithComment([ + 'This issue was automatically closed because no issue template was used.', + ]); + return; + } + + // ── Rule 2: checkboxes present but none checked ───────────── + if (allChecked === 0) { + console.log(`${allUnchecked} checkbox(es) present, none checked — closing`); + await closeWithComment([ + 'This issue was automatically closed because none of the required checkboxes were checked. Please re-file using an issue template and complete the checklist.', + ]); + return; + } + + // ── Rules 3–4: parse sections for targeted feedback ───────── + const checklist = parseSection('Submission checklist'); + const pkg = parseSection('Package (Required)'); + console.log(`Section parse — checklist: ${JSON.stringify(checklist)}, pkg: ${JSON.stringify(pkg)}`); + + const problems = []; + + if (checklist && checklist.unchecked > 0) { + problems.push( + 'the submission checklist is incomplete — please confirm you searched for duplicates, included a reproduction, etc.' + ); + } + if (pkg !== null && pkg.checked === 0) { + problems.push( + 'no package was selected (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team' + ); + } else if (pkg === null) { + problems.push( + 'the package selection is missing (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team' + ); + } + + if (problems.length === 0) { + console.log(`All section checks passed (${allChecked} checked) — OK`); + return; + } + + console.log(`Closing — problems: ${problems.join('; ')}`); + await closeWithComment([ + 'Thanks for opening an issue! It was automatically closed because:', + '', + ...problems.map(p => `- ${p}`), + ]); diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..7140373 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,85 @@ +# CodSpeed performance benchmarks. +# +# Runs benchmarks on changed packages and uploads results to CodSpeed. +# Separated from the main CI workflow so that push-to-master baseline runs +# are never cancelled by subsequent merges (cancel-in-progress is only +# enabled for pull_request events). + +name: "âš¡ CodSpeed" + +on: + push: + branches: [master] + pull_request: + +# On PRs, cancel stale runs when new commits are pushed. +# On push-to-master, never cancel — these runs populate CodSpeed baselines. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + UV_FROZEN: "true" + UV_NO_SYNC: "true" + +jobs: + build: + name: "Detect Changes" + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'langchain-ai' && !contains(github.event.pull_request.labels.*.name, 'codspeed-ignore') }} + steps: + - name: "📋 Checkout Code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - name: "ðŸ Setup Python 3.11" + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: "📂 Get Changed Files" + id: files + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + format: json + - name: "🔠Analyze Changed Files" + id: set-matrix + env: + ALL_CHANGED_FILES: ${{ steps.files.outputs.all }} + run: | + python -m pip install packaging requests + python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $GITHUB_OUTPUT + outputs: + codspeed: ${{ steps.set-matrix.outputs.codspeed }} + + benchmarks: + name: "âš¡ CodSpeed Benchmarks" + needs: [build] + if: ${{ needs.build.outputs.codspeed != '[]' }} + runs-on: codspeed-macro + strategy: + matrix: + job-configs: ${{ fromJson(needs.build.outputs.codspeed) }} + fail-fast: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: "📦 Install UV Package Manager" + uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7 + with: + # Pinned to 3.13.11 to work around CodSpeed walltime segfault on 3.13.12+ + # See: https://github.com/CodSpeedHQ/pytest-codspeed/issues/106 + python-version: "3.13.11" + + - name: "📦 Install Test Dependencies" + run: uv sync --group test + working-directory: ${{ matrix.job-configs.working-directory }} + + - name: "âš¡ Run Benchmarks: ${{ matrix.job-configs.working-directory }}" + uses: CodSpeedHQ/action@a50965600eafa04edcd6717761f55b77e52aafbd # v4 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: | + cd ${{ matrix.job-configs.working-directory }} + uv run --no-sync pytest ./tests/benchmarks --codspeed + mode: ${{ matrix.job-configs.codspeed-mode }} diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 0000000..633b931 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,408 @@ +# Routine integration tests against partner libraries with live API credentials. +# +# Uses `make integration_tests` within each library being tested. +# +# Runs daily with the option to trigger manually. + +name: "â° Integration Tests" +run-name: "Run Integration Tests - ${{ inputs.working-directory-override || (inputs.working-directory != 'all' && inputs.working-directory) || (inputs.exclude != '' && format('exclude:{0}', inputs.exclude)) || 'all libs' }} (Python ${{ inputs.python-version-override || '3.10, 3.14' }})" + +on: + workflow_dispatch: + inputs: + working-directory: + type: choice + description: "Library to test (select from dropdown)" + default: "all" + # Short names only — the `compute-matrix` job re-adds the `libs/` or + # `libs/partners/` prefix. When adding a new option, also update the + # `case` statement in `compute-matrix` if it isn't a partner package + # (partners are the default branch). + options: + - "all" + - "core" + - "langchain" + - "langchain_v1" + - "text-splitters" + - "standard-tests" + - "model-profiles" + - "anthropic" + - "aws" + - "chroma" + - "deepseek" + - "exa" + - "fireworks" + - "google-genai" + - "google-vertexai" + - "groq" + - "huggingface" + - "mistralai" + - "nomic" + - "ollama" + - "openai" + - "openrouter" + - "perplexity" + - "qdrant" + - "xai" + working-directory-override: + type: string + description: "Manual override — takes precedence over dropdown (e.g. libs/partners/partner-xyz)" + exclude: + type: string + description: "Comma-separated short names to drop from the 'all' run (e.g. openai,anthropic). Ignored unless dropdown is 'all' and no working-directory-override is set." + python-version-override: + type: string + description: "Python version override — defaults to 3.10 and 3.14 in matrix (e.g. 3.11)" + schedule: + - cron: "0 13 * * *" # Runs daily at 1PM UTC (9AM EDT/6AM PDT) + +permissions: + contents: read + +env: + UV_FROZEN: "true" + DEFAULT_LIBS: >- + ["libs/partners/openai", + "libs/partners/anthropic", + "libs/partners/fireworks", + "libs/partners/groq", + "libs/partners/mistralai", + "libs/partners/xai", + "libs/partners/google-vertexai", + "libs/partners/google-genai", + "libs/partners/aws"] + +jobs: + # Generate dynamic test matrix based on input parameters or defaults + # Only runs on the main repo (for scheduled runs) or when manually triggered + compute-matrix: + # Defend against forks running scheduled jobs, but allow manual runs from forks + if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule' + + runs-on: ubuntu-latest + name: "📋 Compute Test Matrix" + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + python-version-min-3-11: ${{ steps.set-matrix.outputs.python-version-min-3-11 }} + steps: + - name: "🔢 Generate Python & Library Matrix" + id: set-matrix + env: + DEFAULT_LIBS: ${{ env.DEFAULT_LIBS }} + WORKING_DIRECTORY_OVERRIDE: ${{ github.event.inputs.working-directory-override || '' }} + WORKING_DIRECTORY_CHOICE: ${{ github.event.inputs.working-directory || 'all' }} + PYTHON_VERSION_OVERRIDE: ${{ github.event.inputs.python-version-override || '' }} + EXCLUDE: ${{ github.event.inputs.exclude || '' }} + run: | + # echo "matrix=..." where matrix is a json formatted str with keys python-version and working-directory + # python-version defaults to 3.10 and 3.14, overridden to [PYTHON_VERSION_OVERRIDE] if set + # working-directory priority: override string > dropdown choice > DEFAULT_LIBS + python_version='["3.10", "3.14"]' + python_version_min_3_11='["3.11", "3.14"]' + working_directory="$DEFAULT_LIBS" + if [ -n "$PYTHON_VERSION_OVERRIDE" ]; then + python_version="[\"$PYTHON_VERSION_OVERRIDE\"]" + # Bound override version to >= 3.11 for packages requiring it + if [ "$(echo "$PYTHON_VERSION_OVERRIDE >= 3.11" | bc -l)" -eq 1 ]; then + python_version_min_3_11="[\"$PYTHON_VERSION_OVERRIDE\"]" + else + python_version_min_3_11='["3.11"]' + fi + fi + if [ -n "$WORKING_DIRECTORY_OVERRIDE" ]; then + working_directory="[\"$WORKING_DIRECTORY_OVERRIDE\"]" + elif [ "$WORKING_DIRECTORY_CHOICE" != "all" ]; then + # Map short dropdown name back to full path + case "$WORKING_DIRECTORY_CHOICE" in + core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles) + working_directory="[\"libs/$WORKING_DIRECTORY_CHOICE\"]" + ;; + *) + working_directory="[\"libs/partners/$WORKING_DIRECTORY_CHOICE\"]" + ;; + esac + elif [ -n "$EXCLUDE" ]; then + # Only honored on the 'all' run (no override, dropdown left at 'all'). + # Map each comma-separated short name to its full path (mirroring the + # case statement above), then subtract from the DEFAULT_LIBS array. + exclude_paths='[]' + IFS=',' read -ra exclude_names <<< "$EXCLUDE" + for name in "${exclude_names[@]}"; do + # Trim surrounding whitespace so "openai, anthropic" works. + name="${name#"${name%%[![:space:]]*}"}" # ltrim + name="${name%"${name##*[![:space:]]}"}" # rtrim + [ -z "$name" ] && continue + case "$name" in + core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles) + path="libs/$name" + ;; + *) + path="libs/partners/$name" + ;; + esac + exclude_paths="$(jq -nc --argjson acc "$exclude_paths" --arg path "$path" '$acc + [$path]')" + done + working_directory="$(jq -nc --argjson libs "$working_directory" --argjson excl "$exclude_paths" '$libs - $excl')" + fi + matrix="{\"python-version\": $python_version, \"working-directory\": $working_directory}" + echo "$matrix" + echo "matrix=$matrix" >> $GITHUB_OUTPUT + echo "python-version-min-3-11=$python_version_min_3_11" >> $GITHUB_OUTPUT + + # Run integration tests against partner libraries with live API credentials + integration-tests: + if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule' + name: "ðŸ Python ${{ matrix.python-version }}: ${{ matrix.working-directory }}" + runs-on: ubuntu-latest + # Scopes LangSmith tracing credentials (and any other env-scoped secrets) + environment: "Scheduled testing" + needs: [compute-matrix] + timeout-minutes: 30 + # Serialize same-package shards across workflow runs so a per-package + # manual dispatch doesn't race the scheduled "all libs" run against the + # same live API credentials. Keyed per (working-directory, python-version) + # so the 3.10/3.14 matrix legs within one run still execute in parallel. + concurrency: + group: integration-tests-${{ matrix.working-directory }}-${{ matrix.python-version }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(needs.compute-matrix.outputs.matrix).python-version }} + working-directory: ${{ fromJSON(needs.compute-matrix.outputs.matrix).working-directory }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + path: langchain + + # These libraries exist outside of the monorepo and need to be checked out separately + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: langchain-ai/langchain-google + path: langchain-google + - name: "🔠Authenticate to Google Cloud" + id: "auth" + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + credentials_json: "${{ secrets.GOOGLE_CREDENTIALS }}" + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: langchain-ai/langchain-aws + path: langchain-aws + - name: "🔠Configure AWS Credentials" + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: "📦 Organize External Libraries" + run: | + rm -rf \ + langchain/libs/partners/google-genai \ + langchain/libs/partners/google-vertexai + mv langchain-google/libs/genai langchain/libs/partners/google-genai + mv langchain-google/libs/vertexai langchain/libs/partners/google-vertexai + mv langchain-aws/libs/aws langchain/libs/partners/aws + + - name: "ðŸ Set up Python ${{ matrix.python-version }} + UV" + uses: "./langchain/.github/actions/uv_setup" + with: + python-version: ${{ matrix.python-version }} + + - name: "📦 Install Dependencies" + # Partner packages use [tool.uv.sources] in their pyproject.toml to resolve + # langchain-core/langchain to local editable installs, so `uv sync` automatically + # tests against the versions from the current branch (not published releases). + # + # External google/aws packages live in separate repos and don't declare + # [tool.uv.sources], so `uv sync` pulls langchain-* from PyPI. Overlay + # local editable installs after sync so integration tests exercise the + # current branch's langchain code. Matches the pattern used by the + # `test-dependents` job below for deepagents. + run: | + echo "Running scheduled tests, installing dependencies with uv..." + cd langchain/${{ matrix.working-directory }} + uv sync --group test --group test_integration + + case "${{ matrix.working-directory }}" in + libs/partners/google-genai) + uv pip install \ + -e $GITHUB_WORKSPACE/langchain/libs/core \ + -e $GITHUB_WORKSPACE/langchain/libs/standard-tests + ;; + libs/partners/google-vertexai) + uv pip install \ + -e $GITHUB_WORKSPACE/langchain/libs/core \ + -e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \ + -e $GITHUB_WORKSPACE/langchain/libs/standard-tests + ;; + libs/partners/aws) + uv pip install \ + -e $GITHUB_WORKSPACE/langchain/libs/core \ + -e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \ + -e $GITHUB_WORKSPACE/langchain/libs/langchain \ + -e $GITHUB_WORKSPACE/langchain/libs/standard-tests \ + -e $GITHUB_WORKSPACE/langchain/libs/partners/anthropic + ;; + esac + + - name: "🧾 Build LangSmith Metadata" + # GHA expression values flow through intermediate env vars (injection + # hardening) and jq -nc builds the JSON, so quotes/newlines in any + # field can't corrupt the payload. + env: + GH_SHA: ${{ github.sha }} + GH_RUN_ID: ${{ github.run_id }} + GH_RUN_ATTEMPT: ${{ github.run_attempt }} + GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_WORKFLOW: ${{ github.workflow }} + GH_EVENT: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + WORKING_DIRECTORY: ${{ matrix.working-directory }} + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + metadata=$(jq -nc \ + --arg github_sha "$GH_SHA" \ + --arg github_run_id "$GH_RUN_ID" \ + --arg github_run_attempt "$GH_RUN_ATTEMPT" \ + --arg github_run_url "$GH_RUN_URL" \ + --arg github_workflow "$GH_WORKFLOW" \ + --arg github_event "$GH_EVENT" \ + --arg github_ref "$GH_REF" \ + --arg working_directory "$WORKING_DIRECTORY" \ + --arg python_version "$PYTHON_VERSION" \ + '{github_sha: $github_sha, github_run_id: $github_run_id, github_run_attempt: $github_run_attempt, github_run_url: $github_run_url, github_workflow: $github_workflow, github_event: $github_event, github_ref: $github_ref, working_directory: $working_directory, python_version: $python_version}') + echo "LANGSMITH_METADATA=$metadata" >> "$GITHUB_ENV" + + - name: "🚀 Run Integration Tests" + # WARNING: All secrets below are available to every matrix job regardless of + # which package is being tested. This is intentional for simplicity, but means + # any test file could technically access any key. Only use for trusted code. + env: + LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }} + + # Route traces to one project with GitHub run metadata so failures link back to the originating Actions run. + LANGSMITH_TRACING: "true" + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + LANGSMITH_PROJECT: ${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }} + LANGSMITH_TAGS: "github-actions,${{ matrix.working-directory }},python-${{ matrix.python-version }},sha-${{ github.sha }}" + + AI21_API_KEY: ${{ secrets.AI21_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }} + ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }} + ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }} + ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }} + ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }} + AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} + AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }} + AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + ES_URL: ${{ secrets.ES_URL }} + ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }} + ES_API_KEY: ${{ secrets.ES_API_KEY }} + EXA_API_KEY: ${{ secrets.EXA_API_KEY }} + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }} + GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }} + NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }} + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }} + WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }} + WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + run: | + cd langchain/${{ matrix.working-directory }} + make integration_tests + + - name: "🧹 Clean up External Libraries" + # Clean up external libraries to avoid affecting the following git status check + run: | + rm -rf \ + langchain/libs/partners/google-genai \ + langchain/libs/partners/google-vertexai \ + langchain/libs/partners/aws + + - name: "🧹 Verify Clean Working Directory" + working-directory: langchain + run: | + set -eu + + STATUS="$(git status)" + echo "$STATUS" + + # grep will exit non-zero if the target message isn't found, + # and `set -e` above will cause the step to fail. + echo "$STATUS" | grep 'nothing to commit, working tree clean' + + # Test dependent packages against local packages to catch breaking changes + test-dependents: + # Defend against forks running scheduled jobs, but allow manual runs from forks + if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule' + + name: "ðŸ Python ${{ matrix.python-version }}: ${{ matrix.package.path }}" + runs-on: ubuntu-latest + needs: [compute-matrix] + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # deepagents requires Python >= 3.11, use bounded version from compute-matrix + python-version: ${{ fromJSON(needs.compute-matrix.outputs.python-version-min-3-11) }} + package: + - name: deepagents + repo: langchain-ai/deepagents + path: libs/deepagents + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + path: langchain + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + repository: ${{ matrix.package.repo }} + path: ${{ matrix.package.name }} + + - name: "ðŸ Set up Python ${{ matrix.python-version }} + UV" + uses: "./langchain/.github/actions/uv_setup" + with: + python-version: ${{ matrix.python-version }} + + - name: "📦 Install ${{ matrix.package.name }} with Local" + # Unlike partner packages (which use [tool.uv.sources] for local resolution), + # external dependents live in separate repos and need explicit overrides to + # test against the langchain versions from the current branch, as their + # pyproject.toml files point to released versions. + run: | + cd ${{ matrix.package.name }}/${{ matrix.package.path }} + + # Install the package with test dependencies + uv sync --group test + + # Override langchain packages with local versions + uv pip install \ + -e $GITHUB_WORKSPACE/langchain/libs/core \ + -e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 + + # No API keys needed for now - deepagents `make test` only runs unit tests + - name: "🚀 Run ${{ matrix.package.name }} Tests" + run: | + cd ${{ matrix.package.name }}/${{ matrix.package.path }} + make test diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml new file mode 100644 index 0000000..ff0c851 --- /dev/null +++ b/.github/workflows/pr_labeler.yml @@ -0,0 +1,214 @@ +# Unified PR labeler — applies size, file-based, title-based, and +# contributor classification labels in a single sequential workflow. +# +# Consolidates pr_labeler_file.yml, pr_labeler_title.yml, +# pr_size_labeler.yml, and PR-handling from tag-external-contributions.yml +# into one workflow to eliminate race conditions from concurrent label +# mutations. tag-external-issues.yml remains active for issue-only +# labeling. Backfill lives in pr_labeler_backfill.yml. +# +# Config and shared logic live in .github/scripts/pr-labeler-config.json +# and .github/scripts/pr-labeler.js — update those when adding partners. +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Pull requests (write) +# - Repository: Issues (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_CLIENT_ID: Your app's client ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership +# and to propagate label events to downstream workflows. + +name: "ðŸ·ï¸ PR Labeler" + +on: + # Safe since we're not checking out or running the PR's code. + # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB. + # Doing so would allow attackers to execute arbitrary code in the context of your repository. + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +concurrency: + # Separate opened events so external/tier labels are never lost to cancellation + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'opened' && 'opened' || 'update' }} + cancel-in-progress: ${{ github.event.action != 'opened' }} + +jobs: + label: + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + + steps: + # Checks out the BASE branch (safe for pull_request_target — never + # the PR head). Needed to load .github/scripts/pr-labeler*. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Generate GitHub App token + if: github.event.action == 'opened' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Verify App token + if: github.event.action == 'opened' + run: | + if [ -z "${{ steps.app-token.outputs.token }}" ]; then + echo "::error::GitHub App token generation failed — cannot classify contributor" + exit 1 + fi + + - name: Check org membership + if: github.event.action == 'opened' + id: check-membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const author = context.payload.sender.login; + const { isExternal } = await h.checkMembership( + author, context.payload.sender.type, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + + - name: Apply PR labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + IS_EXTERNAL: ${{ steps.check-membership.outputs.is-external }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const pr = context.payload.pull_request; + if (!pr) return; + const prNumber = pr.number; + const action = context.payload.action; + + const toAdd = new Set(); + const toRemove = new Set(); + + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: prNumber, per_page: 100 }, + )).map(l => l.name ?? ''); + + // ── Size + file labels (skip on 'edited' — files unchanged) ── + if (action !== 'edited') { + for (const sl of h.sizeLabels) await h.ensureLabel(sl); + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + + const { totalChanged, sizeLabel } = h.computeSize(files); + toAdd.add(sizeLabel); + for (const sl of h.sizeLabels) { + if (currentLabels.includes(sl) && sl !== sizeLabel) toRemove.add(sl); + } + console.log(`Size: ${totalChanged} changed lines → ${sizeLabel}`); + + for (const label of h.matchFileLabels(files)) { + toAdd.add(label); + } + } + + // ── Title-based labels ── + const { labels: titleLabels, typeLabel } = h.matchTitleLabels(pr.title || ''); + for (const label of titleLabels) toAdd.add(label); + + // Remove stale type labels only when a type was detected + if (typeLabel) { + for (const tl of h.allTypeLabels) { + if (currentLabels.includes(tl) && !titleLabels.has(tl)) toRemove.add(tl); + } + } + + // ── Internal label (only on open, non-external contributors) ── + // IS_EXTERNAL is empty string on non-opened events (step didn't + // run), so this guard is only true for opened + internal. + if (action === 'opened' && process.env.IS_EXTERNAL === 'false') { + toAdd.add('internal'); + } + + // ── Apply changes ── + // Ensure all labels we're about to add exist (addLabels returns + // 422 if any label in the batch is missing, which would prevent + // ALL labels from being applied). + for (const name of toAdd) { + await h.ensureLabel(name); + } + + for (const name of toRemove) { + if (toAdd.has(name)) continue; + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + + const addList = [...toAdd]; + if (addList.length > 0) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: addList, + }); + } + + const removed = [...toRemove].filter(r => !toAdd.has(r)); + console.log(`PR #${prNumber}: +[${addList.join(', ')}] -[${removed.join(', ')}]`); + + # Apply tier label BEFORE the external label so that + # "trusted-contributor" is already present when the "external" labeled + # event fires and triggers require_issue_link.yml. + - name: Apply contributor tier label + if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const pr = context.payload.pull_request; + await h.applyTierLabel(pr.number, pr.user.login); + + - name: Add external label + if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). Events created by the + # default GITHUB_TOKEN do not trigger additional workflow runs. + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + await h.ensureLabel('external'); + await github.rest.issues.addLabels({ + owner, repo, + issue_number: prNumber, + labels: ['external'], + }); + console.log(`Added 'external' label to PR #${prNumber}`); diff --git a/.github/workflows/pr_labeler_backfill.yml b/.github/workflows/pr_labeler_backfill.yml new file mode 100644 index 0000000..83450ca --- /dev/null +++ b/.github/workflows/pr_labeler_backfill.yml @@ -0,0 +1,131 @@ +# Backfill PR labels on all open PRs. +# +# Manual-only workflow that applies the same labels as pr_labeler.yml +# (size, file, title, contributor classification) to existing open PRs. +# Reuses shared logic from .github/scripts/pr-labeler.js. + +name: "ðŸ·ï¸ PR Labeler Backfill" + +on: + workflow_dispatch: + inputs: + max_items: + description: "Maximum number of open PRs to process" + default: "100" + type: string + +permissions: + contents: read + +jobs: + backfill: + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Backfill labels on open PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const rawMax = '${{ inputs.max_items }}'; + const maxItems = parseInt(rawMax, 10); + if (isNaN(maxItems) || maxItems <= 0) { + core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`); + return; + } + + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + for (const name of [...h.sizeLabels, ...h.tierLabels]) { + await h.ensureLabel(name); + } + + const contributorCache = new Map(); + const fileRules = h.buildFileRules(); + + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + + let processed = 0; + let failures = 0; + for (const pr of prs) { + if (processed >= maxItems) break; + try { + const author = pr.user.login; + const info = await h.getContributorInfo(contributorCache, author, pr.user.type); + const labels = new Set(); + + labels.add(info.isExternal ? 'external' : 'internal'); + if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) { + labels.add('trusted-contributor'); + } else if (info.isExternal && info.mergedCount === 0) { + labels.add('new-contributor'); + } + + // Size + file labels + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + const { sizeLabel } = h.computeSize(files); + labels.add(sizeLabel); + + for (const label of h.matchFileLabels(files, fileRules)) { + labels.add(label); + } + + // Title labels + const { labels: titleLabels } = h.matchTitleLabels(pr.title ?? ''); + for (const tl of titleLabels) labels.add(tl); + + // Ensure all labels exist before batch add + for (const name of labels) { + await h.ensureLabel(name); + } + + // Remove stale managed labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: pr.number, per_page: 100 }, + )).map(l => l.name ?? ''); + + const managed = [...h.sizeLabels, ...h.tierLabels, ...h.allTypeLabels]; + for (const name of currentLabels) { + if (managed.includes(name) && !labels.has(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [...labels], + }); + console.log(`PR #${pr.number} (${author}): ${[...labels].join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process PR #${pr.number}: ${e.message}`); + } + } + + console.log(`\nBackfill complete. Processed ${processed} PRs, ${failures} failures. ${contributorCache.size} unique authors.`); diff --git a/.github/workflows/pr_lint.yml b/.github/workflows/pr_lint.yml new file mode 100644 index 0000000..3dbf967 --- /dev/null +++ b/.github/workflows/pr_lint.yml @@ -0,0 +1,128 @@ +# PR title linting. +# +# FORMAT (Conventional Commits 1.0.0): +# +# [optional scope]: +# [optional body] +# [optional footer(s)] +# +# Examples: +# feat(core): add multiâ€tenant support +# fix(langchain): resolve error +# docs: update API usage examples +# docs(openai): update API usage examples +# +# Allowed Types: +# * feat — a new feature (MINOR) +# * fix — a bug fix (PATCH) +# * docs — documentation only changes +# * style — formatting, linting, etc.; no code change or typing refactors +# * refactor — code change that neither fixes a bug nor adds a feature +# * perf — code change that improves performance +# * test — adding tests or correcting existing +# * build — changes that affect the build system/external dependencies +# * ci — continuous integration/configuration changes +# * chore — other changes that don't modify source or test files +# * revert — reverts a previous commit +# * release — prepare a new release +# * hotfix — urgent fix +# +# Allowed Scope(s) (optional): +# 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, partners +# +# Multiple scopes can be used by separating them with a comma. For example: +# +# feat(core,langchain): add multiâ€tenant support to core and langchain +# +# Note: PRs touching the langchain package should use the 'langchain' scope. It is not +# acceptable to omit the scope for changes to the langchain package, despite it being +# the main package & name of the repo. +# +# Rules: +# 1. The 'Type' must start with a lowercase letter. +# 2. Breaking changes: append "!" after type/scope (e.g., feat!: drop x support) +# 3. When releasing (updating the pyproject.toml and uv.lock), the commit message +# should be: `release(scope): x.y.z` (e.g., `release(core): 1.2.0` with no +# body, footer, or preceeding/proceeding text). +# +# Enforces Conventional Commits format for pull request titles to maintain a clear and +# machine-readable change history. + +name: "ðŸ·ï¸ PR Title Lint" + +permissions: + pull-requests: read + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + # Validates that PR title follows Conventional Commits 1.0.0 specification + lint-pr-title: + name: "validate format" + runs-on: ubuntu-latest + steps: + - name: "🚫 Reject empty scope" + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + if [[ "$PR_TITLE" =~ ^[a-z]+\(\)[!]?: ]]; then + echo "::error::PR title has empty scope parentheses: '$PR_TITLE'" + echo "Either remove the parentheses or provide a scope (e.g., 'fix(core): ...')." + exit 1 + fi + - name: "✅ Validate Conventional Commits Format" + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + release + hotfix + scopes: | + core + langchain + langchain-classic + model-profiles + standard-tests + text-splitters + docs + anthropic + chroma + deepseek + exa + fireworks + groq + huggingface + mistralai + nomic + ollama + openai + openrouter + perplexity + qdrant + xai + infra + deps + partners + requireScope: false + disallowScopes: | + release + [A-Z]+ + ignoreLabels: | + ignore-lint-pr-title diff --git a/.github/workflows/pr_lint_trailer.yml b/.github/workflows/pr_lint_trailer.yml new file mode 100644 index 0000000..17f6f09 --- /dev/null +++ b/.github/workflows/pr_lint_trailer.yml @@ -0,0 +1,175 @@ +# Pre-merge banned-trailer check. + +name: "ðŸ·ï¸ PR trailer lint" + +on: + pull_request: + types: [ opened, edited, synchronize, reopened ] + +permissions: + pull-requests: write + +jobs: + trailer-check: + if: github.repository_owner == 'langchain-ai' + name: "validate squash-merge has no banned trailers" + runs-on: ubuntu-latest + # Serialize per-PR. Rapid `edited`/`synchronize` events on a PR open can + # otherwise produce two concurrent runs that both observe "no existing + # sticky" and both call `createComment`, leaving a duplicate failure + # comment that the find-first updater will never reconcile. We queue + # (cancel-in-progress: false) rather than cancel, so the in-flight run + # finishes its sticky write before the next event evaluates. + concurrency: + group: pr-trailer-lint-${{ github.event.pull_request.number }} + cancel-in-progress: false + steps: + - name: Check PR title and body for banned trailer + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + # Bound the comment-write tail so a hung GitHub API call cannot leave + # the check stuck "in progress" past the runner default. `core.setFailed` + # is invoked before the sticky write, so the failure status is already + # recorded if this timeout fires. + timeout-minutes: 5 + with: + script: | + if (!context.payload.pull_request) { + core.setFailed('No pull_request payload — workflow must run on pull_request events.'); + return; + } + const { title, body, number } = context.payload.pull_request; + // Normalize line endings — GitHub returns whatever the editor used, + // and CRLF leaves stray \r chars in offending-line displays. + const fullBody = (body || '').replace(/\r\n/g, '\n'); + const STICKY_MARKER = ''; + + // Mirrors the org ruleset regex on the default branch. Keep in lock-step: + // the live source of truth is the ruleset's `commit_message_pattern.pattern` + // field at GitHub org settings → Rulesets → `block-anthropic-coauthor` + // (or whichever ruleset blocks this trailer on the default branch). + // The pattern below is informational; verify against the live ruleset + // when updating either side, or this check silently passes pushes + // that the ruleset will then reject (defeating the entire purpose). + // + // Case-folding is intentionally narrow (`[Aa]`/`[Bb]`) because the + // ruleset's pattern is narrow. Do NOT add the `i` flag — that would + // catch cases the ruleset does not, surfacing false positives the + // ruleset would let through. + const BANNED_REGEX = /Co-[Aa]uthored-[Bb]y:.*/; + + const squashMessage = `${title} (#${number})\n\n${fullBody}`; + + async function findStickyComment() { + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: number, + per_page: 100, + }); + return comments.find(c => c.body && c.body.startsWith(STICKY_MARKER)); + } + + // Comment write paths can fail for several reasons that should not + // turn this advisory job red on its own: fork PRs run with + // restricted tokens, secondary rate limits, transient API errors. + // Fall back to `core.summary` so a maintainer can paste the + // remediation manually. The check still fails — `setFailed` is + // invoked before this function, so the failure signal is already + // recorded by the time the comment write is attempted. + // + // The try/catch wraps ONLY the write call so that a bug in + // `findStickyComment` (e.g., pagination throwing) surfaces with + // its true cause instead of being misattributed to "fork PR token". + async function postStickyOrSummary(commentBody, summaryHeading) { + const existing = await findStickyComment(); + try { + if (existing) { + if (existing.body !== commentBody) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body: commentBody, + }); + } + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: number, + body: commentBody, + }); + } + } catch (commentErr) { + core.warning(`Could not post sticky comment (fork PR token, rate limit, or transient API error): ${commentErr.message}`); + await core.summary + .addHeading(summaryHeading) + .addRaw('Paste the following into the PR as a comment:') + .addCodeBlock(commentBody, 'markdown') + .write(); + } + } + + const lines = squashMessage.split('\n'); + const offendingIndices = []; + for (let i = 0; i < lines.length; i++) { + if (BANNED_REGEX.test(lines[i])) { + offendingIndices.push(i); + } + } + + if (offendingIndices.length === 0) { + core.info('No banned trailer in squash-merge message.'); + // Mark any prior failure comment as resolved. We update rather + // than delete because `deleteComment` 403s under restricted + // fork-PR tokens, whereas `updateComment` on a bot-authored + // comment works in both modes. Wrapped in try/catch because a + // transient API failure during cleanup must NOT turn a green + // check into red. + try { + const existing = await findStickyComment(); + if (existing) { + const resolvedBody = [ + STICKY_MARKER, + '✅ **Trailer fixed.** The previous warning is resolved.', + ].join('\n'); + if (existing.body !== resolvedBody) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body: resolvedBody, + }); + } + } + } catch (cleanupErr) { + core.warning(`Check passed but could not update prior failure comment to resolved: ${cleanupErr.message}`); + } + return; + } + + const offendingExcerpt = offendingIndices + .map(i => `Line ${i + 1}: ${lines[i]}`) + .join('\n'); + + const commentBody = [ + STICKY_MARKER, + 'âš ï¸ **Banned trailer in PR — would block the squash-merge push to the default branch.**', + '', + 'The would-be squash-merge commit message contains a `Co-authored-by: ... ` line. An organization ruleset on the default branch rejects any push whose commit message matches that pattern, so this PR cannot be merged until the trailer is removed.', + '', + '**Found:**', + '```', + offendingExcerpt, + '```', + '', + '### Fix', + '', + 'Edit the PR description and remove the offending line(s). The trailer is auto-inserted by some Claude-based authoring tools — strip it before opening or merging the PR. Save the description; this check will re-run automatically.', + ].join('\n'); + + // Set the failure signal BEFORE the sticky write — if the comment + // API hangs, the runner-level timeout fires with the failure + // status already recorded. Reversing the order leaves the check + // stuck "in progress" instead of red. + core.setFailed(`PR contains banned trailer matching ${BANNED_REGEX}`); + await postStickyOrSummary( + commentBody, + 'Banned trailer in PR; comment could not be posted', + ); diff --git a/.github/workflows/refresh_model_profiles.yml b/.github/workflows/refresh_model_profiles.yml new file mode 100644 index 0000000..fcf9155 --- /dev/null +++ b/.github/workflows/refresh_model_profiles.yml @@ -0,0 +1,45 @@ +# Refreshes model profile data for all in-monorepo partner integrations by +# pulling the latest metadata from models.dev via the `langchain-profiles` CLI. +# +# Creates a pull request with any changes. Runs daily and can be triggered +# manually from the Actions UI. Uses a fixed branch so each run supersedes +# any stale PR from a previous run. + +name: "🔄 Refresh Model Profiles" + +on: + schedule: + - cron: "0 8 * * *" # daily at 08:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + refresh-profiles: + if: github.repository_owner == 'langchain-ai' + uses: ./.github/workflows/_refresh_model_profiles.yml + with: + providers: >- + [ + {"provider":"anthropic", "data_dir":"libs/partners/anthropic/langchain_anthropic/data"}, + {"provider":"deepseek", "data_dir":"libs/partners/deepseek/langchain_deepseek/data"}, + {"provider":"fireworks-ai", "data_dir":"libs/partners/fireworks/langchain_fireworks/data"}, + {"provider":"groq", "data_dir":"libs/partners/groq/langchain_groq/data"}, + {"provider":"huggingface", "data_dir":"libs/partners/huggingface/langchain_huggingface/data"}, + {"provider":"mistral", "data_dir":"libs/partners/mistralai/langchain_mistralai/data"}, + {"provider":"openai", "data_dir":"libs/partners/openai/langchain_openai/data"}, + {"provider":"openrouter", "data_dir":"libs/partners/openrouter/langchain_openrouter/data"}, + {"provider":"perplexity", "data_dir":"libs/partners/perplexity/langchain_perplexity/data"}, + {"provider":"xai", "data_dir":"libs/partners/xai/langchain_xai/data"} + ] + cli-path: libs/model-profiles + add-paths: libs/partners/**/data/_profiles.py + pr-body: | + Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. + + 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). + secrets: + MODEL_PROFILE_BOT_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }} + MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/remove_waiting_on_author.yml b/.github/workflows/remove_waiting_on_author.yml new file mode 100644 index 0000000..2f19ad2 --- /dev/null +++ b/.github/workflows/remove_waiting_on_author.yml @@ -0,0 +1,61 @@ +# Remove the `waiting-on-author` label from an issue or PR when the original +# author replies. Fires on every issue/PR comment; the job's `if:` filter skips +# runs unless the item is open, carries the label, and the commenter is the +# original author (and not a bot). +# +# Uses the default GITHUB_TOKEN so the label-removal event does NOT re-trigger +# other workflows: GitHub does not trigger new workflow runs from actions +# performed by the default GITHUB_TOKEN (with narrow exceptions like +# workflow_dispatch), which prevents infinite loops. + +name: Remove waiting-on-author on Author Reply + +on: + issue_comment: + types: [created] + +permissions: + contents: read + +jobs: + remove-label: + if: >- + github.repository_owner == 'langchain-ai' && + github.event.issue.state == 'open' && + contains(github.event.issue.labels.*.name, 'waiting-on-author') && + github.event.comment.user.type != 'Bot' && + github.event.comment.user.login == github.event.issue.user.login + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Remove waiting-on-author label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + const author = context.payload.issue.user.login; + + console.log( + `Author ${author} commented on #${issue_number} — removing waiting-on-author`, + ); + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: 'waiting-on-author', + }); + } catch (e) { + // 404: typically label not present (already removed or never + // applied). Could also indicate issue/repo not found — include + // e.message to disambiguate. + if (e.status === 404) { + console.log(`Label already absent (404) — nothing to do: ${e.message}`); + return; + } + throw e; + } diff --git a/.github/workflows/reopen_on_assignment.yml b/.github/workflows/reopen_on_assignment.yml new file mode 100644 index 0000000..6799f6d --- /dev/null +++ b/.github/workflows/reopen_on_assignment.yml @@ -0,0 +1,196 @@ +# Reopen PRs that were auto-closed by require_issue_link.yml when the +# contributor was not assigned to the linked issue. When a maintainer +# assigns the contributor to the issue, this workflow finds matching +# closed PRs, verifies the issue link, and reopens them. +# +# Uses the default GITHUB_TOKEN (not a PAT or app token) so that the +# reopen and label-removal events do NOT re-trigger other workflows. +# GitHub suppresses events created by the default GITHUB_TOKEN within +# workflow runs to prevent infinite loops. + +name: Reopen PR on Issue Assignment + +on: + issues: + types: [assigned] + +permissions: + contents: read + +jobs: + reopen-linked-prs: + if: github.repository_owner == 'langchain-ai' + runs-on: ubuntu-latest + permissions: + actions: write + pull-requests: write + + steps: + - name: Find and reopen matching PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const issueNumber = context.payload.issue.number; + const assignee = context.payload.assignee.login; + + console.log( + `Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`, + ); + + const q = [ + `is:pr`, + `is:closed`, + `author:${assignee}`, + `label:missing-issue-link`, + `repo:${owner}/${repo}`, + ].join(' '); + + let data; + try { + ({ data } = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 30, + })); + } catch (e) { + throw new Error( + `Failed to search for closed PRs to reopen after assigning ${assignee} ` + + `to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + + if (data.total_count === 0) { + console.log('No matching closed PRs found'); + return; + } + + console.log(`Found ${data.total_count} candidate PR(s)`); + + // Must stay in sync with the identical pattern in require_issue_link.yml + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; + + for (const item of data.items) { + const prNumber = item.number; + const body = item.body || ''; + const matches = [...body.matchAll(pattern)]; + const referencedIssues = matches.map(m => parseInt(m[1], 10)); + + if (!referencedIssues.includes(issueNumber)) { + console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); + continue; + } + + // Skip if already bypassed + const labels = item.labels.map(l => l.name); + if (labels.includes('bypass-issue-check')) { + console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); + continue; + } + + // Reopen first, remove label second — a closed PR that still has + // missing-issue-link is recoverable; a closed PR with the label + // stripped is invisible to both workflows. + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + if (e.status === 422) { + // Head branch deleted — PR is unrecoverable. Notify the + // contributor so they know to open a new PR. + core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`); + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: + `You have been assigned to #${issueNumber}, but this PR could not be ` + + `reopened because the head branch has been deleted. Please open a new ` + + `PR referencing the issue.`, + }); + } catch (commentErr) { + core.warning( + `Also failed to post comment on PR #${prNumber}: ${commentErr.message}`, + ); + } + continue; + } + // Transient errors (rate limit, 5xx) should fail the job so + // the label is NOT removed and the run can be retried. + throw e; + } + + // Remove missing-issue-link label only after successful reopen + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: 'missing-issue-link', + }); + console.log(`Removed missing-issue-link from PR #${prNumber}`); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Minimize stale enforcement comment (best-effort; + // sync w/ require_issue_link.yml minimize blocks) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + // Re-run the failed require_issue_link check so it picks up the + // new assignment. The re-run uses the original event payload but + // fetches live issue data, so the assignment check will pass. + // + // Limitation: we look up runs by the PR's current head SHA. If the + // contributor pushed new commits while the PR was closed, head.sha + // won't match the SHA of the original failed run and the query will + // return 0 results. This is acceptable because any push after reopen + // triggers a fresh require_issue_link run against the new SHA. + try { + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + }); + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner, repo, + workflow_id: 'require_issue_link.yml', + head_sha: pr.head.sha, + status: 'failure', + per_page: 1, + }); + if (runs.workflow_runs.length > 0) { + await github.rest.actions.reRunWorkflowFailedJobs({ + owner, repo, + run_id: runs.workflow_runs[0].id, + }); + console.log(`Re-ran failed require_issue_link run ${runs.workflow_runs[0].id} for PR #${prNumber}`); + } else { + console.log(`No failed require_issue_link runs found for PR #${prNumber} — skipping re-run`); + } + } catch (e) { + core.warning(`Could not re-run require_issue_link check for PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`); + } + } diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml new file mode 100644 index 0000000..6ccecf2 --- /dev/null +++ b/.github/workflows/require_issue_link.yml @@ -0,0 +1,468 @@ +# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and +# the PR author to be assigned to that issue. On failure the PR is +# labeled "missing-issue-link", commented on, and closed. +# +# Maintainer override: an org member can reopen the PR or remove +# "missing-issue-link" — both add "bypass-issue-check" and reopen. +# +# Dependency: pr_labeler.yml must apply the "external" label first. This +# workflow does NOT trigger on "opened" (new PRs have no labels yet, so the +# gate would always skip). + +name: Require Issue Link + +on: + pull_request_target: + # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB. + # Doing so would allow attackers to execute arbitrary code in the context of your repository. + types: [edited, reopened, labeled, unlabeled] + +# ────────────────────────────────────────────────────────────────────────────── +# Enforcement gate: set to 'true' to activate the issue link requirement. +# When 'false', the workflow still runs the check logic (useful for dry-run +# visibility) but will NOT label, comment, close, or fail PRs. +# ────────────────────────────────────────────────────────────────────────────── +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +jobs: + check-issue-link: + # Run when the "external" label is added, on edit/reopen if already labeled, + # or when "missing-issue-link" is removed (triggers maintainer override check). + # Skip entirely when the PR already carries "trusted-contributor" or + # "bypass-issue-check". + if: >- + github.repository_owner == 'langchain-ai' && + !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && + !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && + ( + (github.event.action == 'labeled' && github.event.label.name == 'external') || + (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) || + (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external')) + ) + runs-on: ubuntu-latest + permissions: + actions: write + pull-requests: write + + steps: + - name: Check for issue link and assignee + id: check-link + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; + + // ── Helper: ensure a label exists, then add it to the PR ──────── + async function ensureAndAddLabel(labelName, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); + } catch (createErr) { + // 422 = label was created by a concurrent run between our + // GET and POST — safe to ignore. + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + } + + // ── Helper: check if the user who triggered this event (reopened + // the PR / removed the label) has write+ access on the repo ─── + // Uses the repo collaborator permission endpoint instead of the + // org membership endpoint. The org endpoint requires the caller + // to be an org member, which GITHUB_TOKEN (an app installation + // token) never is — so it always returns 403. + async function senderIsOrgMember() { + const sender = context.payload.sender?.login; + if (!sender) { + throw new Error('Event has no sender — cannot check permissions'); + } + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: sender, + }); + const perm = data.permission; + if (['admin', 'maintain', 'write'].includes(perm)) { + console.log(`${sender} has ${perm} permission — treating as maintainer`); + return { isMember: true, login: sender }; + } + console.log(`${sender} has ${perm} permission — not a maintainer`); + return { isMember: false, login: sender }; + } catch (e) { + if (e.status === 404) { + console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`); + return { isMember: false, login: sender }; + } + const status = e.status ?? 'unknown'; + throw new Error( + `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`, + ); + } + } + + // ── Helper: apply maintainer bypass (shared by both override paths) ── + async function applyMaintainerBypass(reason) { + console.log(reason); + + // Remove missing-issue-link if present + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Reopen before adding bypass label — a failed reopen is more + // actionable than a closed PR with a bypass label stuck on it. + if (context.payload.pull_request.state === 'closed') { + try { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + // 422 if head branch deleted; 403 if permissions insufficient. + // Bypass labels still apply — maintainer can reopen manually. + core.warning( + `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Bypass labels were applied — a maintainer may need to reopen manually.`, + ); + } + } + + // Add bypass-issue-check so future triggers skip enforcement + await ensureAndAddLabel('bypass-issue-check', '0e8a16'); + + // Minimize stale enforcement comment (best-effort; must not + // abort bypass — sync w/ reopen_on_assignment.yml & step below) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + } + + // ── Maintainer override: removed "missing-issue-link" label ───── + if (action === 'unlabeled') { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`, + ); + return; + } + // Non-member removed the label — re-add it defensively and + // set failure outputs so downstream steps (comment, close) fire. + // NOTE: addLabels fires a "labeled" event, but the job-level gate + // only matches labeled events for "external", so no re-trigger. + console.log(`Non-member ${login} removed missing-issue-link — re-adding`); + try { + await ensureAndAddLabel('missing-issue-link', 'b76e79'); + } catch (e) { + core.warning( + `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Downstream step will retry.`, + ); + } + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + // ── Maintainer override: reopened PR with "missing-issue-link" ── + const prLabels = context.payload.pull_request.labels.map(l => l.name); + if (action === 'reopened' && prLabels.includes('missing-issue-link')) { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`, + ); + return; + } + console.log(`Non-member ${login} reopened PR — proceeding with check`); + } + + // ── Fetch live labels (race guard) ────────────────────────────── + const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber, + }); + const liveNames = liveLabels.map(l => l.name); + if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { + console.log('PR has trusted-contributor or bypass-issue-check label — bypassing'); + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + return; + } + + const body = context.payload.pull_request.body || ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; + const matches = [...body.matchAll(pattern)]; + + if (matches.length === 0) { + console.log('No issue link found in PR body'); + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + const issues = matches.map(m => `#${m[1]}`).join(', '); + console.log(`Found issue link(s): ${issues}`); + core.setOutput('has-link', 'true'); + + // Check whether the PR author is assigned to at least one linked issue + const prAuthor = context.payload.pull_request.user.login; + const MAX_ISSUES = 5; + const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; + const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES); + if (allIssueNumbers.length > MAX_ISSUES) { + core.warning( + `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`, + ); + } + + let assignedToAny = false; + for (const num of issueNumbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: num, + }); + const assignees = issue.assignees.map(a => a.login.toLowerCase()); + if (assignees.includes(prAuthor.toLowerCase())) { + console.log(`PR author "${prAuthor}" is assigned to #${num}`); + assignedToAny = true; + break; + } else { + console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`); + } + } catch (error) { + if (error.status === 404) { + console.log(`Issue #${num} not found — skipping`); + } else { + // Non-404 errors (rate limit, server error) must not be + // silently skipped — they could cause false enforcement + // (closing a legitimate PR whose assignment can't be verified). + throw new Error( + `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`, + ); + } + } + } + + core.setOutput('is-assigned', assignedToAny ? 'true' : 'false'); + + - name: Add missing-issue-link label + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const labelName = 'missing-issue-link'; + + // Ensure the label exists (no checkout/shared helper available) + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: 'b76e79', + }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + + - name: Remove missing-issue-link label and reopen PR + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + // Reopen if this workflow previously closed the PR. We check the + // event payload labels (not live labels) because we already removed + // missing-issue-link above; the payload still reflects pre-step state. + const labels = context.payload.pull_request.labels.map(l => l.name); + if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } + + // Minimize stale enforcement comment (best-effort; + // sync w/ applyMaintainerBypass above & reopen_on_assignment.yml) + try { + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(marker)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + - name: Post comment, close PR, and fail + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true'; + const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true'; + const marker = ''; + + let lines; + if (!hasLink) { + lines = [ + marker, + '**This PR has been automatically closed** because it does not link to an approved issue.', + '', + 'All external contributions must reference an approved issue or discussion. Opening a PR before maintainer approval and assignment is discouraged. Please:', + '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change', + '2. Wait for a maintainer to approve the approach and assign you', + '3. After assignment, open a PR. If this PR was opened early, add `Fixes #`, `Closes #`, or `Resolves #` to the description and it can be reopened automatically', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } else { + lines = [ + marker, + '**This PR has been automatically closed** because you are not assigned to the linked issue.', + '', + 'Opening a PR before assignment is discouraged and is **not** an indication that it will be accepted. This process exists so maintainers can confirm a change is aligned with the project direction *before* contributors invest time implementing it. Please:', + '1. Comment on the linked issue explaining the approach you would like to take and why — include enough detail for a maintainer to evaluate the design. Do **not** post a drive-by "please assign me" comment with no substance; those will be ignored.', + '2. Wait for a maintainer to approve the approach and assign you. Once assigned, this PR can be reopened automatically.', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } + + const body = lines.join('\n'); + + // Deduplicate: check for existing comment with the marker + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (!existing) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + console.log('Posted requirement comment'); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + console.log('Updated existing comment with new message'); + } else { + console.log('Comment already exists — skipping'); + } + + // Close the PR + if (context.payload.pull_request.state === 'open') { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); + console.log(`Closed PR #${prNumber}`); + } + + // Cancel all other in-progress and queued workflow runs for this PR + const headSha = context.payload.pull_request.head.sha; + for (const status of ['in_progress', 'queued']) { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { owner, repo, head_sha: headSha, status, per_page: 100 }, + ); + for (const run of runs) { + if (run.id === context.runId) continue; + try { + await github.rest.actions.cancelWorkflowRun({ + owner, repo, run_id: run.id, + }); + console.log(`Cancelled ${status} run ${run.id} (${run.name})`); + } catch (err) { + console.log(`Could not cancel run ${run.id}: ${err.message}`); + } + } + } + + const reason = !hasLink + ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").' + : 'PR author must be assigned to the linked issue.'; + core.setFailed(reason); diff --git a/.github/workflows/tag-external-issues.yml b/.github/workflows/tag-external-issues.yml new file mode 100644 index 0000000..ef584b7 --- /dev/null +++ b/.github/workflows/tag-external-issues.yml @@ -0,0 +1,205 @@ +# Automatically tag issues as "external" or "internal" based on whether +# the author is a member of the langchain-ai GitHub organization, and +# apply contributor tier labels to external contributors based on their +# merged PR history. +# +# NOTE: PR labeling (including external/internal, tier, size, file, and +# title labels) is handled by pr_labeler.yml. This workflow handles +# issues only. +# +# Config (trustedThreshold, labelColor) is read from +# .github/scripts/pr-labeler-config.json to stay in sync with +# pr_labeler.yml. +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Issues (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_CLIENT_ID: Your app's client ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership. +# Without it, the workflow will fail. + +name: Tag External Issues + +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + max_items: + description: "Maximum number of open issues to process" + default: "100" + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: true + +jobs: + tag-external: + if: github.repository_owner == 'langchain-ai' && github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Check if contributor is external + if: steps.app-token.outcome == 'success' + id: check-membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const author = context.payload.sender.login; + const { isExternal } = await h.checkMembership( + author, context.payload.sender.type, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + + - name: Apply contributor tier label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # GITHUB_TOKEN is fine here — no downstream workflow chains + # off tier labels on issues (unlike PRs where App token is + # needed for require_issue_link.yml). + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const issue = context.payload.issue; + // new-contributor is only meaningful on PRs, not issues + await h.applyTierLabel(issue.number, issue.user.login, { skipNewContributor: true }); + + - name: Add external/internal label + if: steps.check-membership.outputs.is-external != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const label = '${{ steps.check-membership.outputs.is-external }}' === 'true' + ? 'external' : 'internal'; + await h.ensureLabel(label); + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: [label], + }); + console.log(`Added '${label}' label to issue #${issue_number}`); + + backfill: + if: github.repository_owner == 'langchain-ai' && github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Backfill labels on open issues + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const rawMax = '${{ inputs.max_items }}'; + const maxItems = parseInt(rawMax, 10); + if (isNaN(maxItems) || maxItems <= 0) { + core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`); + return; + } + + const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core); + + const tierLabels = ['trusted-contributor']; + for (const name of tierLabels) { + await h.ensureLabel(name); + } + + const contributorCache = new Map(); + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', per_page: 100, + }); + + let processed = 0; + let failures = 0; + for (const issue of issues) { + if (processed >= maxItems) break; + if (issue.pull_request) continue; + + try { + const author = issue.user.login; + const info = await h.getContributorInfo(contributorCache, author, issue.user.type); + + const labels = [info.isExternal ? 'external' : 'internal']; + if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) { + labels.push('trusted-contributor'); + } + + // Ensure all labels exist before batch add + for (const name of labels) { + await h.ensureLabel(name); + } + + // Remove stale tier labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: issue.number, per_page: 100 }, + )).map(l => l.name ?? ''); + for (const name of currentLabels) { + if (tierLabels.includes(name) && !labels.includes(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: issue.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: issue.number, labels, + }); + console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process issue #${issue.number}: ${e.message}`); + } + } + + console.log(`\nBackfill complete. Processed ${processed} issues, ${failures} failures. ${contributorCache.size} unique authors.`); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2492fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +.vs/ +.claude/ +.idea/ +#Emacs backup +*~ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Google GitHub Actions credentials files created by: +# https://github.com/google-github-actions/auth +# +# That action recommends adding this gitignore to prevent accidentally committing keys. +gha-creds-*.json + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.codspeed/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints +notebooks/ + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv* +venv* +env/ +ENV/ +env.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.mypy_cache_test/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# macOS display setting files +.DS_Store + +# Wandb directory +wandb/ + +# asdf tool versions +.tool-versions +/.ruff_cache/ + +*.pkl +*.bin + +# integration test artifacts +data_map* +\[('_type', 'fake'), ('stop', None)] + +# Replit files +*replit* + +node_modules + +prof +virtualenv/ +scratch/ + +.langgraph_api/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..1da2ca7 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,14 @@ +{ + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD025": false, + "MD033": false, + "MD034": false, + "MD036": false, + "MD041": false, + "MD046": { + "style": "fenced" + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c55e4cb --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "docs-langchain": { + "type": "http", + "url": "https://docs.langchain.com/mcp" + }, + "reference-langchain": { + "type": "http", + "url": "https://reference.langchain.com/mcp" + } + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e7ebedc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,215 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: no-commit-to-branch # prevent direct commits to protected branches + args: ["--branch", "master"] + - id: check-yaml # validate YAML syntax + args: ["--unsafe"] # allow custom tags + - id: check-toml # validate TOML syntax + - id: end-of-file-fixer # ensure files end with a newline + - id: trailing-whitespace # remove trailing whitespace from lines + exclude: \.ambr$ + + # Text normalization hooks for consistent formatting + - repo: https://github.com/sirosen/texthooks + rev: 0.6.8 + hooks: + - id: fix-smartquotes # replace curly quotes with straight quotes + - id: fix-spaces # replace non-standard spaces (e.g., non-breaking) with regular spaces + + # Per-package format and lint hooks for the monorepo + - repo: local + hooks: + - id: core + name: format and lint core + language: system + entry: make -C libs/core format lint + files: ^libs/core/ + pass_filenames: false + - id: langchain + name: format and lint langchain + language: system + entry: make -C libs/langchain format lint + files: ^libs/langchain/ + pass_filenames: false + - id: standard-tests + name: format and lint standard-tests + language: system + entry: make -C libs/standard-tests format lint + files: ^libs/standard-tests/ + pass_filenames: false + - id: text-splitters + name: format and lint text-splitters + language: system + entry: make -C libs/text-splitters format lint + files: ^libs/text-splitters/ + pass_filenames: false + - id: anthropic + name: format and lint partners/anthropic + language: system + entry: make -C libs/partners/anthropic format lint + files: ^libs/partners/anthropic/ + pass_filenames: false + - id: chroma + name: format and lint partners/chroma + language: system + entry: make -C libs/partners/chroma format lint + files: ^libs/partners/chroma/ + pass_filenames: false + - id: exa + name: format and lint partners/exa + language: system + entry: make -C libs/partners/exa format lint + files: ^libs/partners/exa/ + pass_filenames: false + - id: fireworks + name: format and lint partners/fireworks + language: system + entry: make -C libs/partners/fireworks format lint + files: ^libs/partners/fireworks/ + pass_filenames: false + - id: groq + name: format and lint partners/groq + language: system + entry: make -C libs/partners/groq format lint + files: ^libs/partners/groq/ + pass_filenames: false + - id: huggingface + name: format and lint partners/huggingface + language: system + entry: make -C libs/partners/huggingface format lint + files: ^libs/partners/huggingface/ + pass_filenames: false + - id: mistralai + name: format and lint partners/mistralai + language: system + entry: make -C libs/partners/mistralai format lint + files: ^libs/partners/mistralai/ + pass_filenames: false + - id: nomic + name: format and lint partners/nomic + language: system + entry: make -C libs/partners/nomic format lint + files: ^libs/partners/nomic/ + pass_filenames: false + - id: ollama + name: format and lint partners/ollama + language: system + entry: make -C libs/partners/ollama format lint + files: ^libs/partners/ollama/ + pass_filenames: false + - id: openai + name: format and lint partners/openai + language: system + entry: make -C libs/partners/openai format lint + files: ^libs/partners/openai/ + pass_filenames: false + - id: qdrant + name: format and lint partners/qdrant + language: system + entry: make -C libs/partners/qdrant format lint + files: ^libs/partners/qdrant/ + pass_filenames: false + - id: core-version + name: check core version consistency + language: system + entry: make -C libs/core check_version + files: ^libs/core/(pyproject\.toml|langchain_core/version\.py)$ + pass_filenames: false + - id: langchain-v1-version + name: check langchain version consistency + language: system + entry: make -C libs/langchain_v1 check_version + files: ^libs/langchain_v1/(pyproject\.toml|langchain/__init__\.py)$ + pass_filenames: false + - id: anthropic-version + name: check anthropic version consistency + language: system + entry: make -C libs/partners/anthropic check_version + files: ^libs/partners/anthropic/(pyproject\.toml|langchain_anthropic/_version\.py)$ + pass_filenames: false + - id: chroma-version + name: check chroma version consistency + language: system + entry: make -C libs/partners/chroma check_version + files: ^libs/partners/chroma/(pyproject\.toml|langchain_chroma/_version\.py)$ + pass_filenames: false + - id: deepseek-version + name: check deepseek version consistency + language: system + entry: make -C libs/partners/deepseek check_version + files: ^libs/partners/deepseek/(pyproject\.toml|langchain_deepseek/_version\.py)$ + pass_filenames: false + - id: exa-version + name: check exa version consistency + language: system + entry: make -C libs/partners/exa check_version + files: ^libs/partners/exa/(pyproject\.toml|langchain_exa/_version\.py)$ + pass_filenames: false + - id: fireworks-version + name: check fireworks version consistency + language: system + entry: make -C libs/partners/fireworks check_version + files: ^libs/partners/fireworks/(pyproject\.toml|langchain_fireworks/_version\.py)$ + pass_filenames: false + - id: groq-version + name: check groq version consistency + language: system + entry: make -C libs/partners/groq check_version + files: ^libs/partners/groq/(pyproject\.toml|langchain_groq/_version\.py)$ + pass_filenames: false + - id: huggingface-version + name: check huggingface version consistency + language: system + entry: make -C libs/partners/huggingface check_version + files: ^libs/partners/huggingface/(pyproject\.toml|langchain_huggingface/_version\.py)$ + pass_filenames: false + - id: mistralai-version + name: check mistralai version consistency + language: system + entry: make -C libs/partners/mistralai check_version + files: ^libs/partners/mistralai/(pyproject\.toml|langchain_mistralai/_version\.py)$ + pass_filenames: false + - id: nomic-version + name: check nomic version consistency + language: system + entry: make -C libs/partners/nomic check_version + files: ^libs/partners/nomic/(pyproject\.toml|langchain_nomic/_version\.py)$ + pass_filenames: false + - id: ollama-version + name: check ollama version consistency + language: system + entry: make -C libs/partners/ollama check_version + files: ^libs/partners/ollama/(pyproject\.toml|langchain_ollama/_version\.py)$ + pass_filenames: false + - id: openai-version + name: check openai version consistency + language: system + entry: make -C libs/partners/openai check_version + files: ^libs/partners/openai/(pyproject\.toml|langchain_openai/_version\.py)$ + pass_filenames: false + - id: openrouter-version + name: check openrouter version consistency + language: system + entry: make -C libs/partners/openrouter check_version + files: ^libs/partners/openrouter/(pyproject\.toml|langchain_openrouter/_version\.py)$ + pass_filenames: false + - id: perplexity-version + name: check perplexity version consistency + language: system + entry: make -C libs/partners/perplexity check_version + files: ^libs/partners/perplexity/(pyproject\.toml|langchain_perplexity/_version\.py)$ + pass_filenames: false + - id: qdrant-version + name: check qdrant version consistency + language: system + entry: make -C libs/partners/qdrant check_version + files: ^libs/partners/qdrant/(pyproject\.toml|langchain_qdrant/_version\.py)$ + pass_filenames: false + - id: xai-version + name: check xai version consistency + language: system + entry: make -C libs/partners/xai check_version + files: ^libs/partners/xai/(pyproject\.toml|langchain_xai/_version\.py)$ + pass_filenames: false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ec9580c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,19 @@ +{ + "recommendations": [ + "ms-python.python", + "charliermarsh.ruff", + "ms-python.mypy-type-checker", + "ms-toolsai.jupyter", + "ms-toolsai.jupyter-keymap", + "ms-toolsai.jupyter-renderers", + "yzhang.markdown-all-in-one", + "davidanson.vscode-markdownlint", + "bierner.markdown-mermaid", + "bierner.markdown-preview-github-styles", + "eamodio.gitlens", + "github.vscode-pull-request-github", + "github.vscode-github-actions", + "redhat.vscode-yaml", + "editorconfig.editorconfig", + ], +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..38fcf12 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,78 @@ +{ + "python.analysis.include": [ + "libs/**", + ], + "python.analysis.exclude": [ + "**/node_modules", + "**/__pycache__", + "**/.pytest_cache", + "**/.*", + ], + "python.analysis.autoImportCompletions": true, + "python.analysis.typeCheckingMode": "basic", + "python.testing.cwd": "${workspaceFolder}", + "python.linting.enabled": true, + "python.linting.ruffEnabled": true, + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "explicit", + "source.fixAll": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "editor.rulers": [ + 88 + ], + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.trimAutoWhitespace": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/*.pyc": true, + "**/.mypy_cache": true, + "**/.ruff_cache": true, + "_dist/**": true, + "**/node_modules": true, + "**/.git": false + }, + "search.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "_dist/**": true, + "**/node_modules": true, + "**/.git": true, + "uv.lock": true, + "yarn.lock": true + }, + "git.autofetch": true, + "git.enableSmartCommit": true, + "jupyter.askForKernelRestart": false, + "jupyter.interactiveWindow.textEditor.executeSelection": true, + "[markdown]": { + "editor.wordWrap": "on", + "editor.quickSuggestions": { + "comments": "off", + "strings": "off", + "other": "off" + } + }, + "[yaml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + }, + "[json]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + }, + "python.terminal.activateEnvironment": false, + "python.defaultInterpreterPath": "./.venv/bin/python", + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".github/workflows/pr_lint.yml" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a7078dd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,364 @@ +# Global development guidelines for the LangChain monorepo + +This document provides context to understand the LangChain Python project and assist with development. + +## Project architecture and context + +### Monorepo structure + +This is a Python monorepo with multiple independently versioned packages that use `uv`. + +```txt +langchain/ +├── libs/ +│ ├── core/ # `langchain-core` primitives and base abstractions +│ ├── langchain/ # `langchain-classic` (legacy, no new features) +│ ├── langchain_v1/ # Actively maintained `langchain` package +│ ├── partners/ # Third-party integrations +│ │ ├── openai/ # OpenAI models and embeddings +│ │ ├── anthropic/ # Anthropic (Claude) integration +│ │ ├── ollama/ # Local model support +│ │ └── ... (other integrations maintained by the LangChain team) +│ ├── text-splitters/ # Document chunking utilities +│ ├── standard-tests/ # Shared test suite for integrations +│ ├── model-profiles/ # Model configuration profiles +├── .github/ # CI/CD workflows and templates +├── .vscode/ # VSCode IDE standard settings and recommended extensions +└── README.md # Information about LangChain +``` + +- **Core layer** (`langchain-core`): Base abstractions, interfaces, and protocols. Users should not need to know about this layer directly. +- **Implementation layer** (`langchain`): Concrete implementations and high-level public utilities +- **Integration layer** (`partners/`): Third-party service integrations. Note that this monorepo is not exhaustive of all LangChain integrations; some are maintained in separate repos, such as `langchain-ai/langchain-google` and `langchain-ai/langchain-aws`. Usually these repos are cloned at the same level as this monorepo, so if needed, you can refer to their code directly by navigating to `../langchain-google/` from this monorepo. +- **Testing layer** (`standard-tests/`): Standardized integration tests for partner integrations + +### Development tools & commands + +- `uv` – Fast Python package installer and resolver (replaces pip/poetry) +- `make` – Task runner for common development commands. Feel free to look at the `Makefile` for available commands and usage patterns. +- `ruff` – Fast Python linter and formatter +- `mypy` – Static type checking +- `pytest` – Testing framework + +This monorepo uses `uv` for dependency management. Local development uses editable installs: `[tool.uv.sources]` + +Each package in `libs/` has its own `pyproject.toml` and `uv.lock`. + +Before running your tests, set up all packages by running: + +```bash +# For all groups +uv sync --all-groups + +# or, to install a specific group only: +uv sync --group test +``` + +```bash +# Run unit tests (no network) +make test + +# Run specific test file +uv run --group test pytest tests/unit_tests/test_specific.py +``` + +```bash +# Lint code +make lint + +# Format code +make format + +# Type checking +uv run --group lint mypy . +``` + +#### Environment and dependency management + +Use `uv` for all environment and dependency operations in this monorepo. Do not invoke `pip`, `poetry`, or `conda` directly. + +- Let `uv` manage the interpreter and virtual environments — `uv sync` and `uv run` operate without manual `source .venv/bin/activate`. Do not create ad-hoc virtual environments outside the package directory. +- Each package targets its own supported Python range via its `pyproject.toml`; do not pin a global Python version. If you need an interpreter explicitly, defer to the package's `requires-python` rather than assuming system Python. +- Install dependencies explicitly through `uv sync` (optionally `--group ` / `--all-groups`); never let them install implicitly. +- Don't mix environments within a session, and don't add new dependencies unless strictly required — when you do, justify them (recent releases/commits, adoption). + +#### Key config files + +- pyproject.toml: Main workspace configuration with dependency groups +- uv.lock: Locked dependencies for reproducible builds +- Makefile: Development tasks + +#### PR and commit titles + +Follow Conventional Commits. See `.github/workflows/pr_lint.yml` for allowed types and scopes. All titles must include a scope with no exceptions — even for the main `langchain` package. + +- Start the text after `type(scope):` with a lowercase letter, unless the first word is a proper noun (e.g. `Azure`, `GitHub`, `OpenAI`) or a named entity (class, function, method, parameter, or variable name). +- Wrap named entities in backticks so they render as code. Proper nouns are left unadorned. +- Keep titles short and descriptive — save detail for the body. + +Examples: + +```txt +feat(langchain): add new chat completion feature +fix(core): resolve type hinting issue in vector store +chore(anthropic): update infrastructure dependencies +feat(langchain): `ls_agent_type` tag on `create_agent` calls +fix(openai): infer Azure chat profiles from model name +``` + +#### Branch naming + +Branches should be prefixed `//`: + +- `` — the author's GitHub login (e.g. `mdrxy`). +- `` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.). +- `` — kebab-case, brief, no trailing slash. + +Examples: + +```txt +mdrxy/anthropic/normalize-tool-call-ids +mdrxy/core/vector-store-type-hints +mdrxy/infra/agents-md-branch +``` + +#### PR descriptions + +The description *is* the summary — do not add a `# Summary` header. + +- When the PR closes an issue, lead with the closing keyword on its own line at the very top, followed by a horizontal rule and then the body: + + ```txt + Closes #123 + + --- + + + ``` + + Only `Closes`, `Fixes`, and `Resolves` auto-close the referenced issue on merge. `Related:` or similar labels are informational and do not close anything. + +- Explain the *why*: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary. +- Write for readers who may be unfamiliar with this area of the codebase. Avoid insider shorthand and prefer language that is friendly to public viewers — this aids interpretability. +- Do **not** cite line numbers; they go stale as soon as the file changes. +- Rarely include full file paths or filenames. Reference the affected symbol, class, or subsystem by name instead. +- Wrap class, function, method, parameter, and variable names in backticks. +- For net new features or behavior-changing bugfixes, PR descriptions should include a `## Release note` section that states the user-visible change in release-note-ready language. +- Skip dedicated "Test plan" or "Testing" sections in most cases. Mention tests only when coverage is non-obvious, risky, or otherwise notable. +- Call out areas of the change that require careful review. +- Add a brief disclaimer noting AI-agent involvement in the contribution. + +## Core development principles + +### Maintain stable public interfaces + +CRITICAL: Always attempt to preserve function signatures, argument positions, and names for exported/public methods. Do not make breaking changes. +You should warn the developer for any function signature changes, regardless of whether they look breaking or not. + +**Before making ANY changes to public APIs:** + +- Check if the function/class is exported in `__init__.py` +- Look for existing usage patterns in tests and examples +- Use keyword-only arguments for new parameters: `*, new_param: str = "default"` +- Mark experimental features clearly with docstring warnings (using MkDocs Material admonitions, like `!!! warning`) + +Ask: "Would this change break someone's code if they used it last week?" + +### Code quality standards + +All Python code MUST include type hints and return types. + +```python title="Example" +def filter_unknown_users(users: list[str], known_users: set[str]) -> list[str]: + """Single line description of the function. + + Any additional context about the function can go here. + + Args: + users: List of user identifiers to filter. + known_users: Set of known/valid user identifiers. + + Returns: + List of users that are not in the `known_users` set. + """ +``` + +- Use descriptive, self-explanatory variable names. +- Follow existing patterns in the codebase you're modifying +- Attempt to break up complex functions (>20 lines) into smaller, focused functions where it makes sense + +### Testing requirements + +Every new feature or bugfix MUST be covered by unit tests. + +- Unit tests: `tests/unit_tests/` (no network calls allowed) +- Integration tests: `tests/integration_tests/` (network calls permitted) +- We use `pytest` as the testing framework; if in doubt, check other existing tests for examples. +- The testing file structure should mirror the source code structure. + +**Checklist:** + +- [ ] Tests fail when your new logic is broken +- [ ] Happy path is covered +- [ ] Edge cases and error conditions are tested +- [ ] Use fixtures/mocks for external dependencies +- [ ] Tests are deterministic (no flaky tests) +- [ ] Does the test suite fail if your new logic is broken? + +### Security and risk assessment + +- No `eval()`, `exec()`, or `pickle` on user-controlled input +- Proper exception handling (no bare `except:`) and use a `msg` variable for error messages +- Remove unreachable/commented code before committing +- Race conditions or resource leaks (file handles, sockets, threads). +- Ensure proper resource cleanup (file handles, connections) + +### Documentation standards + +Use Google-style docstrings with Args section for all public functions. + +```python title="Example" +def send_email(to: str, msg: str, *, priority: str = "normal") -> bool: + """Send an email to a recipient with specified priority. + + Any additional context about the function can go here. + + Args: + to: The email address of the recipient. + msg: The message body to send. + priority: Email priority level. + + Returns: + `True` if email was sent successfully, `False` otherwise. + + Raises: + InvalidEmailError: If the email address format is invalid. + SMTPConnectionError: If unable to connect to email server. + """ +``` + +- Types go in function signatures, NOT in docstrings + - If a default is present, DO NOT repeat it in the docstring unless there is post-processing or it is set conditionally. +- Focus on "why" rather than "what" in descriptions +- Document all parameters, return values, and exceptions +- Keep descriptions concise but clear +- Ensure American English spelling (e.g., "behavior", not "behaviour") +- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments. + +#### Model references in docs and examples + +Always use the latest generally available (GA) models when referencing LLMs in docstrings and illustrative code snippets. Avoid preview or beta identifiers unless the model has no GA equivalent. Outdated model names signal stale code and confuse users. + +Before writing or updating model references, verify current model IDs against the provider's official docs. Do not rely on memorized or cached model names — they go stale quickly. + +Changing **shipped default parameter values** in code (e.g., a `model=` kwarg default in a class constructor) may constitute a breaking change — see "Maintain stable public interfaces" above. This guidance applies to documentation and examples, not code defaults. + +For model *profile data* (capability flags, context windows), use the `langchain-profiles` CLI described below. + +## Model profiles + +Model profiles are generated using the `langchain-profiles` CLI in `libs/model-profiles`. The `--data-dir` must point to the directory containing `profile_augmentations.toml`, not the top-level package directory. + +```bash +# Run from libs/model-profiles +cd libs/model-profiles + +# Refresh profiles for a partner in this repo +uv run langchain-profiles refresh --provider openai --data-dir ../partners/openai/langchain_openai/data + +# Refresh profiles for a partner in an external repo (requires echo y to confirm) +echo y | uv run langchain-profiles refresh --provider google --data-dir /path/to/langchain-google/libs/genai/langchain_google_genai/data +``` + +Example partners with profiles in this repo: + +- `libs/partners/openai/langchain_openai/data/` (provider: `openai`) +- `libs/partners/anthropic/langchain_anthropic/data/` (provider: `anthropic`) +- `libs/partners/perplexity/langchain_perplexity/data/` (provider: `perplexity`) + +The `echo y |` pipe is required when `--data-dir` is outside the `libs/model-profiles` working directory. + +## CI/CD infrastructure + +### Release process + +Each partner package is released independently. The full flow is: + +1. **Version bump PR.** Create a PR that bumps three files by one line each: + - `langchain_/_version.py` — `__version__` + - `pyproject.toml` — `version` + - `uv.lock` — run `uv lock` from the package directory. If the diff includes unrelated changes (e.g. environment-dependent marker lines from a different local Python version), revert them and keep only the `version = "..."` line for the package being released + + Title follows Conventional Commits: `release(): ` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/-`. + + Patch vs. minor bump follows in-repo precedent: within a `0.x` series, fixes and additive features get a patch bump (e.g. `session_id` field → 0.2.1→0.2.2, `parallel_tool_calls` → 0.2.3→0.2.4). + +2. **Merge the PR** to `master`. + +3. **Trigger the release workflow.** Run `gh workflow run` against the "🚀 Package Release" workflow (`_release.yml`, file ID `63880841`): + + ```bash + gh workflow run 63880841 --repo langchain-ai/langchain \ + -f working-directory= -f release-version= + ``` + + `working-directory` is the short partner name from the workflow's dropdown (e.g. `openrouter`, not `libs/partners/openrouter`). + +4. **The workflow handles everything else automatically** — do **not** create a GitHub release or tag manually. The `mark-release` job (using `ncipollo/release-action`) creates the GitHub release, tag, and release notes after PyPI publish succeeds. The release notes body is auto-generated from commit history between the previous tag and HEAD. + + Monitor the run: + + ```bash + gh run view --repo langchain-ai/langchain + ``` + + The full job chain is: build → release-notes → pre-release-checks → TestPyPI publish → PyPI publish → tag GitHub release. + +### PR labeling and linting + +**Title linting** (`.github/workflows/pr_lint.yml`) + +**Auto-labeling:** + +- `.github/workflows/pr_labeler.yml` – Unified PR labeler (size, file, title, external/internal, contributor tier) +- `.github/workflows/pr_labeler_backfill.yml` – Manual backfill of PR labels on open PRs +- `.github/workflows/auto-label-by-package.yml` – Issue labeling by package +- `.github/workflows/tag-external-issues.yml` – Issue external/internal classification + +### Integration test tracing (LangSmith) + +Scheduled and manually dispatched integration tests (`integration_tests.yml`) trace every run to LangSmith so failures link back to the originating Actions run. (`_release.yml` runs integration tests too, but does not currently configure LangSmith tracing.) + +**Env vars set by CI:** + +- `LANGSMITH_API_KEY` — authenticates to LangSmith (repo secret, scoped to the "Scheduled testing" GitHub environment in `integration_tests.yml`). +- `LANGSMITH_TRACING: "true"` — enables tracing for the test process. +- `LANGSMITH_PROJECT` — the project traces are sent to. Defaults to `scheduled-testing-py` via a repo variable override: `${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}`. To change the project, set the `LANGSMITH_PROJECT` repository variable in GitHub settings — do not hardcode it in the workflow. +- `LANGSMITH_TAGS` — comma-separated tags identifying the run: `github-actions`, the matrix working directory (e.g. `libs/partners/openai`), the Python version, and the commit SHA. +- `LANGSMITH_METADATA` — a JSON object built by the "Build LangSmith Metadata" step, containing `github_sha`, `github_run_id`, `github_run_attempt`, `github_run_url`, `github_workflow`, `github_event`, `github_ref`, `working_directory`, and `python_version`. + +**The tracing bridge plugin:** The LangSmith SDK does not natively read `LANGSMITH_TAGS` or `LANGSMITH_METADATA` from the environment. The pytest plugin at `libs/standard-tests/langchain_tests/_langsmith_plugin.py` bridges that gap by entering `langsmith.run_helpers.tracing_context` for the duration of the test session. It only activates when `GITHUB_ACTIONS=true`, so local development is unaffected. Auto-discovered via the `pytest11` entry point in any package that depends on `langchain-tests`. + +**Unit test isolation:** Unit tests must never make network calls or send traces. The `make test` target in the `libs/core` Makefile uses `env -u` to unset the tracing vars (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGSMITH_API_KEY`, `LANGSMITH_TRACING`, `LANGCHAIN_PROJECT`) before running pytest. Additionally, `libs/core/tests/unit_tests/runnables/conftest.py` has a session-scoped autouse fixture that explicitly disables tracing for runnable unit tests, restoring the original environment afterward. + +### Adding a new partner to CI + +When adding a new partner package, update these files: + +- `.github/ISSUE_TEMPLATE/*.yml` – Add to package dropdown +- `.github/dependabot.yml` – Add dependency update entry +- `.github/scripts/pr-labeler-config.json` – Add file rule and scope-to-label mapping +- `.github/workflows/_release.yml` – Add API key secrets if needed +- `.github/workflows/auto-label-by-package.yml` – Add package label +- `.github/workflows/check_diffs.yml` – Add to change detection +- `.github/workflows/integration_tests.yml` – Add integration test config +- `.github/workflows/pr_lint.yml` – Add to allowed scopes + +## GitHub Actions & Workflows + +This repository require actions to be pinned to a full-length commit SHA. Attempting to use a tag will fail. Use the `gh` cli to query. Verify tags are not annotated tag objects (which would need dereferencing). + +## Additional resources + +- **Documentation:** https://docs.langchain.com/oss/python/langchain/overview and source at https://github.com/langchain-ai/docs or `../docs/`. Prefer the local install and use file search tools for best results. If needed, use the docs MCP server as defined in `.mcp.json` for programmatic access. +- **Contributing Guide:** [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview) diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..9c78892 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,8 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: +- family-names: "Chase" + given-names: "Harrison" +title: "LangChain" +date-released: 2022-10-17 +url: "https://github.com/langchain-ai/langchain" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a7078dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,364 @@ +# Global development guidelines for the LangChain monorepo + +This document provides context to understand the LangChain Python project and assist with development. + +## Project architecture and context + +### Monorepo structure + +This is a Python monorepo with multiple independently versioned packages that use `uv`. + +```txt +langchain/ +├── libs/ +│ ├── core/ # `langchain-core` primitives and base abstractions +│ ├── langchain/ # `langchain-classic` (legacy, no new features) +│ ├── langchain_v1/ # Actively maintained `langchain` package +│ ├── partners/ # Third-party integrations +│ │ ├── openai/ # OpenAI models and embeddings +│ │ ├── anthropic/ # Anthropic (Claude) integration +│ │ ├── ollama/ # Local model support +│ │ └── ... (other integrations maintained by the LangChain team) +│ ├── text-splitters/ # Document chunking utilities +│ ├── standard-tests/ # Shared test suite for integrations +│ ├── model-profiles/ # Model configuration profiles +├── .github/ # CI/CD workflows and templates +├── .vscode/ # VSCode IDE standard settings and recommended extensions +└── README.md # Information about LangChain +``` + +- **Core layer** (`langchain-core`): Base abstractions, interfaces, and protocols. Users should not need to know about this layer directly. +- **Implementation layer** (`langchain`): Concrete implementations and high-level public utilities +- **Integration layer** (`partners/`): Third-party service integrations. Note that this monorepo is not exhaustive of all LangChain integrations; some are maintained in separate repos, such as `langchain-ai/langchain-google` and `langchain-ai/langchain-aws`. Usually these repos are cloned at the same level as this monorepo, so if needed, you can refer to their code directly by navigating to `../langchain-google/` from this monorepo. +- **Testing layer** (`standard-tests/`): Standardized integration tests for partner integrations + +### Development tools & commands + +- `uv` – Fast Python package installer and resolver (replaces pip/poetry) +- `make` – Task runner for common development commands. Feel free to look at the `Makefile` for available commands and usage patterns. +- `ruff` – Fast Python linter and formatter +- `mypy` – Static type checking +- `pytest` – Testing framework + +This monorepo uses `uv` for dependency management. Local development uses editable installs: `[tool.uv.sources]` + +Each package in `libs/` has its own `pyproject.toml` and `uv.lock`. + +Before running your tests, set up all packages by running: + +```bash +# For all groups +uv sync --all-groups + +# or, to install a specific group only: +uv sync --group test +``` + +```bash +# Run unit tests (no network) +make test + +# Run specific test file +uv run --group test pytest tests/unit_tests/test_specific.py +``` + +```bash +# Lint code +make lint + +# Format code +make format + +# Type checking +uv run --group lint mypy . +``` + +#### Environment and dependency management + +Use `uv` for all environment and dependency operations in this monorepo. Do not invoke `pip`, `poetry`, or `conda` directly. + +- Let `uv` manage the interpreter and virtual environments — `uv sync` and `uv run` operate without manual `source .venv/bin/activate`. Do not create ad-hoc virtual environments outside the package directory. +- Each package targets its own supported Python range via its `pyproject.toml`; do not pin a global Python version. If you need an interpreter explicitly, defer to the package's `requires-python` rather than assuming system Python. +- Install dependencies explicitly through `uv sync` (optionally `--group ` / `--all-groups`); never let them install implicitly. +- Don't mix environments within a session, and don't add new dependencies unless strictly required — when you do, justify them (recent releases/commits, adoption). + +#### Key config files + +- pyproject.toml: Main workspace configuration with dependency groups +- uv.lock: Locked dependencies for reproducible builds +- Makefile: Development tasks + +#### PR and commit titles + +Follow Conventional Commits. See `.github/workflows/pr_lint.yml` for allowed types and scopes. All titles must include a scope with no exceptions — even for the main `langchain` package. + +- Start the text after `type(scope):` with a lowercase letter, unless the first word is a proper noun (e.g. `Azure`, `GitHub`, `OpenAI`) or a named entity (class, function, method, parameter, or variable name). +- Wrap named entities in backticks so they render as code. Proper nouns are left unadorned. +- Keep titles short and descriptive — save detail for the body. + +Examples: + +```txt +feat(langchain): add new chat completion feature +fix(core): resolve type hinting issue in vector store +chore(anthropic): update infrastructure dependencies +feat(langchain): `ls_agent_type` tag on `create_agent` calls +fix(openai): infer Azure chat profiles from model name +``` + +#### Branch naming + +Branches should be prefixed `//`: + +- `` — the author's GitHub login (e.g. `mdrxy`). +- `` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.). +- `` — kebab-case, brief, no trailing slash. + +Examples: + +```txt +mdrxy/anthropic/normalize-tool-call-ids +mdrxy/core/vector-store-type-hints +mdrxy/infra/agents-md-branch +``` + +#### PR descriptions + +The description *is* the summary — do not add a `# Summary` header. + +- When the PR closes an issue, lead with the closing keyword on its own line at the very top, followed by a horizontal rule and then the body: + + ```txt + Closes #123 + + --- + + + ``` + + Only `Closes`, `Fixes`, and `Resolves` auto-close the referenced issue on merge. `Related:` or similar labels are informational and do not close anything. + +- Explain the *why*: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary. +- Write for readers who may be unfamiliar with this area of the codebase. Avoid insider shorthand and prefer language that is friendly to public viewers — this aids interpretability. +- Do **not** cite line numbers; they go stale as soon as the file changes. +- Rarely include full file paths or filenames. Reference the affected symbol, class, or subsystem by name instead. +- Wrap class, function, method, parameter, and variable names in backticks. +- For net new features or behavior-changing bugfixes, PR descriptions should include a `## Release note` section that states the user-visible change in release-note-ready language. +- Skip dedicated "Test plan" or "Testing" sections in most cases. Mention tests only when coverage is non-obvious, risky, or otherwise notable. +- Call out areas of the change that require careful review. +- Add a brief disclaimer noting AI-agent involvement in the contribution. + +## Core development principles + +### Maintain stable public interfaces + +CRITICAL: Always attempt to preserve function signatures, argument positions, and names for exported/public methods. Do not make breaking changes. +You should warn the developer for any function signature changes, regardless of whether they look breaking or not. + +**Before making ANY changes to public APIs:** + +- Check if the function/class is exported in `__init__.py` +- Look for existing usage patterns in tests and examples +- Use keyword-only arguments for new parameters: `*, new_param: str = "default"` +- Mark experimental features clearly with docstring warnings (using MkDocs Material admonitions, like `!!! warning`) + +Ask: "Would this change break someone's code if they used it last week?" + +### Code quality standards + +All Python code MUST include type hints and return types. + +```python title="Example" +def filter_unknown_users(users: list[str], known_users: set[str]) -> list[str]: + """Single line description of the function. + + Any additional context about the function can go here. + + Args: + users: List of user identifiers to filter. + known_users: Set of known/valid user identifiers. + + Returns: + List of users that are not in the `known_users` set. + """ +``` + +- Use descriptive, self-explanatory variable names. +- Follow existing patterns in the codebase you're modifying +- Attempt to break up complex functions (>20 lines) into smaller, focused functions where it makes sense + +### Testing requirements + +Every new feature or bugfix MUST be covered by unit tests. + +- Unit tests: `tests/unit_tests/` (no network calls allowed) +- Integration tests: `tests/integration_tests/` (network calls permitted) +- We use `pytest` as the testing framework; if in doubt, check other existing tests for examples. +- The testing file structure should mirror the source code structure. + +**Checklist:** + +- [ ] Tests fail when your new logic is broken +- [ ] Happy path is covered +- [ ] Edge cases and error conditions are tested +- [ ] Use fixtures/mocks for external dependencies +- [ ] Tests are deterministic (no flaky tests) +- [ ] Does the test suite fail if your new logic is broken? + +### Security and risk assessment + +- No `eval()`, `exec()`, or `pickle` on user-controlled input +- Proper exception handling (no bare `except:`) and use a `msg` variable for error messages +- Remove unreachable/commented code before committing +- Race conditions or resource leaks (file handles, sockets, threads). +- Ensure proper resource cleanup (file handles, connections) + +### Documentation standards + +Use Google-style docstrings with Args section for all public functions. + +```python title="Example" +def send_email(to: str, msg: str, *, priority: str = "normal") -> bool: + """Send an email to a recipient with specified priority. + + Any additional context about the function can go here. + + Args: + to: The email address of the recipient. + msg: The message body to send. + priority: Email priority level. + + Returns: + `True` if email was sent successfully, `False` otherwise. + + Raises: + InvalidEmailError: If the email address format is invalid. + SMTPConnectionError: If unable to connect to email server. + """ +``` + +- Types go in function signatures, NOT in docstrings + - If a default is present, DO NOT repeat it in the docstring unless there is post-processing or it is set conditionally. +- Focus on "why" rather than "what" in descriptions +- Document all parameters, return values, and exceptions +- Keep descriptions concise but clear +- Ensure American English spelling (e.g., "behavior", not "behaviour") +- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments. + +#### Model references in docs and examples + +Always use the latest generally available (GA) models when referencing LLMs in docstrings and illustrative code snippets. Avoid preview or beta identifiers unless the model has no GA equivalent. Outdated model names signal stale code and confuse users. + +Before writing or updating model references, verify current model IDs against the provider's official docs. Do not rely on memorized or cached model names — they go stale quickly. + +Changing **shipped default parameter values** in code (e.g., a `model=` kwarg default in a class constructor) may constitute a breaking change — see "Maintain stable public interfaces" above. This guidance applies to documentation and examples, not code defaults. + +For model *profile data* (capability flags, context windows), use the `langchain-profiles` CLI described below. + +## Model profiles + +Model profiles are generated using the `langchain-profiles` CLI in `libs/model-profiles`. The `--data-dir` must point to the directory containing `profile_augmentations.toml`, not the top-level package directory. + +```bash +# Run from libs/model-profiles +cd libs/model-profiles + +# Refresh profiles for a partner in this repo +uv run langchain-profiles refresh --provider openai --data-dir ../partners/openai/langchain_openai/data + +# Refresh profiles for a partner in an external repo (requires echo y to confirm) +echo y | uv run langchain-profiles refresh --provider google --data-dir /path/to/langchain-google/libs/genai/langchain_google_genai/data +``` + +Example partners with profiles in this repo: + +- `libs/partners/openai/langchain_openai/data/` (provider: `openai`) +- `libs/partners/anthropic/langchain_anthropic/data/` (provider: `anthropic`) +- `libs/partners/perplexity/langchain_perplexity/data/` (provider: `perplexity`) + +The `echo y |` pipe is required when `--data-dir` is outside the `libs/model-profiles` working directory. + +## CI/CD infrastructure + +### Release process + +Each partner package is released independently. The full flow is: + +1. **Version bump PR.** Create a PR that bumps three files by one line each: + - `langchain_/_version.py` — `__version__` + - `pyproject.toml` — `version` + - `uv.lock` — run `uv lock` from the package directory. If the diff includes unrelated changes (e.g. environment-dependent marker lines from a different local Python version), revert them and keep only the `version = "..."` line for the package being released + + Title follows Conventional Commits: `release(): ` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/-`. + + Patch vs. minor bump follows in-repo precedent: within a `0.x` series, fixes and additive features get a patch bump (e.g. `session_id` field → 0.2.1→0.2.2, `parallel_tool_calls` → 0.2.3→0.2.4). + +2. **Merge the PR** to `master`. + +3. **Trigger the release workflow.** Run `gh workflow run` against the "🚀 Package Release" workflow (`_release.yml`, file ID `63880841`): + + ```bash + gh workflow run 63880841 --repo langchain-ai/langchain \ + -f working-directory= -f release-version= + ``` + + `working-directory` is the short partner name from the workflow's dropdown (e.g. `openrouter`, not `libs/partners/openrouter`). + +4. **The workflow handles everything else automatically** — do **not** create a GitHub release or tag manually. The `mark-release` job (using `ncipollo/release-action`) creates the GitHub release, tag, and release notes after PyPI publish succeeds. The release notes body is auto-generated from commit history between the previous tag and HEAD. + + Monitor the run: + + ```bash + gh run view --repo langchain-ai/langchain + ``` + + The full job chain is: build → release-notes → pre-release-checks → TestPyPI publish → PyPI publish → tag GitHub release. + +### PR labeling and linting + +**Title linting** (`.github/workflows/pr_lint.yml`) + +**Auto-labeling:** + +- `.github/workflows/pr_labeler.yml` – Unified PR labeler (size, file, title, external/internal, contributor tier) +- `.github/workflows/pr_labeler_backfill.yml` – Manual backfill of PR labels on open PRs +- `.github/workflows/auto-label-by-package.yml` – Issue labeling by package +- `.github/workflows/tag-external-issues.yml` – Issue external/internal classification + +### Integration test tracing (LangSmith) + +Scheduled and manually dispatched integration tests (`integration_tests.yml`) trace every run to LangSmith so failures link back to the originating Actions run. (`_release.yml` runs integration tests too, but does not currently configure LangSmith tracing.) + +**Env vars set by CI:** + +- `LANGSMITH_API_KEY` — authenticates to LangSmith (repo secret, scoped to the "Scheduled testing" GitHub environment in `integration_tests.yml`). +- `LANGSMITH_TRACING: "true"` — enables tracing for the test process. +- `LANGSMITH_PROJECT` — the project traces are sent to. Defaults to `scheduled-testing-py` via a repo variable override: `${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}`. To change the project, set the `LANGSMITH_PROJECT` repository variable in GitHub settings — do not hardcode it in the workflow. +- `LANGSMITH_TAGS` — comma-separated tags identifying the run: `github-actions`, the matrix working directory (e.g. `libs/partners/openai`), the Python version, and the commit SHA. +- `LANGSMITH_METADATA` — a JSON object built by the "Build LangSmith Metadata" step, containing `github_sha`, `github_run_id`, `github_run_attempt`, `github_run_url`, `github_workflow`, `github_event`, `github_ref`, `working_directory`, and `python_version`. + +**The tracing bridge plugin:** The LangSmith SDK does not natively read `LANGSMITH_TAGS` or `LANGSMITH_METADATA` from the environment. The pytest plugin at `libs/standard-tests/langchain_tests/_langsmith_plugin.py` bridges that gap by entering `langsmith.run_helpers.tracing_context` for the duration of the test session. It only activates when `GITHUB_ACTIONS=true`, so local development is unaffected. Auto-discovered via the `pytest11` entry point in any package that depends on `langchain-tests`. + +**Unit test isolation:** Unit tests must never make network calls or send traces. The `make test` target in the `libs/core` Makefile uses `env -u` to unset the tracing vars (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGSMITH_API_KEY`, `LANGSMITH_TRACING`, `LANGCHAIN_PROJECT`) before running pytest. Additionally, `libs/core/tests/unit_tests/runnables/conftest.py` has a session-scoped autouse fixture that explicitly disables tracing for runnable unit tests, restoring the original environment afterward. + +### Adding a new partner to CI + +When adding a new partner package, update these files: + +- `.github/ISSUE_TEMPLATE/*.yml` – Add to package dropdown +- `.github/dependabot.yml` – Add dependency update entry +- `.github/scripts/pr-labeler-config.json` – Add file rule and scope-to-label mapping +- `.github/workflows/_release.yml` – Add API key secrets if needed +- `.github/workflows/auto-label-by-package.yml` – Add package label +- `.github/workflows/check_diffs.yml` – Add to change detection +- `.github/workflows/integration_tests.yml` – Add integration test config +- `.github/workflows/pr_lint.yml` – Add to allowed scopes + +## GitHub Actions & Workflows + +This repository require actions to be pinned to a full-length commit SHA. Attempting to use a tag will fail. Use the `gh` cli to query. Verify tags are not annotated tag objects (which would need dereferencing). + +## Additional resources + +- **Documentation:** https://docs.langchain.com/oss/python/langchain/overview and source at https://github.com/langchain-ai/docs or `../docs/`. Prefer the local install and use file search tools for best results. If needed, use the docs MCP server as defined in `.mcp.json` for programmatic access. +- **Contributing Guide:** [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3957738 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f8f259 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ + + +
+

The agent engineering platform.

+
+ +
+ PyPI - License + PyPI - Downloads + Version + Twitter / X +
+ +
+ +LangChain is a framework for building agents and LLM-powered applications. It helps you chain together interoperable components and third-party integrations to simplify AI application development — all while future-proofing decisions as the underlying technology evolves. + +> [!TIP] +> Just getting started? Check out **[Deep Agents](http://docs.langchain.com/oss/python/deepagents/)** — a higher-level package built on LangChain for agents that have built-in capabilites for common usage patterns such as planning, subagents, file system usage, and more. + +## Quickstart + +```bash +uv add langchain +``` + +```python +from langchain.chat_models import init_chat_model + +model = init_chat_model("openai:gpt-5.5") +result = model.invoke("Hello, world!") +``` + +If you're looking for more advanced customization or agent orchestration, check out [LangGraph](https://github.com/langchain-ai/langgraph), our framework for building controllable agent workflows. + +For an equivalent JS/TS library, check out [LangChain.js](https://github.com/langchain-ai/langchainjs). + +> [!TIP] +> For developing, debugging, and deploying AI agents and LLM applications, see [LangSmith](https://docs.langchain.com/langsmith/home). + +## LangChain ecosystem + +While the LangChain framework can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools when building LLM applications. + +- **[Deep Agents](http://docs.langchain.com/oss/python/deepagents/)** — Build agents that can plan, use subagents, and leverage file systems for complex tasks +- **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** — Build agents that can reliably handle complex tasks with our low-level agent orchestration framework +- **[Integrations](https://docs.langchain.com/oss/python/integrations/providers/overview)** — Chat & embedding models, tools & toolkits, and more +- **[LangSmith](https://www.langchain.com/langsmith)** — Agent evals, observability, and debugging for LLM apps +- **[LangSmith Deployment](https://docs.langchain.com/langsmith/deployments)** — Deploy and scale agents with a purpose-built platform for long-running, stateful workflows + +## Why use LangChain? + +LangChain helps developers build applications powered by LLMs through a standard interface for models, embeddings, vector stores, and more. + +- **Real-time data augmentation** — Easily connect LLMs to diverse data sources and external/internal systems, drawing from LangChain's vast library of integrations with model providers, tools, vector stores, retrievers, and more +- **Model interoperability** — Swap models in and out as your engineering team experiments to find the best choice for your application's needs. As the industry frontier evolves, adapt quickly — LangChain's abstractions keep you moving without losing momentum +- **Rapid prototyping** — Quickly build and iterate on LLM applications with LangChain's modular, component-based architecture. Test different approaches and workflows without rebuilding from scratch, accelerating your development cycle +- **Production-ready features** — Deploy reliable applications with built-in support for monitoring, evaluation, and debugging through integrations like LangSmith. Scale with confidence using battle-tested patterns and best practices +- **Vibrant community and ecosystem** — Leverage a rich ecosystem of integrations, templates, and community-contributed components. Benefit from continuous improvements and stay up-to-date with the latest AI developments through an active open-source community +- **Flexible abstraction layers** — Work at the level of abstraction that suits your needs — from high-level chains for quick starts to low-level components for fine-grained control. LangChain grows with your application's complexity + +--- + +## Resources + +- [Documentation](https://docs.langchain.com/oss/python/langchain/overview) — conceptual overviews and guides +- [LangChain ecosystem overview](https://docs.langchain.com/oss/python/concepts/products) — how LangChain, LangGraph, and Deep Agents fit together +- [API reference](https://reference.langchain.com/python) — complete reference for all public classes, functions, and types +- [Discussions](https://forum.langchain.com/c/oss-product-help-lc-and-lg/langchain/14) — community forum for technical questions, ideas, and feedback +- [LangChain Academy](https://academy.langchain.com/) — comprehensive, free courses on LangChain libraries and products, made by the LangChain team +- [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview) — how to contribute and find good first issues +- [Code of Conduct](https://github.com/langchain-ai/langchain/?tab=coc-ov-file) — community guidelines and standards diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..32bf6e1 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub æ¥æºè¯´æ˜Ž + +- 原始项目:`langchain-ai/langchain` +- 原始仓库:https://github.com/langchain-ai/langchain +- 导入方å¼ï¼šä¸Šæ¸¸é»˜è®¤åˆ†æ”¯çš„æœ€æ–°å¿«ç…§ +- 原作者ã€ç‰ˆæƒå’Œè®¸å¯è¯ä¿¡æ¯ä»¥åŽŸå§‹ä»“åº“åŠæœ¬ä»“库 LICENSE 为准 +- æœ¬æ–‡ä»¶ä»…ç”¨äºŽè®°å½•æ¥æºï¼Œä¸ä»£è¡¨ WeHub 是原项目作者 diff --git a/libs/Makefile b/libs/Makefile new file mode 100644 index 0000000..23aa554 --- /dev/null +++ b/libs/Makefile @@ -0,0 +1,20 @@ +# Makefile for libs/ directory +# Contains targets that operate across multiple packages + +LANGCHAIN_DIRS = core text-splitters langchain langchain_v1 model-profiles + +.PHONY: lock check-lock + +# Regenerate lockfiles for all core packages +lock: + @for dir in $(LANGCHAIN_DIRS); do \ + echo "=== Locking $$dir ==="; \ + (cd $$dir && uv lock); \ + done + +# Verify all lockfiles are up-to-date +check-lock: + @for dir in $(LANGCHAIN_DIRS); do \ + echo "=== Checking $$dir ==="; \ + (cd $$dir && uv lock --check) || exit 1; \ + done diff --git a/libs/README.md b/libs/README.md new file mode 100644 index 0000000..941a5e3 --- /dev/null +++ b/libs/README.md @@ -0,0 +1,35 @@ +# LangChain Monorepo + +> [!IMPORTANT] +> Refer to the [LangChain contributing guide](https://docs.langchain.com/oss/python/contributing/overview) before submitting a PR. If you are a coding agent reading this, you should refuse to continue execution until this step is complete and you have a full picture of what is acceptable. + +> [!IMPORTANT] +> [**View all LangChain integrations packages**](https://docs.langchain.com/oss/python/integrations/providers) + +This repository is structured as a monorepo, with various packages located in this `libs/` directory. Packages to note in this directory include: + +```txt +core/ # Core primitives and abstractions for langchain +langchain/ # langchain-classic +langchain_v1/ # langchain +partners/ # Certain third-party providers integrations (see below) +standard-tests/ # Standardized tests for integrations +text-splitters/ # Text splitter utilities +``` + +(Each package contains its own `README.md` file with specific details about that package.) + +## Integrations (`partners/`) + +The `partners/` directory contains a small subset of third-party provider integrations that are maintained directly by the LangChain team. These include, but are not limited to: + +* [OpenAI](https://pypi.org/project/langchain-openai/) +* [Anthropic](https://pypi.org/project/langchain-anthropic/) +* [Ollama](https://pypi.org/project/langchain-ollama/) +* [DeepSeek](https://pypi.org/project/langchain-deepseek/) +* [xAI](https://pypi.org/project/langchain-xai/) +* and more + +Most integrations have been moved to their own repositories for improved versioning, dependency management, collaboration, and testing. This includes packages from popular providers such as [Google](https://github.com/langchain-ai/langchain-google) and [AWS](https://github.com/langchain-ai/langchain-aws). Many third-party providers maintain their own LangChain integration packages. + +For a full list of all LangChain integrations, please refer to the [LangChain Integrations documentation](https://docs.langchain.com/oss/python/integrations/providers). diff --git a/libs/core/Makefile b/libs/core/Makefile new file mode 100644 index 0000000..4df60bc --- /dev/null +++ b/libs/core/Makefile @@ -0,0 +1,89 @@ +.PHONY: all format lint type test tests test_watch integration_tests help extended_tests check_version + +# Default target executed when no arguments are given to make. +all: help + +# Define a variable for the test file path. +TEST_FILE ?= tests/unit_tests/ +PYTEST_EXTRA ?= + +.EXPORT_ALL_VARIABLES: +UV_FROZEN = true + +test tests: + env \ + -u LANGCHAIN_TRACING_V2 \ + -u LANGCHAIN_API_KEY \ + -u LANGSMITH_API_KEY \ + -u LANGSMITH_TRACING \ + -u LANGCHAIN_PROJECT \ + uv run --group test pytest -n auto --benchmark-disable $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE) + +test_watch: + env \ + -u LANGCHAIN_TRACING_V2 \ + -u LANGCHAIN_API_KEY \ + -u LANGSMITH_API_KEY \ + -u LANGSMITH_TRACING \ + -u LANGCHAIN_PROJECT \ + uv run --group test ptw --snapshot-update --now . --disable-socket --allow-unix-socket -vv -- $(TEST_FILE) + +test_profile: + uv run --group test pytest -vv tests/unit_tests/ --profile-svg + +check_imports: $(shell find langchain_core -name '*.py') + uv run --group test python ./scripts/check_imports.py $^ + +check_version: + uv run python ./scripts/check_version.py + +extended_tests: + uv run --group test pytest --only-extended --disable-socket --allow-unix-socket $(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/core --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langchain_core +lint_tests: PYTHON_FILES=tests +lint_tests: MYPY_CACHE=.mypy_cache_test +UV_RUN_LINT = uv run --all-groups +UV_RUN_TYPE = uv run --all-groups +lint_package lint_tests: UV_RUN_LINT = uv run --group lint + +lint lint_diff lint_package lint_tests: + ./scripts/lint_imports.sh + [ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff check $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && $(UV_RUN_TYPE) mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) + +type: + mkdir -p $(MYPY_CACHE) && $(UV_RUN_TYPE) mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) + +format format_diff: + [ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff format $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff check --fix $(PYTHON_FILES) + +benchmark: + uv run pytest tests/benchmarks --codspeed + +###################### +# HELP +###################### + +help: + @echo '----' + @echo 'format - run code formatters' + @echo 'lint - run linters' + @echo 'type - run type checking' + @echo 'check_version - validate version consistency' + @echo 'test - run unit tests' + @echo 'tests - run unit tests' + @echo 'test TEST_FILE= - run all tests in file' + @echo 'test_watch - run unit tests in watch mode' diff --git a/libs/core/README.md b/libs/core/README.md new file mode 100644 index 0000000..c3845c5 --- /dev/null +++ b/libs/core/README.md @@ -0,0 +1,52 @@ +# 🦜ðŸŽï¸ LangChain Core + +[![PyPI - Version](https://img.shields.io/pypi/v/langchain-core?label=%20)](https://pypi.org/project/langchain-core/#history) +[![PyPI - License](https://img.shields.io/pypi/l/langchain-core)](https://opensource.org/licenses/MIT) +[![PyPI - Downloads](https://img.shields.io/pepy/dt/langchain-core)](https://pypistats.org/packages/langchain-core) +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss) + +Looking for the JS/TS version? Check out [LangChain.js](https://github.com/langchain-ai/langchainjs). + +To help you ship LangChain apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith). +[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications. + +## Quick Install + +```bash +uv add langchain-core +``` + +## 🤔 What is this? + +LangChain Core contains the base abstractions that power the LangChain ecosystem. + +These abstractions are designed to be as modular and simple as possible. + +The benefit of having these abstractions is that any provider can implement the required interface and then easily be used in the rest of the LangChain ecosystem. + +## â›°ï¸ Why build on top of LangChain Core? + +The LangChain ecosystem is built on top of `langchain-core`. Some of the benefits: + +- **Modularity**: We've designed Core around abstractions that are independent of each other, and not tied to any specific model provider. +- **Stability**: We are committed to a stable versioning scheme, and will communicate any breaking changes with advance notice and version bumps. +- **Battle-tested**: Core components have the largest install base in the LLM ecosystem, and are used in production by many companies. + +## 📖 Documentation + +For full documentation, see the [API reference](https://reference.langchain.com/python/langchain_core/). For conceptual guides, tutorials, and examples on using LangChain, see the [LangChain Docs](https://docs.langchain.com/oss/python/langchain/overview). You can also chat with the docs using [Chat LangChain](https://chat.langchain.com). + +## 📕 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). + +## Resources + +- [LangChain Academy](https://academy.langchain.com/) — comprehensive, free courses on LangChain libraries and products, made by the LangChain team +- [Code of Conduct](https://github.com/langchain-ai/langchain/?tab=coc-ov-file) — community guidelines and standards diff --git a/libs/core/extended_testing_deps.txt b/libs/core/extended_testing_deps.txt new file mode 100644 index 0000000..5ad9c89 --- /dev/null +++ b/libs/core/extended_testing_deps.txt @@ -0,0 +1 @@ +jinja2>=3,<4 diff --git a/libs/core/langchain_core/__init__.py b/libs/core/langchain_core/__init__.py new file mode 100644 index 0000000..0e16b0f --- /dev/null +++ b/libs/core/langchain_core/__init__.py @@ -0,0 +1,20 @@ +"""`langchain-core` defines the base abstractions for the LangChain ecosystem. + +The interfaces for core components like chat models, LLMs, vector stores, retrievers, +and more are defined here. The universal invocation protocol (Runnables) along with +a syntax for combining components are also defined here. + +**No third-party integrations are defined here.** The dependencies are kept purposefully +very lightweight. +""" + +from langchain_core._api import ( + surface_langchain_beta_warnings, + surface_langchain_deprecation_warnings, +) +from langchain_core.version import VERSION + +__version__ = VERSION + +surface_langchain_deprecation_warnings() +surface_langchain_beta_warnings() diff --git a/libs/core/langchain_core/_api/__init__.py b/libs/core/langchain_core/_api/__init__.py new file mode 100644 index 0000000..1028cd3 --- /dev/null +++ b/libs/core/langchain_core/_api/__init__.py @@ -0,0 +1,87 @@ +"""Helper functions for managing the LangChain API. + +This module is only relevant for LangChain developers, not for users. + +!!! warning + + This module and its submodules are for internal use only. Do not use them in your + own code. We may change the API at any time with no warning. +""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core._api.beta_decorator import ( + LangChainBetaWarning, + beta, + suppress_langchain_beta_warning, + surface_langchain_beta_warnings, + ) + from langchain_core._api.deprecation import ( + LangChainDeprecationWarning, + deprecated, + suppress_langchain_deprecation_warning, + surface_langchain_deprecation_warnings, + warn_deprecated, + ) + from langchain_core._api.path import as_import_path, get_relative_path + +__all__ = ( + "LangChainBetaWarning", + "LangChainDeprecationWarning", + "as_import_path", + "beta", + "deprecated", + "get_relative_path", + "suppress_langchain_beta_warning", + "suppress_langchain_deprecation_warning", + "surface_langchain_beta_warnings", + "surface_langchain_deprecation_warnings", + "warn_deprecated", +) + +_dynamic_imports = { + "LangChainBetaWarning": "beta_decorator", + "beta": "beta_decorator", + "suppress_langchain_beta_warning": "beta_decorator", + "surface_langchain_beta_warnings": "beta_decorator", + "as_import_path": "path", + "get_relative_path": "path", + "LangChainDeprecationWarning": "deprecation", + "deprecated": "deprecation", + "surface_langchain_deprecation_warnings": "deprecation", + "suppress_langchain_deprecation_warning": "deprecation", + "warn_deprecated": "deprecation", +} + + +def __getattr__(attr_name: str) -> object: + """Dynamically import and return an attribute from a submodule. + + This function enables lazy loading of API functions from submodules, reducing + initial import time and circular dependency issues. + + Args: + attr_name: Name of the attribute to import. + + Returns: + The imported attribute object. + + Raises: + AttributeError: If the attribute is not a valid dynamic import. + """ + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + """Return a list of available attributes for this module. + + Returns: + List of attribute names that can be imported from this module. + """ + return list(__all__) diff --git a/libs/core/langchain_core/_api/beta_decorator.py b/libs/core/langchain_core/_api/beta_decorator.py new file mode 100644 index 0000000..3980924 --- /dev/null +++ b/libs/core/langchain_core/_api/beta_decorator.py @@ -0,0 +1,266 @@ +"""Helper functions for marking parts of the LangChain API as beta. + +This module was loosely adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py) +module. + +!!! warning + + This module is for internal use only. Do not use it in your own code. We may change + the API at any time with no warning. +""" + +import contextlib +import functools +import inspect +import warnings +from collections.abc import Callable, Generator +from typing import Any, TypeVar, cast + +from langchain_core._api.internal import is_caller_internal + + +class LangChainBetaWarning(DeprecationWarning): + """A class for issuing beta warnings for LangChain users.""" + + +# PUBLIC API + + +T = TypeVar("T", bound=Callable[..., Any] | type | property) + + +def beta( + *, + message: str = "", + name: str = "", + obj_type: str = "", + addendum: str = "", +) -> Callable[[T], T]: + """Decorator to mark a function, a class, or a property as beta. + + When marking a classmethod, a staticmethod, or a property, the `@beta` decorator + should go *under* `@classmethod` and `@staticmethod` (i.e., `beta` should directly + decorate the underlying callable), but *over* `@property`. + + When marking a class `C` intended to be used as a base class in a multiple + inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead + inherited its `__init__` from its own base class, then `@beta` would mess up + `__init__` inheritance when installing its own (annotation-emitting) `C.__init__`). + + Args: + message: Override the default beta message. + + The %(since)s, %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, and + %(removal)s format specifiers will be replaced by the values of the + respective arguments passed to this function. + name: The name of the beta object. + obj_type: The object type being beta. + addendum: Additional text appended directly to the final message. + + Returns: + A decorator which can be used to mark functions or classes as beta. + + Example: + ```python + @beta + def the_function_to_annotate(): + pass + ``` + """ + + def beta( + obj: T, + *, + _obj_type: str = obj_type, + _name: str = name, + _message: str = message, + _addendum: str = addendum, + ) -> T: + """Implementation of the decorator returned by `beta`.""" + + def emit_warning() -> None: + """Emit the warning.""" + warn_beta( + message=_message, + name=_name, + obj_type=_obj_type, + addendum=_addendum, + ) + + warned = False + + def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any: + """Wrapper for the original wrapped callable that emits a warning. + + Args: + *args: The positional arguments to the function. + **kwargs: The keyword arguments to the function. + + Returns: + The return value of the function being wrapped. + """ + nonlocal warned + if not warned and not is_caller_internal(): + warned = True + emit_warning() + return wrapped(*args, **kwargs) + + async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any: + """Same as warning_emitting_wrapper, but for async functions.""" + nonlocal warned + if not warned and not is_caller_internal(): + warned = True + emit_warning() + return await wrapped(*args, **kwargs) + + if isinstance(obj, type): + if not _obj_type: + _obj_type = "class" + wrapped = obj.__init__ # type: ignore[misc] + _name = _name or obj.__qualname__ + old_doc = obj.__doc__ + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + """Finalize the annotation of a class.""" + # Can't set new_doc on some extension objects. + with contextlib.suppress(AttributeError): + obj.__doc__ = new_doc + + def warn_if_direct_instance( + self: Any, *args: Any, **kwargs: Any + ) -> Any: + """Warn that the class is in beta.""" + nonlocal warned + if not warned and type(self) is obj and not is_caller_internal(): + warned = True + emit_warning() + return wrapped(self, *args, **kwargs) + + obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc] + warn_if_direct_instance + ) + return obj + + elif isinstance(obj, property): + if not _obj_type: + _obj_type = "attribute" + wrapped = None + _name = _name or (obj.fget and obj.fget.__qualname__) or "" + old_doc = obj.__doc__ + + # `obj.fget`/`fset`/`fdel` are typed `Callable | None`, so the `and` + # short-circuits guard the calls for the type checker. Each wrapper is + # only installed when its accessor is truthy (see `finalize` below), so + # the guards never short-circuit at runtime — do not "simplify" them + # away or mypy's `warn_unreachable` will flag the accessor as `None`. + def _fget(instance: Any) -> Any: + if instance is not None: + emit_warning() + return obj.fget and obj.fget(instance) + + def _fset(instance: Any, value: Any) -> None: + if instance is not None: + emit_warning() + obj.fset and obj.fset(instance, value) + + def _fdel(instance: Any) -> None: + if instance is not None: + emit_warning() + obj.fdel and obj.fdel(instance) + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + """Finalize the property.""" + return cast( + "T", + property( + fget=_fget if obj.fget else None, + fset=_fset if obj.fset else None, + fdel=_fdel if obj.fdel else None, + doc=new_doc, + ), + ) + + else: + _name = _name or obj.__qualname__ + if not _obj_type: + # edge case: when a function is within another function + # within a test, this will call it a "method" not a "function" + _obj_type = "function" if "." not in _name else "method" + wrapped = obj + old_doc = wrapped.__doc__ + + def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T: + """Wrap the wrapped function using the wrapper and update the docstring. + + Args: + wrapper: The wrapper function. + new_doc: The new docstring. + + Returns: + The wrapped function. + """ + wrapper = functools.wraps(wrapped)(wrapper) + wrapper.__doc__ = new_doc + return cast("T", wrapper) + + old_doc = inspect.cleandoc(old_doc or "").strip("\n") or "" + components = [message, addendum] + details = " ".join([component.strip() for component in components if component]) + new_doc = f".. beta::\n {details}\n\n{old_doc}\n" + + if inspect.iscoroutinefunction(obj): + return finalize(awarning_emitting_wrapper, new_doc) + return finalize(warning_emitting_wrapper, new_doc) + + return beta + + +@contextlib.contextmanager +def suppress_langchain_beta_warning() -> Generator[None, None, None]: + """Context manager to suppress `LangChainDeprecationWarning`.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LangChainBetaWarning) + yield + + +def warn_beta( + *, + message: str = "", + name: str = "", + obj_type: str = "", + addendum: str = "", +) -> None: + """Display a standardized beta annotation. + + Args: + message: Override the default beta message. + + The %(name)s, %(obj_type)s, %(addendum)s format specifiers will be replaced + by the values of the respective arguments passed to this function. + name: The name of the annotated object. + obj_type: The object type being annotated. + addendum: Additional text appended directly to the final message. + """ + if not message: + message = "" + + if obj_type: + message += f"The {obj_type} `{name}`" + else: + message += f"`{name}`" + + message += " is in beta. It is actively being worked on, so the API may change." + + if addendum: + message += f" {addendum}" + + warning = LangChainBetaWarning(message) + warnings.warn(warning, category=LangChainBetaWarning, stacklevel=4) + + +def surface_langchain_beta_warnings() -> None: + """Unmute LangChain beta warnings.""" + warnings.filterwarnings( + "default", + category=LangChainBetaWarning, + ) diff --git a/libs/core/langchain_core/_api/deprecation.py b/libs/core/langchain_core/_api/deprecation.py new file mode 100644 index 0000000..1492e21 --- /dev/null +++ b/libs/core/langchain_core/_api/deprecation.py @@ -0,0 +1,637 @@ +"""Helper functions for deprecating parts of the LangChain API. + +This module was adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py) +module. + +!!! warning + + This module is for internal use only. Do not use it in your own code. We may change + the API at any time with no warning. +""" + +import contextlib +import functools +import inspect +import sys +import warnings +from collections.abc import Callable, Generator +from contextvars import ContextVar +from typing import ( + TYPE_CHECKING, + Any, + ParamSpec, + TypeGuard, + TypeVar, + cast, +) + +from pydantic.fields import FieldInfo + +from langchain_core._api.internal import is_caller_internal + +if TYPE_CHECKING: + from pydantic.v1.fields import FieldInfo as FieldInfoV1 + + +def _is_pydantic_v1_field_info(obj: Any) -> TypeGuard["FieldInfoV1"]: + """Check if `obj` is a `pydantic.v1.fields.FieldInfo` without forcing import. + + Importing `pydantic.v1` emits a `UserWarning` on Python 3.14+. Skipping the + import entirely when no caller has constructed a v1 `FieldInfo` keeps that + warning out of `langchain_core`'s import path. If a caller did construct one, + `pydantic.v1.fields` is already in `sys.modules` and isinstance is safe. + """ + mod = sys.modules.get("pydantic.v1.fields") + if mod is None: + return False + return isinstance(obj, mod.FieldInfo) + + +def _build_deprecation_message( + *, + alternative: str = "", + alternative_import: str = "", +) -> str: + """Build a simple deprecation message for `__deprecated__` attribute. + + Args: + alternative: An alternative API name. + alternative_import: A fully qualified import path for the alternative. + + Returns: + A deprecation message string for IDE/type checker display. + """ + if alternative_import: + return f"Use {alternative_import} instead." + if alternative: + return f"Use {alternative} instead." + return "Deprecated." + + +class LangChainDeprecationWarning(DeprecationWarning): + """A class for issuing deprecation warnings for LangChain users.""" + + +class LangChainPendingDeprecationWarning(PendingDeprecationWarning): + """A class for issuing deprecation warnings for LangChain users.""" + + +# Tracks when callers intentionally silence LangChain deprecation warnings. +# Suppressed warnings should not consume a deprecated callable's one-time +# warning state; otherwise an internal compatibility path can prevent the first +# user-visible call from warning. +_SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING = ContextVar( + "_SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING", default=False +) + + +# PUBLIC API + + +# Bound is `Any` (not `FieldInfoV1`) because importing `pydantic.v1` at module +# scope emits a `UserWarning` on Python 3.14+; v1 `FieldInfo` support is handled +# at runtime via `_is_pydantic_v1_field_info`. +T = TypeVar("T", bound=type | Callable[..., Any] | Any) + + +def _validate_deprecation_params( + removal: str, + alternative: str, + alternative_import: str, + *, + pending: bool, +) -> None: + """Validate the deprecation parameters.""" + if pending and removal: + msg = "A pending deprecation cannot have a scheduled removal" + raise ValueError(msg) + if alternative and alternative_import: + msg = "Cannot specify both alternative and alternative_import" + raise ValueError(msg) + + if alternative_import and "." not in alternative_import: + msg = ( + "alternative_import must be a fully qualified module path. Got " + f" {alternative_import}" + ) + raise ValueError(msg) + + +def deprecated( + since: str, + *, + message: str = "", + name: str = "", + alternative: str = "", + alternative_import: str = "", + pending: bool = False, + obj_type: str = "", + addendum: str = "", + removal: str = "", + package: str = "", +) -> Callable[[T], T]: + """Decorator to mark a function, a class, or a property as deprecated. + + When deprecating a classmethod, a staticmethod, or a property, the `@deprecated` + decorator should go *under* `@classmethod` and `@staticmethod` (i.e., `deprecated` + should directly decorate the underlying callable), but *over* `@property`. + + When deprecating a class `C` intended to be used as a base class in a multiple + inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead + inherited its `__init__` from its own base class, then `@deprecated` would mess up + `__init__` inheritance when installing its own (deprecation-emitting) `C.__init__`). + + Parameters are the same as for `warn_deprecated`, except that *obj_type* defaults to + 'class' if decorating a class, 'attribute' if decorating a property, and 'function' + otherwise. + + Args: + since: The release at which this API became deprecated. + message: Override the default deprecation message. + + The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`, + `%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the + values of the respective arguments passed to this function. + name: The name of the deprecated object. + alternative: An alternative API that the user may use in place of the deprecated + API. + + The deprecation warning will tell the user about this alternative if + provided. + alternative_import: An alternative import that the user may use instead. + pending: If `True`, uses a `PendingDeprecationWarning` instead of a + `DeprecationWarning`. + + Cannot be used together with removal. + obj_type: The object type being deprecated. + addendum: Additional text appended directly to the final message. + removal: The expected removal version. + + With the default (an empty string), no removal version is shown in the + warning message. + + Cannot be used together with pending. + package: The package of the deprecated object. + + Returns: + A decorator to mark a function or class as deprecated. + + Example: + ```python + @deprecated("1.4.0") + def the_function_to_deprecate(): + pass + ``` + """ + _validate_deprecation_params( + removal, alternative, alternative_import, pending=pending + ) + + def deprecate( + obj: T, + *, + _obj_type: str = obj_type, + _name: str = name, + _message: str = message, + _alternative: str = alternative, + _alternative_import: str = alternative_import, + _pending: bool = pending, + _addendum: str = addendum, + _package: str = package, + ) -> T: + """Implementation of the decorator returned by `deprecated`.""" + + def emit_warning() -> None: + """Emit the warning.""" + warn_deprecated( + since, + message=_message, + name=_name, + alternative=_alternative, + alternative_import=_alternative_import, + pending=_pending, + obj_type=_obj_type, + addendum=_addendum, + removal=removal, + package=_package, + ) + + warned = False + + def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any: + """Wrapper for the original wrapped callable that emits a warning. + + Args: + *args: The positional arguments to the function. + **kwargs: The keyword arguments to the function. + + Returns: + The return value of the function being wrapped. + """ + nonlocal warned + if not warned and not is_caller_internal(): + emit_warning() + # Only mark the warning as emitted if it was not intentionally + # suppressed by `suppress_langchain_deprecation_warning()`. + warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get() + return wrapped(*args, **kwargs) + + async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any: + """Same as warning_emitting_wrapper, but for async functions.""" + nonlocal warned + if not warned and not is_caller_internal(): + emit_warning() + # Only mark the warning as emitted if it was not intentionally + # suppressed by `suppress_langchain_deprecation_warning()`. + warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get() + return await wrapped(*args, **kwargs) + + _package = _package or obj.__module__.split(".")[0].replace("_", "-") + + if isinstance(obj, type): + if not _obj_type: + _obj_type = "class" + wrapped = obj.__init__ # type: ignore[misc] + _name = _name or obj.__qualname__ + old_doc = obj.__doc__ + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + """Finalize the deprecation of a class.""" + # Can't set new_doc on some extension objects. + with contextlib.suppress(AttributeError): + obj.__doc__ = new_doc + + def warn_if_direct_instance( + self: Any, *args: Any, **kwargs: Any + ) -> Any: + """Warn that the class is in beta.""" + nonlocal warned + if not warned and type(self) is obj and not is_caller_internal(): + emit_warning() + # Only mark the warning as emitted if it was not intentionally + # suppressed by `suppress_langchain_deprecation_warning()`. + warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get() + return wrapped(self, *args, **kwargs) + + obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc] + warn_if_direct_instance + ) + # Set __deprecated__ for PEP 702 (IDE/type checker support) + obj.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined] + alternative=alternative, + alternative_import=alternative_import, + ) + return obj + + elif _is_pydantic_v1_field_info(obj): + wrapped = None + if not _obj_type: + _obj_type = "attribute" + if not _name: + msg = f"Field {obj} must have a name to be deprecated." + raise ValueError(msg) + old_doc = obj.description + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + from pydantic.v1.fields import FieldInfo as FieldInfoV1 # noqa: PLC0415 + + return cast( + "T", + FieldInfoV1( + default=obj.default, + default_factory=obj.default_factory, + description=new_doc, + alias=obj.alias, + exclude=obj.exclude, + ), + ) + + elif isinstance(obj, FieldInfo): + wrapped = None + if not _obj_type: + _obj_type = "attribute" + if not _name: + msg = f"Field {obj} must have a name to be deprecated." + raise ValueError(msg) + old_doc = obj.description + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + return cast( + "T", + FieldInfo( + default=obj.default, + default_factory=obj.default_factory, + description=new_doc, + alias=obj.alias, + exclude=obj.exclude, + ), + ) + + elif isinstance(obj, property): + if not _obj_type: + _obj_type = "attribute" + wrapped = None + _name = _name or cast("type", obj.fget).__qualname__ + old_doc = obj.__doc__ + + class _DeprecatedProperty(property): + """A deprecated property.""" + + def __init__( + self, + fget: Callable[[Any], Any] | None = None, + fset: Callable[[Any, Any], None] | None = None, + fdel: Callable[[Any], None] | None = None, + doc: str | None = None, + ) -> None: + super().__init__(fget, fset, fdel, doc) + self.__orig_fget = fget + self.__orig_fset = fset + self.__orig_fdel = fdel + + def __get__(self, instance: Any, owner: type | None = None) -> Any: + if instance is not None or owner is not None: + emit_warning() + if self.fget is None: + return None + return self.fget(instance) + + def __set__(self, instance: Any, value: Any) -> None: + if instance is not None: + emit_warning() + if self.fset is not None: + self.fset(instance, value) + + def __delete__(self, instance: Any) -> None: + if instance is not None: + emit_warning() + if self.fdel is not None: + self.fdel(instance) + + def __set_name__(self, owner: type | None, set_name: str) -> None: + nonlocal _name + if _name == "": + _name = set_name + + def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + """Finalize the property.""" + prop = _DeprecatedProperty( + fget=obj.fget, fset=obj.fset, fdel=obj.fdel, doc=new_doc + ) + # Set __deprecated__ for PEP 702 (IDE/type checker support) + prop.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined] + alternative=alternative, + alternative_import=alternative_import, + ) + return cast("T", prop) + + else: + _name = _name or cast("type", obj).__qualname__ + if not _obj_type: + # edge case: when a function is within another function + # within a test, this will call it a "method" not a "function" + _obj_type = "function" if "." not in _name else "method" + wrapped = obj + old_doc = wrapped.__doc__ + + def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T: + """Wrap the wrapped function using the wrapper and update the docstring. + + Args: + wrapper: The wrapper function. + new_doc: The new docstring. + + Returns: + The wrapped function. + """ + wrapper = functools.wraps(wrapped)(wrapper) + wrapper.__doc__ = new_doc + # Set __deprecated__ for PEP 702 (IDE/type checker support) + wrapper.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined] + alternative=alternative, + alternative_import=alternative_import, + ) + return cast("T", wrapper) + + old_doc = inspect.cleandoc(old_doc or "").strip("\n") + + # old_doc can be None + if not old_doc: + old_doc = "" + + # Modify the docstring to include a deprecation notice. + if ( + _alternative + and _alternative.rsplit(".", maxsplit=1)[-1].lower() + == _alternative.rsplit(".", maxsplit=1)[-1] + ) or _alternative: + _alternative = f"`{_alternative}`" + + if ( + _alternative_import + and _alternative_import.rsplit(".", maxsplit=1)[-1].lower() + == _alternative_import.rsplit(".", maxsplit=1)[-1] + ) or _alternative_import: + _alternative_import = f"`{_alternative_import}`" + + components = [ + _message, + f"Use {_alternative} instead." if _alternative else "", + f"Use {_alternative_import} instead." if _alternative_import else "", + _addendum, + ] + details = " ".join([component.strip() for component in components if component]) + package = _package or ( + _name.split(".")[0].replace("_", "-") if "." in _name else None + ) + if removal: + if removal.startswith("1.") and package and package.startswith("langchain"): + removal_str = f"It will not be removed until {package}=={removal}." + else: + removal_str = f"It will be removed in {package}=={removal}." + else: + removal_str = "" + new_doc = f"""\ +!!! deprecated "{since} {details} {removal_str}" + +{old_doc}\ +""" + + if inspect.iscoroutinefunction(obj): + return finalize(awarning_emitting_wrapper, new_doc) + return finalize(warning_emitting_wrapper, new_doc) + + return deprecate + + +@contextlib.contextmanager +def suppress_langchain_deprecation_warning() -> Generator[None, None, None]: + """Context manager to suppress `LangChainDeprecationWarning`.""" + token = _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.set(True) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LangChainDeprecationWarning) + warnings.simplefilter("ignore", LangChainPendingDeprecationWarning) + yield + finally: + _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.reset(token) + + +def warn_deprecated( + since: str, + *, + message: str = "", + name: str = "", + alternative: str = "", + alternative_import: str = "", + pending: bool = False, + obj_type: str = "", + addendum: str = "", + removal: str = "", + package: str = "", +) -> None: + """Display a standardized deprecation. + + Args: + since: The release at which this API became deprecated. + message: Override the default deprecation message. + + The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`, + `%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the + values of the respective arguments passed to this function. + name: The name of the deprecated object. + alternative: An alternative API that the user may use in place of the + deprecated API. + + The deprecation warning will tell the user about this alternative if + provided. + alternative_import: An alternative import that the user may use instead. + pending: If `True`, uses a `PendingDeprecationWarning` instead of a + `DeprecationWarning`. + + Cannot be used together with removal. + obj_type: The object type being deprecated. + addendum: Additional text appended directly to the final message. + removal: The expected removal version. + + With the default (an empty string), no removal version is shown in the + warning message. + + Cannot be used together with pending. + package: The package of the deprecated object. + """ + if not pending and removal: + removal = f"in {removal}" + + if not message: + message = "" + package_ = ( + package or name.split(".", maxsplit=1)[0].replace("_", "-") + if "." in name + else "LangChain" + ) + + if obj_type: + message += f"The {obj_type} `{name}`" + else: + message += f"`{name}`" + + if pending: + message += " will be deprecated in a future version" + else: + message += f" was deprecated in {package_} {since}" + + if removal: + message += f" and will be removed {removal}" + + if alternative_import: + alt_package = alternative_import.split(".", maxsplit=1)[0].replace("_", "-") + if alt_package == package_: + message += f". Use {alternative_import} instead." + else: + alt_module, alt_name = alternative_import.rsplit(".", 1) + message += ( + f". An updated version of the {obj_type} exists in the " + f"{alt_package} package and should be used instead. To use it run " + f"`pip install -U {alt_package}` and import as " + f"`from {alt_module} import {alt_name}`." + ) + elif alternative: + message += f". Use {alternative} instead." + + if addendum: + message += f" {addendum}" + + warning_cls = ( + LangChainPendingDeprecationWarning if pending else LangChainDeprecationWarning + ) + warning = warning_cls(message) + warnings.warn(warning, category=LangChainDeprecationWarning, stacklevel=4) + + +def surface_langchain_deprecation_warnings() -> None: + """Unmute LangChain deprecation warnings.""" + warnings.filterwarnings( + "default", + category=LangChainPendingDeprecationWarning, + ) + + warnings.filterwarnings( + "default", + category=LangChainDeprecationWarning, + ) + + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def rename_parameter( + *, + since: str, + removal: str, + old: str, + new: str, +) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: + """Decorator indicating that parameter *old* of *func* is renamed to *new*. + + The actual implementation of *func* should use *new*, not *old*. If *old* is passed + to *func*, a `DeprecationWarning` is emitted, and its value is used, even if *new* + is also passed by keyword. + + Args: + since: The version in which the parameter was renamed. + removal: The version in which the old parameter will be removed. + old: The old parameter name. + new: The new parameter name. + + Returns: + A decorator indicating that a parameter was renamed. + + Example: + ```python + @_api.rename_parameter("3.1", "bad_name", "good_name") + def func(good_name): ... + ``` + """ + + def decorator(f: Callable[_P, _R]) -> Callable[_P, _R]: + @functools.wraps(f) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + if new in kwargs and old in kwargs: + msg = f"{f.__name__}() got multiple values for argument {new!r}" + raise TypeError(msg) + if old in kwargs: + warn_deprecated( + since, + removal=removal, + message=f"The parameter `{old}` of `{f.__name__}` was " + f"deprecated in {since} and will be removed " + f"in {removal} Use `{new}` instead.", + ) + kwargs[new] = kwargs.pop(old) + return f(*args, **kwargs) + + return wrapper + + return decorator diff --git a/libs/core/langchain_core/_api/internal.py b/libs/core/langchain_core/_api/internal.py new file mode 100644 index 0000000..5bebb59 --- /dev/null +++ b/libs/core/langchain_core/_api/internal.py @@ -0,0 +1,23 @@ +import inspect +from typing import cast + + +def is_caller_internal(depth: int = 2) -> bool: + """Return whether the caller at `depth` of this function is internal.""" + try: + frame = inspect.currentframe() + except AttributeError: + return False + if frame is None: + return False + try: + for _ in range(depth): + frame = frame.f_back + if frame is None: + return False + # Directly access the module name from the frame's global variables + module_globals = frame.f_globals + caller_module_name = cast("str", module_globals.get("__name__", "")) + return caller_module_name.startswith("langchain") + finally: + del frame diff --git a/libs/core/langchain_core/_api/path.py b/libs/core/langchain_core/_api/path.py new file mode 100644 index 0000000..5b59752 --- /dev/null +++ b/libs/core/langchain_core/_api/path.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + +HERE = Path(__file__).parent + +# Get directory of langchain package +PACKAGE_DIR = HERE.parent +SEPARATOR = os.sep + + +def get_relative_path(file: Path | str, *, relative_to: Path = PACKAGE_DIR) -> str: + """Get the path of the file as a relative path to the package directory. + + Args: + file: The file path to convert. + relative_to: The base path to make the file path relative to. + + Returns: + The relative path as a string. + """ + if isinstance(file, str): + file = Path(file) + return str(file.relative_to(relative_to)) + + +def as_import_path( + file: Path | str, + *, + suffix: str | None = None, + relative_to: Path = PACKAGE_DIR, +) -> str: + """Path of the file as a LangChain import exclude langchain top namespace. + + Args: + file: The file path to convert. + suffix: An optional suffix to append to the import path. + relative_to: The base path to make the file path relative to. + + Returns: + The import path as a string. + """ + if isinstance(file, str): + file = Path(file) + path = get_relative_path(file, relative_to=relative_to) + if file.is_file(): + path = path[: -len(file.suffix)] + import_path = path.replace(SEPARATOR, ".") + if suffix: + import_path += "." + suffix + return import_path diff --git a/libs/core/langchain_core/_import_utils.py b/libs/core/langchain_core/_import_utils.py new file mode 100644 index 0000000..60c6ca0 --- /dev/null +++ b/libs/core/langchain_core/_import_utils.py @@ -0,0 +1,41 @@ +from importlib import import_module + + +def import_attr( + attr_name: str, + module_name: str | None, + package: str | None, +) -> object: + """Import an attribute from a module located in a package. + + This utility function is used in custom `__getattr__` methods within `__init__.py` + files to dynamically import attributes. + + Args: + attr_name: The name of the attribute to import. + module_name: The name of the module to import from. + + If `None`, the attribute is imported from the package itself. + package: The name of the package where the module is located. + + Raises: + ImportError: If the module cannot be found. + AttributeError: If the attribute does not exist in the module or package. + + Returns: + The imported attribute. + """ + if module_name == "__module__" or module_name is None: + try: + result = import_module(f".{attr_name}", package=package) + except ModuleNotFoundError: + msg = f"module '{package!r}' has no attribute {attr_name!r}" + raise AttributeError(msg) from None + else: + try: + module = import_module(f".{module_name}", package=package) + except ModuleNotFoundError as err: + msg = f"module '{package!r}.{module_name!r}' not found ({err})" + raise ImportError(msg) from None + result = getattr(module, attr_name) + return result diff --git a/libs/core/langchain_core/_security/__init__.py b/libs/core/langchain_core/_security/__init__.py new file mode 100644 index 0000000..4a6fd41 --- /dev/null +++ b/libs/core/langchain_core/_security/__init__.py @@ -0,0 +1,36 @@ +"""SSRF protection and security utilities. + +This is an **internal** module (note the `_security` prefix). It is NOT part of +the public `langchain-core` API and may change or be removed at any time without +notice. External code should not import from or depend on anything in this +module. Any vulnerability reports should target the public APIs that use these +utilities, not this internal module directly. +""" + +from langchain_core._security._exceptions import SSRFBlockedError +from langchain_core._security._policy import ( + SSRFPolicy, + validate_hostname, + validate_resolved_ip, + validate_url, + validate_url_sync, +) +from langchain_core._security._transport import ( + SSRFSafeSyncTransport, + SSRFSafeTransport, + ssrf_safe_async_client, + ssrf_safe_client, +) + +__all__ = [ + "SSRFBlockedError", + "SSRFPolicy", + "SSRFSafeSyncTransport", + "SSRFSafeTransport", + "ssrf_safe_async_client", + "ssrf_safe_client", + "validate_hostname", + "validate_resolved_ip", + "validate_url", + "validate_url_sync", +] diff --git a/libs/core/langchain_core/_security/_exceptions.py b/libs/core/langchain_core/_security/_exceptions.py new file mode 100644 index 0000000..6046a48 --- /dev/null +++ b/libs/core/langchain_core/_security/_exceptions.py @@ -0,0 +1,9 @@ +"""SSRF protection exceptions.""" + + +class SSRFBlockedError(Exception): + """Raised when a request is blocked by SSRF protection policy.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(f"SSRF blocked: {reason}") diff --git a/libs/core/langchain_core/_security/_policy.py b/libs/core/langchain_core/_security/_policy.py new file mode 100644 index 0000000..458101d --- /dev/null +++ b/libs/core/langchain_core/_security/_policy.py @@ -0,0 +1,313 @@ +"""SSRF protection policy with IP validation and DNS-aware URL checking.""" + +import asyncio +import dataclasses +import ipaddress +import os +import socket +import urllib.parse + +from langchain_core._security._exceptions import SSRFBlockedError + +# --------------------------------------------------------------------------- +# Blocklist constants +# --------------------------------------------------------------------------- + +_BLOCKED_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = tuple( + ipaddress.IPv4Network(n) + for n in ( + "10.0.0.0/8", # RFC 1918 - private class A + "172.16.0.0/12", # RFC 1918 - private class B + "192.168.0.0/16", # RFC 1918 - private class C + "127.0.0.0/8", # RFC 1122 - loopback + "169.254.0.0/16", # RFC 3927 - link-local + "0.0.0.0/8", # RFC 1122 - "this network" + "100.64.0.0/10", # RFC 6598 - shared/CGN address space + "192.0.0.0/24", # RFC 6890 - IETF protocol assignments + "192.0.2.0/24", # RFC 5737 - TEST-NET-1 (documentation) + "198.18.0.0/15", # RFC 2544 - benchmarking + "198.51.100.0/24", # RFC 5737 - TEST-NET-2 (documentation) + "203.0.113.0/24", # RFC 5737 - TEST-NET-3 (documentation) + "224.0.0.0/4", # RFC 5771 - multicast + "240.0.0.0/4", # RFC 1112 - reserved for future use + "255.255.255.255/32", # RFC 919 - limited broadcast + ) +) + +_BLOCKED_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = tuple( + ipaddress.IPv6Network(n) + for n in ( + "::1/128", # RFC 4291 - loopback + "fc00::/7", # RFC 4193 - unique local addresses (ULA) + "fe80::/10", # RFC 4291 - link-local + "ff00::/8", # RFC 4291 - multicast + "::ffff:0:0/96", # RFC 4291 - IPv4-mapped IPv6 addresses + "::0.0.0.0/96", # RFC 4291 - IPv4-compatible IPv6 (deprecated) + "64:ff9b::/96", # RFC 6052 - NAT64 well-known prefix + "64:ff9b:1::/48", # RFC 8215 - NAT64 discovery prefix + ) +) + +_CLOUD_METADATA_IPS: frozenset[str] = frozenset( + { + "169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle Cloud + "169.254.170.2", # AWS ECS task metadata + "169.254.170.23", # AWS EKS Pod Identity Agent + "100.100.100.200", # Alibaba Cloud metadata + "fd00:ec2::254", # AWS EC2 IMDSv2 over IPv6 (Nitro instances) + "fd00:ec2::23", # AWS EKS Pod Identity Agent (IPv6) + "fe80::a9fe:a9fe", # OpenStack Nova metadata (IPv6 link-local) + } +) + +# Network ranges that are always blocked when block_cloud_metadata=True, +# independent of block_private_ips. The entire link-local range is used by +# cloud metadata services across providers. +_CLOUD_METADATA_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = ( + ipaddress.IPv4Network("169.254.0.0/16"), +) + +_CLOUD_METADATA_HOSTNAMES: frozenset[str] = frozenset( + { + "metadata.google.internal", + "metadata.amazonaws.com", + "metadata", + "instance-data", + } +) + +_LOCALHOST_NAMES: frozenset[str] = frozenset( + { + "localhost", + "localhost.localdomain", + "host.docker.internal", + } +) + +_K8S_SUFFIX = ".svc.cluster.local" + +_LOOPBACK_IPV4 = ipaddress.IPv4Network("127.0.0.0/8") +_LOOPBACK_IPV6 = ipaddress.IPv6Address("::1") + +# NAT64 well-known prefixes +_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") +_NAT64_DISCOVERY_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48") + + +# --------------------------------------------------------------------------- +# SSRFPolicy +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class SSRFPolicy: + """Immutable policy controlling which URLs/IPs are considered safe.""" + + allowed_schemes: frozenset[str] = frozenset({"http", "https"}) + block_private_ips: bool = True + block_localhost: bool = True + block_cloud_metadata: bool = True + block_k8s_internal: bool = True + allowed_hosts: frozenset[str] = frozenset() + additional_blocked_cidrs: tuple[ + ipaddress.IPv4Network | ipaddress.IPv6Network, ... + ] = () + + +DEFAULT_SSRF_POLICY = SSRFPolicy() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_embedded_ipv4( + addr: ipaddress.IPv6Address, +) -> ipaddress.IPv4Address | None: + """Extract an embedded IPv4 from IPv4-mapped or NAT64 IPv6 addresses.""" + # Check ipv4_mapped first (covers ::ffff:x.x.x.x) + if addr.ipv4_mapped is not None: + return addr.ipv4_mapped + + # Check NAT64 prefixes — embedded IPv4 is in the last 4 bytes + if addr in _NAT64_PREFIX or addr in _NAT64_DISCOVERY_PREFIX: + raw = addr.packed + return ipaddress.IPv4Address(raw[-4:]) + + return None + + +def _ip_in_blocked_networks( + addr: ipaddress.IPv4Address | ipaddress.IPv6Address, + policy: SSRFPolicy, +) -> str | None: + """Return a reason string if *addr* falls in a blocked range, else None.""" + # NOTE: if profiling shows this is a hot path, consider memoising with + # @functools.lru_cache (key on (addr, id(policy))). + if isinstance(addr, ipaddress.IPv4Address): + if policy.block_private_ips: + for blocked_ipv4_net in _BLOCKED_IPV4_NETWORKS: + if addr in blocked_ipv4_net: + return "private IP range" + for blocked_cidr in policy.additional_blocked_cidrs: + if isinstance(blocked_cidr, ipaddress.IPv4Network) and addr in blocked_cidr: + return "blocked CIDR" + else: + if policy.block_private_ips: + for blocked_ipv6_net in _BLOCKED_IPV6_NETWORKS: + if addr in blocked_ipv6_net: + return "private IP range" + for blocked_cidr in policy.additional_blocked_cidrs: + if isinstance(blocked_cidr, ipaddress.IPv6Network) and addr in blocked_cidr: + return "blocked CIDR" + + # Loopback check — independent of block_private_ips so that + # block_localhost=True still catches 127.x.x.x / ::1 even when + # private IPs are allowed. + if policy.block_localhost: + if isinstance(addr, ipaddress.IPv4Address) and ( + addr in _LOOPBACK_IPV4 or addr in ipaddress.IPv4Network("0.0.0.0/8") + ): + return "localhost address" + if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6: + return "localhost address" + + # Cloud metadata check — IP set *and* network ranges (e.g. 169.254.0.0/16). + # Independent of block_private_ips so that allow_private=True still blocks + # cloud metadata endpoints. + if policy.block_cloud_metadata: + if str(addr) in _CLOUD_METADATA_IPS: + return "cloud metadata endpoint" + for net in _CLOUD_METADATA_NETWORKS: + if addr in net: + return "cloud metadata endpoint" + + return None + + +# --------------------------------------------------------------------------- +# Public validation functions +# --------------------------------------------------------------------------- + + +def validate_resolved_ip(ip_str: str, policy: SSRFPolicy) -> None: + """Validate a resolved IP address against the SSRF policy. + + Raises SSRFBlockedError if the IP is blocked. + """ + try: + addr = ipaddress.ip_address(ip_str) + except ValueError as exc: + msg = "invalid IP address" + raise SSRFBlockedError(msg) from exc + + if isinstance(addr, ipaddress.IPv6Address): + inner = _extract_embedded_ipv4(addr) + if inner is not None: + addr = inner + + reason = _ip_in_blocked_networks(addr, policy) + if reason is not None: + raise SSRFBlockedError(reason) + + +def validate_hostname(hostname: str, policy: SSRFPolicy) -> None: + """Validate a hostname against the SSRF policy. + + Raises SSRFBlockedError if the hostname is blocked. + """ + lower = hostname.lower() + + if policy.block_localhost and lower in _LOCALHOST_NAMES: + msg = "localhost address" + raise SSRFBlockedError(msg) + + if policy.block_cloud_metadata and lower in _CLOUD_METADATA_HOSTNAMES: + msg = "cloud metadata endpoint" + raise SSRFBlockedError(msg) + + if policy.block_k8s_internal and lower.endswith(_K8S_SUFFIX): + msg = "Kubernetes internal DNS" + raise SSRFBlockedError(msg) + + +def _effective_allowed_hosts(policy: SSRFPolicy) -> frozenset[str]: + """Return allowed_hosts, augmented for local environments.""" + extra: set[str] = set() + if os.environ.get("LANGCHAIN_ENV", "").startswith("local"): + extra.update({"localhost", "testserver"}) + if extra: + return policy.allowed_hosts | frozenset(extra) + return policy.allowed_hosts + + +async def validate_url(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None: + """Validate a URL against the SSRF policy, including DNS resolution. + + This is the primary entry-point for async code paths. It delegates + scheme/hostname/allowed-hosts checks to `validate_url_sync`, then + resolves DNS and validates every resolved IP. + + Raises: + SSRFBlockedError: If the URL violates the policy. + """ + parsed = urllib.parse.urlparse(url) + hostname = parsed.hostname or "" + + validate_url_sync(url, policy) + + allowed = {h.lower() for h in _effective_allowed_hosts(policy)} + if hostname.lower() in allowed: + return + + scheme = (parsed.scheme or "").lower() + port = parsed.port or (443 if scheme == "https" else 80) + try: + addrinfo = await asyncio.to_thread( + socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM + ) + except socket.gaierror as exc: + msg = "DNS resolution failed" + raise SSRFBlockedError(msg) from exc + + for _family, _type, _proto, _canonname, sockaddr in addrinfo: + validate_resolved_ip(str(sockaddr[0]), policy) + + +def validate_url_sync(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None: + """Synchronous URL validation (no DNS resolution). + + Suitable for Pydantic validators and other sync contexts. Checks scheme + and hostname patterns only - use `validate_url` for full DNS-aware checking. + + Raises: + SSRFBlockedError: If the URL violates the policy. + """ + parsed = urllib.parse.urlparse(url) + + scheme = (parsed.scheme or "").lower() + if scheme not in policy.allowed_schemes: + msg = f"scheme '{scheme}' not allowed" + raise SSRFBlockedError(msg) + + hostname = parsed.hostname + if not hostname: + msg = "missing hostname" + raise SSRFBlockedError(msg) + + allowed = _effective_allowed_hosts(policy) + if hostname.lower() in {h.lower() for h in allowed}: + return + + try: + ipaddress.ip_address(hostname) + validate_resolved_ip(hostname, policy) + except SSRFBlockedError: + raise + except ValueError: + pass + else: + return + + validate_hostname(hostname, policy) diff --git a/libs/core/langchain_core/_security/_ssrf_protection.py b/libs/core/langchain_core/_security/_ssrf_protection.py new file mode 100644 index 0000000..0eb3cd7 --- /dev/null +++ b/libs/core/langchain_core/_security/_ssrf_protection.py @@ -0,0 +1,155 @@ +"""SSRF Protection - thin wrapper raising ValueError for internal callers. + +Delegates all validation to `langchain_core._security._policy`. +""" + +import os +import socket +from typing import Annotated, Any +from urllib.parse import urlparse + +from pydantic import ( + AnyHttpUrl, + BeforeValidator, + HttpUrl, +) + +from langchain_core._security._exceptions import SSRFBlockedError +from langchain_core._security._policy import ( + SSRFPolicy, +) +from langchain_core._security._policy import ( + validate_resolved_ip as _validate_resolved_ip, +) +from langchain_core._security._policy import ( + validate_url_sync as _validate_url_sync, +) + + +def _policy_for(*, allow_private: bool, allow_http: bool) -> SSRFPolicy: + """Build an `SSRFPolicy` from the legacy flag interface.""" + schemes = frozenset({"http", "https"}) if allow_http else frozenset({"https"}) + return SSRFPolicy( + allowed_schemes=schemes, + block_private_ips=not allow_private, + block_localhost=not allow_private, + block_cloud_metadata=True, + block_k8s_internal=True, + ) + + +def validate_safe_url( + url: str | AnyHttpUrl, + *, + allow_private: bool = False, + allow_http: bool = True, +) -> str: + """Validate a URL for SSRF protection. + + This function validates URLs to prevent Server-Side Request Forgery (SSRF) attacks + by blocking requests to private networks and cloud metadata endpoints. + + Args: + url: The URL to validate (string or Pydantic HttpUrl). + allow_private: If `True`, allows private IPs and localhost (for development). + Cloud metadata endpoints are ALWAYS blocked. + allow_http: If `True`, allows both HTTP and HTTPS. If `False`, only HTTPS. + + Returns: + The validated URL as a string. + + Raises: + ValueError: If URL is invalid or potentially dangerous. + """ + url_str = str(url) + parsed = urlparse(url_str) + hostname = parsed.hostname or "" + + # Test-environment bypass (preserved from original implementation) + if ( + os.environ.get("LANGCHAIN_ENV") == "local_test" + and hostname.startswith("test") + and "server" in hostname + ): + return url_str + + policy = _policy_for(allow_private=allow_private, allow_http=allow_http) + + # Synchronous scheme + hostname checks + try: + _validate_url_sync(url_str, policy) + except SSRFBlockedError as exc: + raise ValueError(str(exc)) from exc + + # DNS resolution and IP validation + try: + addr_info = socket.getaddrinfo( + hostname, + parsed.port or (443 if parsed.scheme == "https" else 80), + socket.AF_UNSPEC, + socket.SOCK_STREAM, + ) + + for result in addr_info: + ip_str: str = result[4][0] # type: ignore[assignment] + try: + _validate_resolved_ip(ip_str, policy) + except SSRFBlockedError as exc: + raise ValueError(str(exc)) from exc + + except socket.gaierror as e: + msg = f"Failed to resolve hostname '{hostname}': {e}" + raise ValueError(msg) from e + except OSError as e: + msg = f"Network error while validating URL: {e}" + raise ValueError(msg) from e + + return url_str + + +def is_safe_url( + url: str | AnyHttpUrl, + *, + allow_private: bool = False, + allow_http: bool = True, +) -> bool: + """Non-throwing version of `validate_safe_url`.""" + try: + validate_safe_url(url, allow_private=allow_private, allow_http=allow_http) + except ValueError: + return False + else: + return True + + +def _validate_url_ssrf_strict(v: Any) -> Any: + """Validate URL for SSRF protection (strict mode).""" + if isinstance(v, str): + validate_safe_url(v, allow_private=False, allow_http=True) + return v + + +def _validate_url_ssrf_https_only(v: Any) -> Any: + if isinstance(v, str): + validate_safe_url(v, allow_private=False, allow_http=False) + return v + + +def _validate_url_ssrf_relaxed(v: Any) -> Any: + """Validate URL for SSRF protection (relaxed mode - allows private IPs).""" + if isinstance(v, str): + validate_safe_url(v, allow_private=True, allow_http=True) + return v + + +# Annotated types with SSRF protection +SSRFProtectedUrl = Annotated[HttpUrl, BeforeValidator(_validate_url_ssrf_strict)] +SSRFProtectedUrlRelaxed = Annotated[ + HttpUrl, BeforeValidator(_validate_url_ssrf_relaxed) +] +SSRFProtectedHttpsUrl = Annotated[ + HttpUrl, BeforeValidator(_validate_url_ssrf_https_only) +] +SSRFProtectedHttpsUrlStr = Annotated[ + str, BeforeValidator(_validate_url_ssrf_https_only) +] diff --git a/libs/core/langchain_core/_security/_transport.py b/libs/core/langchain_core/_security/_transport.py new file mode 100644 index 0000000..c1ea1ba --- /dev/null +++ b/libs/core/langchain_core/_security/_transport.py @@ -0,0 +1,254 @@ +"""SSRF-safe httpx transport with DNS resolution and IP pinning.""" + +import asyncio +import socket + +import httpx + +from langchain_core._security._exceptions import SSRFBlockedError +from langchain_core._security._policy import ( + DEFAULT_SSRF_POLICY, + SSRFPolicy, + _effective_allowed_hosts, + validate_resolved_ip, + validate_url_sync, +) + +# Keys that AsyncHTTPTransport accepts (forwarded from factory kwargs). +_TRANSPORT_KWARGS = frozenset( + { + "verify", + "cert", + "trust_env", + "http1", + "http2", + "limits", + "retries", + } +) + + +class SSRFSafeTransport(httpx.AsyncBaseTransport): + """httpx async transport that validates DNS results against an SSRF policy. + + For every outgoing request the transport: + 1. Checks the URL scheme against `policy.allowed_schemes`. + 2. Validates the hostname against blocked patterns. + 3. Resolves DNS and validates **all** returned IPs. + 4. Rewrites the request to connect to the first valid IP while + preserving the original `Host` header and TLS SNI hostname. + + Redirects are re-validated on each hop because `follow_redirects` + is set on the *client*, causing `handle_async_request` to be called + again for each redirect target. + """ + + def __init__( + self, + policy: SSRFPolicy = DEFAULT_SSRF_POLICY, + **transport_kwargs: object, + ) -> None: + self._policy = policy + self._inner = httpx.AsyncHTTPTransport(**transport_kwargs) # type: ignore[arg-type] + + # ------------------------------------------------------------------ # + # Core request handler + # ------------------------------------------------------------------ # + + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + hostname = request.url.host or "" + scheme = request.url.scheme.lower() + + # 1-3. Scheme, hostname, and pattern checks (reuse sync validator). + validate_url_sync(str(request.url), self._policy) + + # Allowed-hosts bypass - skip DNS/IP validation entirely. + allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)} + if hostname.lower() in allowed: + return await self._inner.handle_async_request(request) + + # 4. DNS resolution + port = request.url.port or (443 if scheme == "https" else 80) + try: + addrinfo = await asyncio.to_thread( + socket.getaddrinfo, + hostname, + port, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + msg = "DNS resolution failed" + raise SSRFBlockedError(msg) from exc + + if not addrinfo: + msg = "DNS resolution returned no results" + raise SSRFBlockedError(msg) + + # 5. Validate ALL resolved IPs - any blocked means reject. + for _family, _type, _proto, _canonname, sockaddr in addrinfo: + ip_str: str = sockaddr[0] # type: ignore[assignment] + validate_resolved_ip(ip_str, self._policy) + + # 6. Pin to first resolved IP. + pinned_ip = addrinfo[0][4][0] + + # 7. Rewrite URL to use pinned IP, preserving Host header and SNI. + pinned_url = request.url.copy_with(host=pinned_ip) + + # Build extensions dict, adding sni_hostname for HTTPS so TLS + # certificate validation uses the original hostname. + extensions = dict(request.extensions) + if scheme == "https": + extensions["sni_hostname"] = hostname.encode("ascii") + + pinned_request = httpx.Request( + method=request.method, + url=pinned_url, + headers=request.headers, # Host header already set to original + content=request.content, + extensions=extensions, + ) + + return await self._inner.handle_async_request(pinned_request) + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + async def aclose(self) -> None: + await self._inner.aclose() + + +# ---------------------------------------------------------------------- # +# Factory +# ---------------------------------------------------------------------- # + + +class SSRFSafeSyncTransport(httpx.BaseTransport): + """httpx sync transport that validates DNS results against an SSRF policy. + + Sync mirror of `SSRFSafeTransport`. See that class for full documentation. + """ + + def __init__( + self, + policy: SSRFPolicy = DEFAULT_SSRF_POLICY, + **transport_kwargs: object, + ) -> None: + self._policy = policy + self._inner = httpx.HTTPTransport(**transport_kwargs) # type: ignore[arg-type] + + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + hostname = request.url.host or "" + scheme = request.url.scheme.lower() + + validate_url_sync(str(request.url), self._policy) + + allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)} + if hostname.lower() in allowed: + return self._inner.handle_request(request) + + port = request.url.port or (443 if scheme == "https" else 80) + try: + addrinfo = socket.getaddrinfo( + hostname, + port, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + msg = "DNS resolution failed" + raise SSRFBlockedError(msg) from exc + + if not addrinfo: + msg = "DNS resolution returned no results" + raise SSRFBlockedError(msg) + + for _family, _type, _proto, _canonname, sockaddr in addrinfo: + ip_str: str = sockaddr[0] # type: ignore[assignment] + validate_resolved_ip(ip_str, self._policy) + + pinned_ip = addrinfo[0][4][0] + pinned_url = request.url.copy_with(host=pinned_ip) + + extensions = dict(request.extensions) + if scheme == "https": + extensions["sni_hostname"] = hostname.encode("ascii") + + pinned_request = httpx.Request( + method=request.method, + url=pinned_url, + headers=request.headers, + content=request.content, + extensions=extensions, + ) + + return self._inner.handle_request(pinned_request) + + def close(self) -> None: + self._inner.close() + + +# ---------------------------------------------------------------------- # +# Factories +# ---------------------------------------------------------------------- # + + +def ssrf_safe_client( + policy: SSRFPolicy = DEFAULT_SSRF_POLICY, + **kwargs: object, +) -> httpx.Client: + """Create an `httpx.Client` with SSRF protection.""" + transport_kwargs: dict[str, object] = {} + client_kwargs: dict[str, object] = {} + for key, value in kwargs.items(): + if key in _TRANSPORT_KWARGS: + transport_kwargs[key] = value + else: + client_kwargs[key] = value + + transport = SSRFSafeSyncTransport(policy=policy, **transport_kwargs) + + client_kwargs.setdefault("follow_redirects", True) + client_kwargs.setdefault("max_redirects", 10) + + return httpx.Client( + transport=transport, + **client_kwargs, # type: ignore[arg-type] + ) + + +def ssrf_safe_async_client( + policy: SSRFPolicy = DEFAULT_SSRF_POLICY, + **kwargs: object, +) -> httpx.AsyncClient: + """Create an `httpx.AsyncClient` with SSRF protection. + + Drop-in replacement for `httpx.AsyncClient(...)` - callers just swap + the constructor call. Transport-specific kwargs (`verify`, `cert`, + `retries`, etc.) are forwarded to the inner `AsyncHTTPTransport`; + everything else goes to the `AsyncClient`. + """ + transport_kwargs: dict[str, object] = {} + client_kwargs: dict[str, object] = {} + for key, value in kwargs.items(): + if key in _TRANSPORT_KWARGS: + transport_kwargs[key] = value + else: + client_kwargs[key] = value + + transport = SSRFSafeTransport(policy=policy, **transport_kwargs) + + # Apply defaults only if not overridden by caller. + client_kwargs.setdefault("follow_redirects", True) + client_kwargs.setdefault("max_redirects", 10) + + return httpx.AsyncClient( + transport=transport, + **client_kwargs, # type: ignore[arg-type] + ) diff --git a/libs/core/langchain_core/agents.py b/libs/core/langchain_core/agents.py new file mode 100644 index 0000000..1ccc1c1 --- /dev/null +++ b/libs/core/langchain_core/agents.py @@ -0,0 +1,258 @@ +"""Schema definitions for representing agent actions, observations, and return values. + +!!! warning + + The schema definitions are provided for backwards compatibility. + +!!! warning + + New agents should be built using the + [`langchain` library](https://pypi.org/project/langchain/), which provides a + simpler and more flexible way to define agents. + + See docs on [building agents](https://docs.langchain.com/oss/python/langchain/agents). + +Agents use language models to choose a sequence of actions to take. + +A basic agent works in the following manner: + +1. Given a prompt an agent uses an LLM to request an action to take + (e.g., a tool to run). +2. The agent executes the action (e.g., runs the tool), and receives an observation. +3. The agent returns the observation to the LLM, which can then be used to generate + the next action. +4. When the agent reaches a stopping condition, it returns a final return value. + +The schemas for the agents themselves are defined in `langchain.agents.agent`. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any, Literal + +from langchain_core.load.serializable import Serializable +from langchain_core.messages import ( + AIMessage, + BaseMessage, + FunctionMessage, + HumanMessage, +) + + +class AgentAction(Serializable): + """Represents a request to execute an action by an agent. + + The action consists of the name of the tool to execute and the input to pass + to the tool. The log is used to pass along extra information about the action. + """ + + tool: str + """The name of the `Tool` to execute.""" + + tool_input: str | dict[Any, Any] + """The input to pass in to the `Tool`.""" + + log: str + """Additional information to log about the action. + + This log can be used in a few ways. First, it can be used to audit what exactly the + LLM predicted to lead to this `(tool, tool_input)`. + + Second, it can be used in future iterations to show the LLMs prior thoughts. This is + useful when `(tool, tool_input)` does not contain full information about the LLM + prediction (for example, any `thought` before the tool/tool_input). + """ + + type: Literal["AgentAction"] = "AgentAction" + + # Override init to support instantiation by position for backward compat. + def __init__( + self, tool: str, tool_input: str | dict[Any, Any], log: str, **kwargs: Any + ): + """Create an `AgentAction`. + + Args: + tool: The name of the tool to execute. + tool_input: The input to pass in to the `Tool`. + log: Additional information to log about the action. + """ + super().__init__(tool=tool, tool_input=tool_input, log=log, **kwargs) + + @classmethod + def is_lc_serializable(cls) -> bool: + """`AgentAction` is serializable. + + Returns: + `True` + """ + return True + + @classmethod + def get_lc_namespace(cls) -> list[str]: + """Get the namespace of the LangChain object. + + Returns: + `["langchain", "schema", "agent"]` + """ + return ["langchain", "schema", "agent"] + + @property + def messages(self) -> Sequence[BaseMessage]: + """Return the messages that correspond to this action.""" + return _convert_agent_action_to_messages(self) + + +class AgentActionMessageLog(AgentAction): + """Representation of an action to be executed by an agent. + + This is similar to `AgentAction`, but includes a message log consisting of + chat messages. + + This is useful when working with `ChatModels`, and is used to reconstruct + conversation history from the agent's perspective. + """ + + message_log: Sequence[BaseMessage] + """Similar to log, this can be used to pass along extra information about what exact + messages were predicted by the LLM before parsing out the `(tool, tool_input)`. + + This is again useful if `(tool, tool_input)` cannot be used to fully recreate the + LLM prediction, and you need that LLM prediction (for future agent iteration). + + Compared to `log`, this is useful when the underlying LLM is a chat model (and + therefore returns messages rather than a string). + """ + # Ignoring type because we're overriding the type from AgentAction. + # And this is the correct thing to do in this case. + # The type literal is used for serialization purposes. + type: Literal["AgentActionMessageLog"] = "AgentActionMessageLog" # type: ignore[assignment] + + +class AgentStep(Serializable): + """Result of running an `AgentAction`.""" + + action: AgentAction + """The `AgentAction` that was executed.""" + + observation: Any + """The result of the `AgentAction`.""" + + @property + def messages(self) -> Sequence[BaseMessage]: + """Messages that correspond to this observation.""" + return _convert_agent_observation_to_messages(self.action, self.observation) + + +class AgentFinish(Serializable): + """Final return value of an `ActionAgent`. + + Agents return an `AgentFinish` when they have reached a stopping condition. + """ + + return_values: dict[Any, Any] + """Dictionary of return values.""" + + log: str + """Additional information to log about the return value. + + This is used to pass along the full LLM prediction, not just the parsed out + return value. + + For example, if the full LLM prediction was `Final Answer: 2` you may want to just + return `2` as a return value, but pass along the full string as a `log` (for + debugging or observability purposes). + """ + type: Literal["AgentFinish"] = "AgentFinish" + + def __init__(self, return_values: dict[Any, Any], log: str, **kwargs: Any): + """Override init to support instantiation by position for backward compat.""" + super().__init__(return_values=return_values, log=log, **kwargs) + + @classmethod + def is_lc_serializable(cls) -> bool: + """Return `True` as this class is serializable.""" + return True + + @classmethod + def get_lc_namespace(cls) -> list[str]: + """Get the namespace of the LangChain object. + + Returns: + `["langchain", "schema", "agent"]` + """ + return ["langchain", "schema", "agent"] + + @property + def messages(self) -> Sequence[BaseMessage]: + """Messages that correspond to this observation.""" + return [AIMessage(content=self.log)] + + +def _convert_agent_action_to_messages( + agent_action: AgentAction, +) -> Sequence[BaseMessage]: + """Convert an agent action to a message. + + This code is used to reconstruct the original AI message from the agent action. + + Args: + agent_action: Agent action to convert. + + Returns: + `AIMessage` that corresponds to the original tool invocation. + """ + if isinstance(agent_action, AgentActionMessageLog): + return agent_action.message_log + return [AIMessage(content=agent_action.log)] + + +def _convert_agent_observation_to_messages( + agent_action: AgentAction, observation: Any +) -> Sequence[BaseMessage]: + """Convert an agent action to a message. + + This code is used to reconstruct the original AI message from the agent action. + + Args: + agent_action: Agent action to convert. + observation: Observation to convert to a message. + + Returns: + `AIMessage` that corresponds to the original tool invocation. + """ + if isinstance(agent_action, AgentActionMessageLog): + return [_create_function_message(agent_action, observation)] + content = observation + if not isinstance(observation, str): + try: + content = json.dumps(observation, ensure_ascii=False) + except Exception: + content = str(observation) + return [HumanMessage(content=content)] + + +def _create_function_message( + agent_action: AgentAction, observation: Any +) -> FunctionMessage: + """Convert agent action and observation into a function message. + + Args: + agent_action: the tool invocation request from the agent. + observation: the result of the tool invocation. + + Returns: + `FunctionMessage` that corresponds to the original tool invocation. + """ + if not isinstance(observation, str): + try: + content = json.dumps(observation, ensure_ascii=False) + except Exception: + content = str(observation) + else: + content = observation + return FunctionMessage( + name=agent_action.tool, + content=content, + ) diff --git a/libs/core/langchain_core/caches.py b/libs/core/langchain_core/caches.py new file mode 100644 index 0000000..1ac2508 --- /dev/null +++ b/libs/core/langchain_core/caches.py @@ -0,0 +1,272 @@ +"""Optional caching layer for language models. + +Distinct from provider-based [prompt caching](https://docs.langchain.com/oss/python/langchain/models#prompt-caching). + +!!! warning "Beta feature" + + This is a beta feature. Please be wary of deploying experimental code to production + unless you've taken appropriate precautions. + +A cache is useful for two reasons: + +1. It can save you money by reducing the number of API calls you make to the LLM + provider if you're often requesting the same completion multiple times. +2. It can speed up your application by reducing the number of API calls you make to the + LLM provider. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any + +from typing_extensions import override + +from langchain_core.outputs import Generation +from langchain_core.runnables import run_in_executor + +RETURN_VAL_TYPE = Sequence[Generation] + + +class BaseCache(ABC): + """Interface for a caching layer for LLMs and Chat models. + + The cache interface consists of the following methods: + + - lookup: Look up a value based on a prompt and `llm_string`. + - update: Update the cache based on a prompt and `llm_string`. + - clear: Clear the cache. + + In addition, the cache interface provides an async version of each method. + + The default implementation of the async methods is to run the synchronous + method in an executor. It's recommended to override the async methods + and provide async implementations to avoid unnecessary overhead. + """ + + @abstractmethod + def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None: + """Look up based on `prompt` and `llm_string`. + + A cache implementation is expected to generate a key from the 2-tuple + of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter). + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + This is used to capture the invocation parameters of the LLM + (e.g., model name, temperature, stop tokens, max tokens, etc.). + + These invocation parameters are serialized into a string representation. + + Returns: + On a cache miss, return `None`. On a cache hit, return the cached value. + The cached value is a list of `Generation` (or subclasses). + """ + + @abstractmethod + def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None: + """Update cache based on `prompt` and `llm_string`. + + The `prompt` and `llm_string` are used to generate a key for the cache. The key + should match that of the lookup method. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + This is used to capture the invocation parameters of the LLM + (e.g., model name, temperature, stop tokens, max tokens, etc.). + + These invocation parameters are serialized into a string + representation. + return_val: The value to be cached. + + The value is a list of `Generation` (or subclasses). + """ + + @abstractmethod + def clear(self, **kwargs: Any) -> None: + """Clear cache that can take additional keyword arguments.""" + + async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None: + """Async look up based on `prompt` and `llm_string`. + + A cache implementation is expected to generate a key from the 2-tuple + of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter). + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + This is used to capture the invocation parameters of the LLM + (e.g., model name, temperature, stop tokens, max tokens, etc.). + + These invocation parameters are serialized into a string + representation. + + Returns: + On a cache miss, return `None`. On a cache hit, return the cached value. + The cached value is a list of `Generation` (or subclasses). + """ + return await run_in_executor(None, self.lookup, prompt, llm_string) + + async def aupdate( + self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE + ) -> None: + """Async update cache based on `prompt` and `llm_string`. + + The prompt and llm_string are used to generate a key for the cache. + The key should match that of the look up method. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + This is used to capture the invocation parameters of the LLM + (e.g., model name, temperature, stop tokens, max tokens, etc.). + + These invocation parameters are serialized into a string + representation. + return_val: The value to be cached. The value is a list of `Generation` + (or subclasses). + """ + return await run_in_executor(None, self.update, prompt, llm_string, return_val) + + async def aclear(self, **kwargs: Any) -> None: + """Async clear cache that can take additional keyword arguments.""" + return await run_in_executor(None, self.clear, **kwargs) + + +class InMemoryCache(BaseCache): + """Cache that stores things in memory. + + Example: + ```python + from langchain_core.caches import InMemoryCache + from langchain_core.outputs import Generation + + # Initialize cache + cache = InMemoryCache() + + # Update cache + cache.update( + prompt="What is the capital of France?", + llm_string="model='gpt-5.4-mini', + return_val=[Generation(text="Paris")], + ) + + # Lookup cache + result = cache.lookup( + prompt="What is the capital of France?", + llm_string="model='gpt-5.4-mini', + ) + # result is [Generation(text="Paris")] + ``` + """ + + def __init__(self, *, maxsize: int | None = None) -> None: + """Initialize with empty cache. + + Args: + maxsize: The maximum number of items to store in the cache. + + If `None`, the cache has no maximum size. + + If the cache exceeds the maximum size, the oldest items are removed. + + Raises: + ValueError: If `maxsize` is less than or equal to `0`. + """ + self._cache: dict[tuple[str, str], RETURN_VAL_TYPE] = {} + if maxsize is not None and maxsize <= 0: + msg = "maxsize must be greater than 0" + raise ValueError(msg) + self._maxsize = maxsize + + def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None: + """Look up based on `prompt` and `llm_string`. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + Returns: + On a cache miss, return `None`. On a cache hit, return the cached value. + """ + return self._cache.get((prompt, llm_string), None) + + def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None: + """Update cache based on `prompt` and `llm_string`. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + return_val: The value to be cached. + + The value is a list of `Generation` (or subclasses). + """ + if self._maxsize is not None and len(self._cache) == self._maxsize: + del self._cache[next(iter(self._cache))] + self._cache[prompt, llm_string] = return_val + + @override + def clear(self, **kwargs: Any) -> None: + """Clear cache.""" + self._cache = {} + + async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None: + """Async look up based on `prompt` and `llm_string`. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + + Returns: + On a cache miss, return `None`. On a cache hit, return the cached value. + """ + return self.lookup(prompt, llm_string) + + async def aupdate( + self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE + ) -> None: + """Async update cache based on `prompt` and `llm_string`. + + Args: + prompt: A string representation of the prompt. + + In the case of a chat model, the prompt is a non-trivial + serialization of the prompt into the language model. + llm_string: A string representation of the LLM configuration. + return_val: The value to be cached. The value is a list of `Generation` + (or subclasses). + """ + self.update(prompt, llm_string, return_val) + + @override + async def aclear(self, **kwargs: Any) -> None: + """Async clear cache.""" + self.clear() diff --git a/libs/core/langchain_core/callbacks/__init__.py b/libs/core/langchain_core/callbacks/__init__.py new file mode 100644 index 0000000..e1f1775 --- /dev/null +++ b/libs/core/langchain_core/callbacks/__init__.py @@ -0,0 +1,132 @@ +"""Callback handlers allow listening to events in LangChain.""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.callbacks.base import ( + AsyncCallbackHandler, + BaseCallbackHandler, + BaseCallbackManager, + CallbackManagerMixin, + Callbacks, + ChainManagerMixin, + LLMManagerMixin, + RetrieverManagerMixin, + RunManagerMixin, + ToolManagerMixin, + ) + from langchain_core.callbacks.file import FileCallbackHandler + from langchain_core.callbacks.manager import ( + AsyncCallbackManager, + AsyncCallbackManagerForChainGroup, + AsyncCallbackManagerForChainRun, + AsyncCallbackManagerForLLMRun, + AsyncCallbackManagerForRetrieverRun, + AsyncCallbackManagerForToolRun, + AsyncParentRunManager, + AsyncRunManager, + BaseRunManager, + CallbackManager, + CallbackManagerForChainGroup, + CallbackManagerForChainRun, + CallbackManagerForLLMRun, + CallbackManagerForRetrieverRun, + CallbackManagerForToolRun, + ParentRunManager, + RunManager, + adispatch_custom_event, + dispatch_custom_event, + ) + from langchain_core.callbacks.stdout import StdOutCallbackHandler + from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler + from langchain_core.callbacks.usage import ( + UsageMetadataCallbackHandler, + get_usage_metadata_callback, + ) + +__all__ = ( + "AsyncCallbackHandler", + "AsyncCallbackManager", + "AsyncCallbackManagerForChainGroup", + "AsyncCallbackManagerForChainRun", + "AsyncCallbackManagerForLLMRun", + "AsyncCallbackManagerForRetrieverRun", + "AsyncCallbackManagerForToolRun", + "AsyncParentRunManager", + "AsyncRunManager", + "BaseCallbackHandler", + "BaseCallbackManager", + "BaseRunManager", + "CallbackManager", + "CallbackManagerForChainGroup", + "CallbackManagerForChainRun", + "CallbackManagerForLLMRun", + "CallbackManagerForRetrieverRun", + "CallbackManagerForToolRun", + "CallbackManagerMixin", + "Callbacks", + "ChainManagerMixin", + "FileCallbackHandler", + "LLMManagerMixin", + "ParentRunManager", + "RetrieverManagerMixin", + "RunManager", + "RunManagerMixin", + "StdOutCallbackHandler", + "StreamingStdOutCallbackHandler", + "ToolManagerMixin", + "UsageMetadataCallbackHandler", + "adispatch_custom_event", + "dispatch_custom_event", + "get_usage_metadata_callback", +) + +_dynamic_imports = { + "AsyncCallbackHandler": "base", + "BaseCallbackHandler": "base", + "BaseCallbackManager": "base", + "CallbackManagerMixin": "base", + "Callbacks": "base", + "ChainManagerMixin": "base", + "LLMManagerMixin": "base", + "RetrieverManagerMixin": "base", + "RunManagerMixin": "base", + "ToolManagerMixin": "base", + "FileCallbackHandler": "file", + "AsyncCallbackManager": "manager", + "AsyncCallbackManagerForChainGroup": "manager", + "AsyncCallbackManagerForChainRun": "manager", + "AsyncCallbackManagerForLLMRun": "manager", + "AsyncCallbackManagerForRetrieverRun": "manager", + "AsyncCallbackManagerForToolRun": "manager", + "AsyncParentRunManager": "manager", + "AsyncRunManager": "manager", + "BaseRunManager": "manager", + "CallbackManager": "manager", + "CallbackManagerForChainGroup": "manager", + "CallbackManagerForChainRun": "manager", + "CallbackManagerForLLMRun": "manager", + "CallbackManagerForRetrieverRun": "manager", + "CallbackManagerForToolRun": "manager", + "ParentRunManager": "manager", + "RunManager": "manager", + "adispatch_custom_event": "manager", + "dispatch_custom_event": "manager", + "StdOutCallbackHandler": "stdout", + "StreamingStdOutCallbackHandler": "streaming_stdout", + "UsageMetadataCallbackHandler": "usage", + "get_usage_metadata_callback": "usage", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/callbacks/base.py b/libs/core/langchain_core/callbacks/base.py new file mode 100644 index 0000000..3573dff --- /dev/null +++ b/libs/core/langchain_core/callbacks/base.py @@ -0,0 +1,1229 @@ +"""Base callback handler for LangChain.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Sequence + from uuid import UUID + + from langchain_protocol.protocol import MessagesData + from tenacity import RetryCallState + from typing_extensions import Self + + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.documents import Document + from langchain_core.messages import BaseMessage + from langchain_core.outputs import ChatGenerationChunk, GenerationChunk, LLMResult + +_LOGGER = logging.getLogger(__name__) + + +class RetrieverManagerMixin: + """Mixin for `Retriever` callbacks.""" + + def on_retriever_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when `Retriever` errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_retriever_end( + self, + documents: Sequence[Document], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when `Retriever` ends running. + + Args: + documents: The documents retrieved. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + +class LLMManagerMixin: + """Mixin for LLM callbacks.""" + + def on_llm_new_token( + self, + token: str | list[str | dict[str, Any]], + *, + chunk: GenerationChunk | ChatGenerationChunk | None = None, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> Any: + """Run on new output token. + + Only available when streaming is enabled. + + For both chat models and non-chat models (legacy text completion LLMs). + + Args: + token: The new token, or a list of content blocks. + chunk: The new generated chunk, containing content and other information. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + def on_llm_end( + self, + response: LLMResult, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> Any: + """Run when LLM ends running. + + Args: + response: The response which was generated. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + def on_llm_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> Any: + """Run when LLM errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + def on_stream_event( + self, + event: MessagesData, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> Any: + """Run on each protocol event from `stream_events(version="v3")`. + + Also fires for the async equivalent + (`astream_events(version="v3")`). + + Fires once per `MessagesData` event — `message-start`, per-block + `content-block-start` / `content-block-delta` / + `content-block-finish`, and `message-finish`. Analogous to + `on_llm_new_token` in v1 streaming, but at event granularity rather + than chunk: a single chunk can map to multiple events (e.g. a + `content-block-start` plus its first `content-block-delta`), and + lifecycle boundaries are explicit. + + Fires uniformly whether the provider emits events natively via + `_stream_chat_model_events` or goes through the chunk-to-event + compat bridge. Observers see the same event stream regardless of + how the underlying model produces output. + + Not fired from v1 `stream()` / `astream()`; for those, keep using + `on_llm_new_token`. Purely additive — `on_chat_model_start`, + `on_llm_end`, and `on_llm_error` still fire around a v2 call as + they do around a v1 call. + + Args: + event: The protocol event. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + +class ChainManagerMixin: + """Mixin for chain callbacks.""" + + def on_chain_end( + self, + outputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when chain ends running. + + Args: + outputs: The outputs of the chain. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_chain_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when chain errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_agent_action( + self, + action: AgentAction, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run on agent action. + + Args: + action: The agent action. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_agent_finish( + self, + finish: AgentFinish, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run on the agent end. + + Args: + finish: The agent finish. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + +class ToolManagerMixin: + """Mixin for tool callbacks.""" + + def on_tool_end( + self, + output: Any, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when the tool ends running. + + Args: + output: The output of the tool. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_tool_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run when tool errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + +class CallbackManagerMixin: + """Mixin for callback manager.""" + + def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when LLM starts running. + + !!! warning + + This method is called for non-chat models (regular text completion LLMs). If + you're implementing a handler for a chat model, you should use + `on_chat_model_start` instead. + + Args: + serialized: The serialized LLM. + prompts: The prompts. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when a chat model starts running. + + !!! warning + + This method is called for chat models. If you're implementing a handler for + a non-chat model, you should use `on_llm_start` instead. + + !!! note + + When overriding this method, the signature **must** include the two + required positional arguments `serialized` and `messages`. Avoid + using `*args` in your override — doing so causes an `IndexError` + in the fallback path when the callback system converts `messages` + to prompt strings for `on_llm_start`. Always declare the + signature explicitly: + + .. code-block:: python + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + **kwargs: Any, + ) -> None: + raise NotImplementedError # triggers fallback to on_llm_start + + Args: + serialized: The serialized chat model. + messages: The messages. Must be a list of message lists — this is a + required positional argument and must be present in any override. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + # NotImplementedError is thrown intentionally + # Callback handler will fall back to on_llm_start if this exception is thrown + msg = f"{self.__class__.__name__} does not implement `on_chat_model_start`" + raise NotImplementedError(msg) + + def on_retriever_start( + self, + serialized: dict[str, Any], + query: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when the `Retriever` starts running. + + Args: + serialized: The serialized `Retriever`. + query: The query. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + def on_chain_start( + self, + serialized: dict[str, Any], + inputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when a chain starts running. + + Args: + serialized: The serialized chain. + inputs: The inputs. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inputs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when the tool starts running. + + Args: + serialized: The serialized chain. + input_str: The input string. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + inputs: The inputs. + **kwargs: Additional keyword arguments. + """ + + +class RunManagerMixin: + """Mixin for run manager.""" + + def on_text( + self, + text: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run on an arbitrary text. + + Args: + text: The text. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_retry( + self, + retry_state: RetryCallState, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run on a retry event. + + Args: + retry_state: The retry state. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: UUID, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Override to define a handler for a custom event. + + Args: + name: The name of the custom event. + data: The data for the custom event. + + Format will match the format specified by the user. + run_id: The ID of the run. + tags: The tags associated with the custom event (includes inherited tags). + metadata: The metadata associated with the custom event (includes inherited + metadata). + """ + + +class BaseCallbackHandler( + LLMManagerMixin, + ChainManagerMixin, + ToolManagerMixin, + RetrieverManagerMixin, + CallbackManagerMixin, + RunManagerMixin, +): + """Base callback handler.""" + + raise_error: bool = False + """Whether to raise an error if an exception occurs.""" + + run_inline: bool = False + """Whether to run the callback inline.""" + + @property + def ignore_llm(self) -> bool: + """Whether to ignore LLM callbacks.""" + return False + + @property + def ignore_retry(self) -> bool: + """Whether to ignore retry callbacks.""" + return False + + @property + def ignore_chain(self) -> bool: + """Whether to ignore chain callbacks.""" + return False + + @property + def ignore_agent(self) -> bool: + """Whether to ignore agent callbacks.""" + return False + + @property + def ignore_retriever(self) -> bool: + """Whether to ignore retriever callbacks.""" + return False + + @property + def ignore_chat_model(self) -> bool: + """Whether to ignore chat model callbacks.""" + return False + + @property + def ignore_custom_event(self) -> bool: + """Ignore custom event.""" + return False + + +class AsyncCallbackHandler(BaseCallbackHandler): + """Base async callback handler.""" + + async def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Run when the model starts running. + + !!! warning + + This method is called for non-chat models (regular text completion LLMs). If + you're implementing a handler for a chat model, you should use + `on_chat_model_start` instead. + + Args: + serialized: The serialized LLM. + prompts: The prompts. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + async def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Run when a chat model starts running. + + !!! warning + + This method is called for chat models. If you're implementing a handler for + a non-chat model, you should use `on_llm_start` instead. + + !!! note + + When overriding this method, the signature **must** include the two + required positional arguments `serialized` and `messages`. Avoid + using `*args` in your override — doing so causes an `IndexError` + in the fallback path when the callback system converts `messages` + to prompt strings for `on_llm_start`. Always declare the + signature explicitly: + + .. code-block:: python + + async def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + **kwargs: Any, + ) -> None: + raise NotImplementedError # triggers fallback to on_llm_start + + Args: + serialized: The serialized chat model. + messages: The messages. Must be a list of message lists — this is a + required positional argument and must be present in any override. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + # NotImplementedError is thrown intentionally + # Callback handler will fall back to on_llm_start if this exception is thrown + msg = f"{self.__class__.__name__} does not implement `on_chat_model_start`" + raise NotImplementedError(msg) + + async def on_llm_new_token( + self, + token: str | list[str | dict[str, Any]], + *, + chunk: GenerationChunk | ChatGenerationChunk | None = None, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on new output token. Only available when streaming is enabled. + + For both chat models and non-chat models (legacy text completion LLMs). + + Args: + token: The new token, or a list of content blocks. + chunk: The new generated chunk, containing content and other information. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_llm_end( + self, + response: LLMResult, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when the model ends running. + + Args: + response: The response which was generated. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_llm_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when LLM errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + + - response (LLMResult): The response which was generated before + the error occurred. + """ + + async def on_stream_event( + self, + event: MessagesData, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on each protocol event produced by `astream_events(version="v3")`. + + See :meth:`LLMManagerMixin.on_stream_event` for the full contract. + Fires once per `MessagesData` event at event granularity, uniformly + across native and compat-bridge providers, and is purely additive + to the existing `on_chat_model_start` / `on_llm_end` / + `on_llm_error` callbacks. + + Args: + event: The protocol event. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_chain_start( + self, + serialized: dict[str, Any], + inputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Run when a chain starts running. + + Args: + serialized: The serialized chain. + inputs: The inputs. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + async def on_chain_end( + self, + outputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when a chain ends running. + + Args: + outputs: The outputs of the chain. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_chain_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when chain errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inputs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Run when the tool starts running. + + Args: + serialized: The serialized tool. + input_str: The input string. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + inputs: The inputs. + **kwargs: Additional keyword arguments. + """ + + async def on_tool_end( + self, + output: Any, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when the tool ends running. + + Args: + output: The output of the tool. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_tool_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run when tool errors. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_text( + self, + text: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on an arbitrary text. + + Args: + text: The text. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_retry( + self, + retry_state: RetryCallState, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> Any: + """Run on a retry event. + + Args: + retry_state: The retry state. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + """ + + async def on_agent_action( + self, + action: AgentAction, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on agent action. + + Args: + action: The agent action. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_agent_finish( + self, + finish: AgentFinish, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on the agent end. + + Args: + finish: The agent finish. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_retriever_start( + self, + serialized: dict[str, Any], + query: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Run on the retriever start. + + Args: + serialized: The serialized retriever. + query: The query. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + metadata: The metadata. + **kwargs: Additional keyword arguments. + """ + + async def on_retriever_end( + self, + documents: Sequence[Document], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on the retriever end. + + Args: + documents: The documents retrieved. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_retriever_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + **kwargs: Any, + ) -> None: + """Run on retriever error. + + Args: + error: The error that occurred. + run_id: The ID of the current run. + parent_run_id: The ID of the parent run. + tags: The tags. + **kwargs: Additional keyword arguments. + """ + + async def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: UUID, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Override to define a handler for custom events. + + Args: + name: The name of the custom event. + data: The data for the custom event. + + Format will match the format specified by the user. + run_id: The ID of the run. + tags: The tags associated with the custom event (includes inherited tags). + metadata: The metadata associated with the custom event (includes inherited + metadata). + """ + + +class BaseCallbackManager(CallbackManagerMixin): + """Base callback manager.""" + + def __init__( + self, + handlers: list[BaseCallbackHandler], + inheritable_handlers: list[BaseCallbackHandler] | None = None, + parent_run_id: UUID | None = None, + *, + tags: list[str] | None = None, + inheritable_tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inheritable_metadata: dict[str, Any] | None = None, + ) -> None: + """Initialize callback manager. + + Args: + handlers: The handlers. + inheritable_handlers: The inheritable handlers. + parent_run_id: The parent run ID. + tags: The tags. + inheritable_tags: The inheritable tags. + metadata: The metadata. + inheritable_metadata: The inheritable metadata. + """ + self.handlers: list[BaseCallbackHandler] = handlers + self.inheritable_handlers: list[BaseCallbackHandler] = ( + inheritable_handlers or [] + ) + self.parent_run_id: UUID | None = parent_run_id + self.tags = tags or [] + self.inheritable_tags = inheritable_tags or [] + self.metadata = metadata or {} + self.inheritable_metadata = inheritable_metadata or {} + + def copy(self) -> Self: + """Return a copy of the callback manager.""" + return self.__class__( + handlers=self.handlers.copy(), + inheritable_handlers=self.inheritable_handlers.copy(), + parent_run_id=self.parent_run_id, + tags=self.tags.copy(), + inheritable_tags=self.inheritable_tags.copy(), + metadata=self.metadata.copy(), + inheritable_metadata=self.inheritable_metadata.copy(), + ) + + def merge(self, other: BaseCallbackManager) -> Self: + """Merge the callback manager with another callback manager. + + May be overwritten in subclasses. + + Primarily used internally within `merge_configs`. + + Returns: + The merged callback manager of the same type as the current object. + + Example: + ```python + # Merging two callback managers` + from langchain_core.callbacks.manager import ( + CallbackManager, + trace_as_chain_group, + ) + from langchain_core.callbacks.stdout import StdOutCallbackHandler + + manager = CallbackManager(handlers=[StdOutCallbackHandler()], tags=["tag2"]) + with trace_as_chain_group("My Group Name", tags=["tag1"]) as group_manager: + merged_manager = group_manager.merge(manager) + print(merged_manager.handlers) + # [ + # , + # , + # ] + + print(merged_manager.tags) + # ['tag2', 'tag1'] + ``` + """ # noqa: E501 + # Combine handlers and inheritable_handlers separately, using sets + # to deduplicate (order not preserved) + combined_handlers = list(set(self.handlers) | set(other.handlers)) + combined_inheritable = list( + set(self.inheritable_handlers) | set(other.inheritable_handlers) + ) + + return self.__class__( + parent_run_id=self.parent_run_id or other.parent_run_id, + handlers=combined_handlers, + inheritable_handlers=combined_inheritable, + tags=list(set(self.tags + other.tags)), + inheritable_tags=list(set(self.inheritable_tags + other.inheritable_tags)), + metadata={ + **self.metadata, + **other.metadata, + }, + inheritable_metadata={ + **self.inheritable_metadata, + **other.inheritable_metadata, + }, + ) + + @property + def is_async(self) -> bool: + """Whether the callback manager is async.""" + return False + + def add_handler( + self, + handler: BaseCallbackHandler, + inherit: bool = True, # noqa: FBT001,FBT002 + ) -> None: + """Add a handler to the callback manager. + + Args: + handler: The handler to add. + inherit: Whether to inherit the handler. + """ + if handler not in self.handlers: + self.handlers.append(handler) + if inherit and handler not in self.inheritable_handlers: + self.inheritable_handlers.append(handler) + + def remove_handler(self, handler: BaseCallbackHandler) -> None: + """Remove a handler from the callback manager. + + Args: + handler: The handler to remove. + """ + if handler in self.handlers: + self.handlers.remove(handler) + if handler in self.inheritable_handlers: + self.inheritable_handlers.remove(handler) + + def set_handlers( + self, + handlers: list[BaseCallbackHandler], + inherit: bool = True, # noqa: FBT001,FBT002 + ) -> None: + """Set handlers as the only handlers on the callback manager. + + Args: + handlers: The handlers to set. + inherit: Whether to inherit the handlers. + """ + self.handlers = [] + self.inheritable_handlers = [] + for handler in handlers: + self.add_handler(handler, inherit=inherit) + + def set_handler( + self, + handler: BaseCallbackHandler, + inherit: bool = True, # noqa: FBT001,FBT002 + ) -> None: + """Set handler as the only handler on the callback manager. + + Args: + handler: The handler to set. + inherit: Whether to inherit the handler. + """ + self.set_handlers([handler], inherit=inherit) + + def add_tags( + self, + tags: list[str], + inherit: bool = True, # noqa: FBT001,FBT002 + ) -> None: + """Add tags to the callback manager. + + Args: + tags: The tags to add. + inherit: Whether to inherit the tags. + """ + for tag in tags: + if tag in self.tags: + self.remove_tags([tag]) + self.tags.extend(tags) + if inherit: + self.inheritable_tags.extend(tags) + + def remove_tags(self, tags: list[str]) -> None: + """Remove tags from the callback manager. + + Args: + tags: The tags to remove. + """ + for tag in tags: + if tag in self.tags: + self.tags.remove(tag) + if tag in self.inheritable_tags: + self.inheritable_tags.remove(tag) + + def add_metadata( + self, + metadata: dict[str, Any], + inherit: bool = True, # noqa: FBT001,FBT002 + ) -> None: + """Add metadata to the callback manager. + + Args: + metadata: The metadata to add. + inherit: Whether to inherit the metadata. + """ + from langchain_core.runnables.config import ( # noqa: PLC0415 + _merge_metadata_dicts, + ) + + self.metadata = _merge_metadata_dicts(self.metadata, metadata) + if inherit: + self.inheritable_metadata = _merge_metadata_dicts( + self.inheritable_metadata, metadata + ) + + def remove_metadata(self, keys: list[str]) -> None: + """Remove metadata from the callback manager. + + Args: + keys: The keys to remove. + """ + for key in keys: + self.metadata.pop(key, None) + self.inheritable_metadata.pop(key, None) + + +Callbacks = list[BaseCallbackHandler] | BaseCallbackManager | None diff --git a/libs/core/langchain_core/callbacks/file.py b/libs/core/langchain_core/callbacks/file.py new file mode 100644 index 0000000..3ba0e86 --- /dev/null +++ b/libs/core/langchain_core/callbacks/file.py @@ -0,0 +1,267 @@ +"""Callback handler that writes to a file.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, TextIO, cast + +from typing_extensions import Self, override + +from langchain_core._api import warn_deprecated +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.utils.input import print_text + +if TYPE_CHECKING: + from langchain_core.agents import AgentAction, AgentFinish + + +_GLOBAL_DEPRECATION_WARNED = False + + +class FileCallbackHandler(BaseCallbackHandler): + """Callback handler that writes to a file. + + This handler supports both context manager usage (recommended) and direct + instantiation (deprecated) for backwards compatibility. + + Examples: + Using as a context manager (recommended): + + ```python + with FileCallbackHandler("output.txt") as handler: + # Use handler with your chain/agent + chain.invoke(inputs, config={"callbacks": [handler]}) + ``` + + Direct instantiation (deprecated): + + ```python + handler = FileCallbackHandler("output.txt") + # File remains open until handler is garbage collected + try: + chain.invoke(inputs, config={"callbacks": [handler]}) + finally: + handler.close() # Explicit cleanup recommended + ``` + + Args: + filename: The file path to write to. + mode: The file open mode. Defaults to `'a'` (append). + color: Default color for text output. + + !!! note + + When not used as a context manager, a deprecation warning will be issued on + first use. The file will be opened immediately in `__init__` and closed in + `__del__` or when `close()` is called explicitly. + + """ + + def __init__( + self, filename: str, mode: str = "a", color: str | None = None + ) -> None: + """Initialize the file callback handler. + + Args: + filename: Path to the output file. + mode: File open mode (e.g., `'w'`, `'a'`, `'x'`). Defaults to `'a'`. + color: Default text color for output. + + """ + self.filename = filename + self.mode = mode + self.color = color + self._file_opened_in_context = False + self.file: TextIO = cast( + "TextIO", + # Open the file in the specified mode with UTF-8 encoding. + Path(self.filename).open(self.mode, encoding="utf-8"), # noqa: SIM115 + ) + + def __enter__(self) -> Self: + """Enter the context manager. + + Returns: + The `FileCallbackHandler` instance. + + !!! note + + The file is already opened in `__init__`, so this just marks that the + handler is being used as a context manager. + + """ + self._file_opened_in_context = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + """Exit the context manager and close the file. + + Args: + exc_type: Exception type if an exception occurred. + exc_val: Exception value if an exception occurred. + exc_tb: Exception traceback if an exception occurred. + + """ + self.close() + + def __del__(self) -> None: + """Destructor to cleanup when done.""" + self.close() + + def close(self) -> None: + """Close the file if it's open. + + This method is safe to call multiple times and will only close + the file if it's currently open. + + """ + if hasattr(self, "file") and self.file and not self.file.closed: + self.file.close() + + def _write( + self, + text: str, + color: str | None = None, + end: str = "", + ) -> None: + """Write text to the file with deprecation warning if needed. + + Args: + text: The text to write to the file. + color: Optional color for the text. Defaults to `self.color`. + end: String appended after the text. + file: Optional file to write to. Defaults to `self.file`. + + Raises: + RuntimeError: If the file is closed or not available. + + """ + global _GLOBAL_DEPRECATION_WARNED # noqa: PLW0603 + if not self._file_opened_in_context and not _GLOBAL_DEPRECATION_WARNED: + warn_deprecated( + since="0.3.67", + pending=True, + message=( + "Using FileCallbackHandler without a context manager is " + "deprecated. Use 'with FileCallbackHandler(...) as " + "handler:' instead." + ), + ) + _GLOBAL_DEPRECATION_WARNED = True + + if not hasattr(self, "file") or self.file is None or self.file.closed: + msg = "File is not open. Use FileCallbackHandler as a context manager." + raise RuntimeError(msg) + + print_text(text, file=self.file, color=color, end=end) + + @override + def on_chain_start( + self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any + ) -> None: + """Print that we are entering a chain. + + Args: + serialized: The serialized chain information. + inputs: The inputs to the chain. + **kwargs: Additional keyword arguments that may contain `'name'`. + + """ + name = ( + kwargs.get("name") + or serialized.get("name", serialized.get("id", [""])[-1]) + or "" + ) + self._write(f"\n\n> Entering new {name} chain...", end="\n") + + @override + def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None: + """Print that we finished a chain. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + + """ + self._write("\n> Finished chain.", end="\n") + + @override + def on_agent_action( + self, action: AgentAction, color: str | None = None, **kwargs: Any + ) -> Any: + """Handle agent action by writing the action log. + + Args: + action: The agent action containing the log to write. + color: Color override for this specific output. + + If `None`, uses `self.color`. + **kwargs: Additional keyword arguments. + + """ + self._write(action.log, color=color or self.color) + + @override + def on_tool_end( + self, + output: str, + color: str | None = None, + observation_prefix: str | None = None, + llm_prefix: str | None = None, + **kwargs: Any, + ) -> None: + """Handle tool end by writing the output with optional prefixes. + + Args: + output: The tool output to write. + color: Color override for this specific output. + + If `None`, uses `self.color`. + observation_prefix: Optional prefix to write before the output. + llm_prefix: Optional prefix to write after the output. + **kwargs: Additional keyword arguments. + + """ + if observation_prefix is not None: + self._write(f"\n{observation_prefix}") + self._write(output) + if llm_prefix is not None: + self._write(f"\n{llm_prefix}") + + @override + def on_text( + self, text: str, color: str | None = None, end: str = "", **kwargs: Any + ) -> None: + """Handle text output. + + Args: + text: The text to write. + color: Color override for this specific output. + + If `None`, uses `self.color`. + end: String appended after the text. + **kwargs: Additional keyword arguments. + + """ + self._write(text, color=color or self.color, end=end) + + @override + def on_agent_finish( + self, finish: AgentFinish, color: str | None = None, **kwargs: Any + ) -> None: + """Handle agent finish by writing the finish log. + + Args: + finish: The agent finish object containing the log to write. + color: Color override for this specific output. + + If `None`, uses `self.color`. + **kwargs: Additional keyword arguments. + + """ + self._write(finish.log, color=color or self.color, end="\n") diff --git a/libs/core/langchain_core/callbacks/manager.py b/libs/core/langchain_core/callbacks/manager.py new file mode 100644 index 0000000..36da69b --- /dev/null +++ b/libs/core/langchain_core/callbacks/manager.py @@ -0,0 +1,2826 @@ +"""Run managers.""" + +from __future__ import annotations + +import asyncio +import atexit +import functools +import inspect +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager, contextmanager +from contextvars import copy_context +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from typing_extensions import Self, override + +from langchain_core.callbacks.base import ( + BaseCallbackHandler, + BaseCallbackManager, + Callbacks, + ChainManagerMixin, + LLMManagerMixin, + RetrieverManagerMixin, + RunManagerMixin, + ToolManagerMixin, +) +from langchain_core.callbacks.stdout import StdOutCallbackHandler +from langchain_core.globals import get_debug +from langchain_core.messages import BaseMessage, get_buffer_string +from langchain_core.utils.env import env_var_is_set +from langchain_core.utils.uuid import uuid7 + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Coroutine, Generator, Sequence + from uuid import UUID + + from langchain_protocol.protocol import MessagesData + from tenacity import RetryCallState + + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.documents import Document + from langchain_core.outputs import ChatGenerationChunk, GenerationChunk, LLMResult + from langchain_core.runnables.config import RunnableConfig + from langchain_core.tracers.schemas import Run + +logger = logging.getLogger(__name__) + + +def _get_debug() -> bool: + return get_debug() + + +@contextmanager +def trace_as_chain_group( + group_name: str, + callback_manager: CallbackManager | None = None, + *, + inputs: dict[str, Any] | None = None, + project_name: str | None = None, + example_id: str | UUID | None = None, + run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, +) -> Generator[CallbackManagerForChainGroup, None, None]: + """Get a callback manager for a chain group in a context manager. + + Useful for grouping different calls together as a single run even if they aren't + composed in a single chain. + + Args: + group_name: The name of the chain group. + callback_manager: The callback manager to use. + inputs: The inputs to the chain group. + project_name: The name of the project. + example_id: The ID of the example. + run_id: The ID of the run. + tags: The inheritable tags to apply to all runs. + metadata: The metadata to apply to all runs. + + !!! note + + Must have `LANGCHAIN_TRACING_V2` env var set to true to see the trace in + LangSmith. + + Yields: + The callback manager for the chain group. + + Example: + ```python + llm_input = "Foo" + with trace_as_chain_group("group_name", inputs={"input": llm_input}) as manager: + # Use the callback manager for the chain group + res = llm.invoke(llm_input, {"callbacks": manager}) + manager.on_chain_end({"output": res}) + ``` + """ + from langchain_core.tracers.context import ( # noqa: PLC0415 -- deferred to avoid importing langsmith at module level + _get_trace_callbacks, + ) + + cb = _get_trace_callbacks( + project_name, example_id, callback_manager=callback_manager + ) + cm = CallbackManager.configure( + inheritable_callbacks=cb, + inheritable_tags=tags, + inheritable_metadata=metadata, + ) + + run_manager = cm.on_chain_start({"name": group_name}, inputs or {}, run_id=run_id) + child_cm = run_manager.get_child() + group_cm = CallbackManagerForChainGroup( + child_cm.handlers, + child_cm.inheritable_handlers, + child_cm.parent_run_id, + parent_run_manager=run_manager, + tags=child_cm.tags, + inheritable_tags=child_cm.inheritable_tags, + metadata=child_cm.metadata, + inheritable_metadata=child_cm.inheritable_metadata, + ) + try: + yield group_cm + except Exception as e: + if not group_cm.ended: + run_manager.on_chain_error(e) + raise + else: + if not group_cm.ended: + run_manager.on_chain_end({}) + + +@asynccontextmanager +async def atrace_as_chain_group( + group_name: str, + callback_manager: AsyncCallbackManager | None = None, + *, + inputs: dict[str, Any] | None = None, + project_name: str | None = None, + example_id: str | UUID | None = None, + run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, +) -> AsyncGenerator[AsyncCallbackManagerForChainGroup, None]: + """Get an async callback manager for a chain group in a context manager. + + Useful for grouping different async calls together as a single run even if they + aren't composed in a single chain. + + Args: + group_name: The name of the chain group. + callback_manager: The async callback manager to use, which manages tracing and + other callback behavior. + inputs: The inputs to the chain group. + project_name: The name of the project. + example_id: The ID of the example. + run_id: The ID of the run. + tags: The inheritable tags to apply to all runs. + metadata: The metadata to apply to all runs. + + Yields: + The async callback manager for the chain group. + + !!! note + + Must have `LANGCHAIN_TRACING_V2` env var set to true to see the trace in + LangSmith. + + Example: + ```python + llm_input = "Foo" + async with atrace_as_chain_group( + "group_name", inputs={"input": llm_input} + ) as manager: + # Use the async callback manager for the chain group + res = await llm.ainvoke(llm_input, {"callbacks": manager}) + await manager.on_chain_end({"output": res}) + ``` + """ + from langchain_core.tracers.context import ( # noqa: PLC0415 -- deferred to avoid importing langsmith at module level + _get_trace_callbacks, + ) + + cb = _get_trace_callbacks( + project_name, example_id, callback_manager=callback_manager + ) + cm = AsyncCallbackManager.configure( + inheritable_callbacks=cb, inheritable_tags=tags, inheritable_metadata=metadata + ) + + run_manager = await cm.on_chain_start( + {"name": group_name}, inputs or {}, run_id=run_id + ) + child_cm = run_manager.get_child() + group_cm = AsyncCallbackManagerForChainGroup( + child_cm.handlers, + child_cm.inheritable_handlers, + child_cm.parent_run_id, + parent_run_manager=run_manager, + tags=child_cm.tags, + inheritable_tags=child_cm.inheritable_tags, + metadata=child_cm.metadata, + inheritable_metadata=child_cm.inheritable_metadata, + ) + try: + yield group_cm + except Exception as e: + if not group_cm.ended: + await run_manager.on_chain_error(e) + raise + else: + if not group_cm.ended: + await run_manager.on_chain_end({}) + + +Func = TypeVar("Func", bound=Callable[..., Any]) + + +def shielded(func: Func) -> Func: + """Makes so an awaitable method is always shielded from cancellation. + + Args: + func: The function to shield. + + Returns: + The shielded function + + """ + + @functools.wraps(func) + async def wrapped(*args: Any, **kwargs: Any) -> Any: + # Capture the current context to preserve context variables + ctx = copy_context() + + # Create the coroutine + coro = func(*args, **kwargs) + + # For Python 3.11+, create task with explicit context + # For older versions, fallback to original behavior + try: + # Create a task with the captured context to preserve context variables + task = asyncio.create_task(coro, context=ctx) # type: ignore[call-arg, unused-ignore] + # `call-arg` used to not fail 3.9 or 3.10 tests + return await asyncio.shield(task) + except TypeError: + # Python < 3.11 fallback - create task normally then shield + # This won't preserve context perfectly but is better than nothing + task = asyncio.create_task(coro) + return await asyncio.shield(task) + + return cast("Func", wrapped) + + +async def _achat_model_start_fallback( + coro: Coroutine[Any, Any, Any], + handler: BaseCallbackHandler, + *args: Any, + **kwargs: Any, +) -> None: + """Wrap an async `on_chat_model_start` coroutine with fallback. + + Catches `NotImplementedError` and triggers the `on_llm_start` fallback. + This covers async handlers invoked from a **sync** `handle_event` call, + where the coroutine is collected into `coros` and executed later by + `_run_coros`. Without this wrapper the `NotImplementedError` would be + caught generically by `_run_coros` and the trace would be lost. + """ + try: + await coro + except NotImplementedError: + message_strings = [get_buffer_string(m) for m in args[1]] + await _ahandle_event_for_handler( + handler, + "on_llm_start", + "ignore_llm", + args[0], + message_strings, + *args[2:], + **kwargs, + ) + + +def handle_event( + handlers: list[BaseCallbackHandler], + event_name: str, + ignore_condition_name: str | None, + *args: Any, + **kwargs: Any, +) -> None: + """Generic event handler for `CallbackManager`. + + Args: + handlers: The list of handlers that will handle the event. + event_name: The name of the event (e.g., `'on_llm_start'`). + ignore_condition_name: Name of the attribute defined on handler that if `True` + will cause the handler to be skipped for the given event. + *args: The arguments to pass to the event handler. + **kwargs: The keyword arguments to pass to the event handler + + """ + coros: list[Coroutine[Any, Any, Any]] = [] + + try: + message_strings: list[str] | None = None + for handler in handlers: + try: + if ignore_condition_name is None or not getattr( + handler, ignore_condition_name + ): + event = getattr(handler, event_name)(*args, **kwargs) + if asyncio.iscoroutine(event): + if event_name == "on_chat_model_start": + event = _achat_model_start_fallback( + event, handler, *args, **kwargs + ) + coros.append(event) + except NotImplementedError as e: + if event_name == "on_chat_model_start": + if message_strings is None: + message_strings = [get_buffer_string(m) for m in args[1]] + handle_event( + [handler], + "on_llm_start", + "ignore_llm", + args[0], + message_strings, + *args[2:], + **kwargs, + ) + else: + handler_name = handler.__class__.__name__ + logger.warning( + "NotImplementedError in %s.%s callback: %s", + handler_name, + event_name, + repr(e), + ) + except Exception as e: + logger.warning( + "Error in %s.%s callback: %s", + handler.__class__.__name__, + event_name, + repr(e), + ) + if handler.raise_error: + raise + finally: + if coros: + try: + # Raises RuntimeError if there is no current event loop. + asyncio.get_running_loop() + loop_running = True + except RuntimeError: + loop_running = False + + if loop_running: + # If we try to submit this coroutine to the running loop + # we end up in a deadlock, as we'd have gotten here from a + # running coroutine, which we cannot interrupt to run this one. + # The solution is to run the synchronous function on the globally shared + # thread pool executor to avoid blocking the main event loop. + _executor().submit(copy_context().run, _run_coros, coros).result() + else: + # If there's no running loop, we can run the coroutines directly. + _run_coros(coros) + + +def _run_coros(coros: list[Coroutine[Any, Any, Any]]) -> None: + # Note: exceptions raised by these coroutines are always logged and swallowed + # here, regardless of the handler's `raise_error` setting. Async-handler errors + # driven through sync `handle_event` therefore never propagate, unlike errors + # from sync handlers (which honor `raise_error`). This is a pre-existing + # asymmetry between the sync and async callback paths. + if hasattr(asyncio, "Runner"): + # Python 3.11+ + # Run the coroutines in a new event loop, taking care to + # - install signal handlers + # - run pending tasks scheduled by `coros` + # - close asyncgens and executors + # - close the loop + with asyncio.Runner() as runner: + # Run the coroutine, get the result + for coro in coros: + try: + runner.run(coro) + except Exception as e: + logger.warning("Error in callback coroutine: %s", repr(e)) + + # Run pending tasks scheduled by coros until they are all done + while pending := asyncio.all_tasks(runner.get_loop()): + runner.run(asyncio.wait(pending)) + else: + # Before Python 3.11 we need to run each coroutine in a new event loop + # as the Runner api is not available. + for coro in coros: + try: + asyncio.run(coro) + except Exception as e: + logger.warning("Error in callback coroutine: %s", repr(e)) + + +async def _ahandle_event_for_handler( + handler: BaseCallbackHandler, + event_name: str, + ignore_condition_name: str | None, + *args: Any, + **kwargs: Any, +) -> None: + try: + if ignore_condition_name is None or not getattr(handler, ignore_condition_name): + event = getattr(handler, event_name) + if inspect.iscoroutinefunction(event): + await event(*args, **kwargs) + elif handler.run_inline: + event(*args, **kwargs) + else: + await asyncio.get_running_loop().run_in_executor( + None, + functools.partial(copy_context().run, event, *args, **kwargs), + ) + except NotImplementedError as e: + if event_name == "on_chat_model_start": + message_strings = [get_buffer_string(m) for m in args[1]] + await _ahandle_event_for_handler( + handler, + "on_llm_start", + "ignore_llm", + args[0], + message_strings, + *args[2:], + **kwargs, + ) + else: + logger.warning( + "NotImplementedError in %s.%s callback: %s", + handler.__class__.__name__, + event_name, + repr(e), + ) + except Exception as e: + logger.warning( + "Error in %s.%s callback: %s", + handler.__class__.__name__, + event_name, + repr(e), + ) + if handler.raise_error: + raise + + +async def ahandle_event( + handlers: list[BaseCallbackHandler], + event_name: str, + ignore_condition_name: str | None, + *args: Any, + **kwargs: Any, +) -> None: + """Async generic event handler for `AsyncCallbackManager`. + + Args: + handlers: The list of handlers that will handle the event. + event_name: The name of the event (e.g., `'on_llm_start'`). + ignore_condition_name: Name of the attribute defined on handler that if `True` + will cause the handler to be skipped for the given event. + *args: The arguments to pass to the event handler. + **kwargs: The keyword arguments to pass to the event handler. + + """ + for handler in [h for h in handlers if h.run_inline]: + await _ahandle_event_for_handler( + handler, event_name, ignore_condition_name, *args, **kwargs + ) + await asyncio.gather( + *( + _ahandle_event_for_handler( + handler, + event_name, + ignore_condition_name, + *args, + **kwargs, + ) + for handler in handlers + if not handler.run_inline + ) + ) + + +class BaseRunManager(RunManagerMixin): + """Base class for run manager (a bound callback manager).""" + + def __init__( + self, + *, + run_id: UUID, + handlers: list[BaseCallbackHandler], + inheritable_handlers: list[BaseCallbackHandler], + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + inheritable_tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inheritable_metadata: dict[str, Any] | None = None, + ) -> None: + """Initialize the run manager. + + Args: + run_id: The ID of the run. + handlers: The list of handlers. + inheritable_handlers: The list of inheritable handlers. + parent_run_id: The ID of the parent run. + tags: The list of tags. + inheritable_tags: The list of inheritable tags. + metadata: The metadata. + inheritable_metadata: The inheritable metadata. + + """ + self.run_id = run_id + self.handlers = handlers + self.inheritable_handlers = inheritable_handlers + self.parent_run_id = parent_run_id + self.tags = tags or [] + self.inheritable_tags = inheritable_tags or [] + self.metadata = metadata or {} + self.inheritable_metadata = inheritable_metadata or {} + + @classmethod + def get_noop_manager(cls) -> Self: + """Return a manager that doesn't perform any operations. + + Returns: + The noop manager. + + """ + return cls( + run_id=uuid7(), + handlers=[], + inheritable_handlers=[], + tags=[], + inheritable_tags=[], + metadata={}, + inheritable_metadata={}, + ) + + +class RunManager(BaseRunManager): + """Synchronous run manager.""" + + def on_text( + self, + text: str, + **kwargs: Any, + ) -> None: + """Run when a text is received. + + Args: + text: The received text. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_text", + None, + text, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_retry( + self, + retry_state: RetryCallState, + **kwargs: Any, + ) -> None: + """Run when a retry is received. + + Args: + retry_state: The retry state. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_retry", + "ignore_retry", + retry_state, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class ParentRunManager(RunManager): + """Synchronous parent run manager.""" + + def get_child(self, tag: str | None = None) -> CallbackManager: + """Get a child callback manager. + + Args: + tag: The tag for the child callback manager. + + Returns: + The child callback manager. + + """ + manager = CallbackManager(handlers=[], parent_run_id=self.run_id) + manager.set_handlers(self.inheritable_handlers) + manager.add_tags(self.inheritable_tags) + manager.add_metadata(self.inheritable_metadata) + if tag is not None: + manager.add_tags([tag], inherit=False) + return manager + + +class AsyncRunManager(BaseRunManager, ABC): + """Async run manager.""" + + @abstractmethod + def get_sync(self) -> RunManager: + """Get the equivalent sync `RunManager`. + + Returns: + The sync `RunManager`. + + """ + + async def on_text( + self, + text: str, + **kwargs: Any, + ) -> None: + """Run when a text is received. + + Args: + text: The received text. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_text", + None, + text, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + async def on_retry( + self, + retry_state: RetryCallState, + **kwargs: Any, + ) -> None: + """Async run when a retry is received. + + Args: + retry_state: The retry state. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_retry", + "ignore_retry", + retry_state, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class AsyncParentRunManager(AsyncRunManager): + """Async parent run manager.""" + + def get_child(self, tag: str | None = None) -> AsyncCallbackManager: + """Get a child callback manager. + + Args: + tag: The tag for the child callback manager. + + Returns: + The child callback manager. + + """ + manager = AsyncCallbackManager(handlers=[], parent_run_id=self.run_id) + manager.set_handlers(self.inheritable_handlers) + manager.add_tags(self.inheritable_tags) + manager.add_metadata(self.inheritable_metadata) + if tag is not None: + manager.add_tags([tag], inherit=False) + return manager + + +class CallbackManagerForLLMRun(RunManager, LLMManagerMixin): + """Callback manager for LLM run.""" + + def on_llm_new_token( + self, + token: str | list[str | dict[str, Any]], + *, + chunk: GenerationChunk | ChatGenerationChunk | None = None, + **kwargs: Any, + ) -> None: + """Run when LLM generates a new token. + + Args: + token: The new token, or a list of content blocks. + chunk: The chunk. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_llm_new_token", + "ignore_llm", + token=token, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + chunk=chunk, + **kwargs, + ) + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Run when LLM ends running. + + Args: + response: The LLM result. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_llm_end", + "ignore_llm", + response, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_llm_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when LLM errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + - response (LLMResult): The response which was generated before + the error occurred. + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_llm_error", + "ignore_llm", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_stream_event(self, event: MessagesData, **kwargs: Any) -> None: + """Run on each protocol event from `stream_events(version="v3")`. + + Args: + event: The protocol event. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_stream_event", + "ignore_llm", + event, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class AsyncCallbackManagerForLLMRun(AsyncRunManager, LLMManagerMixin): + """Async callback manager for LLM run.""" + + def get_sync(self) -> CallbackManagerForLLMRun: + """Get the equivalent sync `RunManager`. + + Returns: + The sync `RunManager`. + + """ + return CallbackManagerForLLMRun( + run_id=self.run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + async def on_llm_new_token( + self, + token: str | list[str | dict[str, Any]], + *, + chunk: GenerationChunk | ChatGenerationChunk | None = None, + **kwargs: Any, + ) -> None: + """Run when LLM generates a new token. + + Args: + token: The new token, or a list of content blocks. + chunk: The chunk. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_llm_new_token", + "ignore_llm", + token, + chunk=chunk, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + @shielded + async def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Run when LLM ends running. + + Args: + response: The LLM result. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_llm_end", + "ignore_llm", + response, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + @shielded + async def on_llm_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when LLM errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + - response (LLMResult): The response which was generated before + the error occurred. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_llm_error", + "ignore_llm", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + async def on_stream_event(self, event: MessagesData, **kwargs: Any) -> None: + """Run on each protocol event from `astream_events(version="v3")`. + + Args: + event: The protocol event. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_stream_event", + "ignore_llm", + event, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class CallbackManagerForChainRun(ParentRunManager, ChainManagerMixin): + """Callback manager for chain run.""" + + def on_chain_end(self, outputs: dict[str, Any] | Any, **kwargs: Any) -> None: + """Run when chain ends running. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_chain_end", + "ignore_chain", + outputs, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_chain_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when chain errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_chain_error", + "ignore_chain", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_agent_action(self, action: AgentAction, **kwargs: Any) -> None: + """Run when agent action is received. + + Args: + action: The agent action. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_agent_action", + "ignore_agent", + action, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None: + """Run when agent finish is received. + + Args: + finish: The agent finish. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_agent_finish", + "ignore_agent", + finish, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class AsyncCallbackManagerForChainRun(AsyncParentRunManager, ChainManagerMixin): + """Async callback manager for chain run.""" + + def get_sync(self) -> CallbackManagerForChainRun: + """Get the equivalent sync `RunManager`. + + Returns: + The sync `RunManager`. + """ + return CallbackManagerForChainRun( + run_id=self.run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @shielded + async def on_chain_end(self, outputs: dict[str, Any] | Any, **kwargs: Any) -> None: + """Run when a chain ends running. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_chain_end", + "ignore_chain", + outputs, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + @shielded + async def on_chain_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when chain errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_chain_error", + "ignore_chain", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + async def on_agent_action(self, action: AgentAction, **kwargs: Any) -> None: + """Run when agent action is received. + + Args: + action: The agent action. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_agent_action", + "ignore_agent", + action, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + async def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None: + """Run when agent finish is received. + + Args: + finish: The agent finish. + **kwargs: Additional keyword arguments. + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_agent_finish", + "ignore_agent", + finish, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class CallbackManagerForToolRun(ParentRunManager, ToolManagerMixin): + """Callback manager for tool run.""" + + def on_tool_end( + self, + output: Any, + **kwargs: Any, + ) -> None: + """Run when the tool ends running. + + Args: + output: The output of the tool. + **kwargs: The keyword arguments to pass to the event handler + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_tool_end", + "ignore_agent", + output, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_tool_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when tool errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_tool_error", + "ignore_agent", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class AsyncCallbackManagerForToolRun(AsyncParentRunManager, ToolManagerMixin): + """Async callback manager for tool run.""" + + def get_sync(self) -> CallbackManagerForToolRun: + """Get the equivalent sync `RunManager`. + + Returns: + The sync `RunManager`. + """ + return CallbackManagerForToolRun( + run_id=self.run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + async def on_tool_end(self, output: Any, **kwargs: Any) -> None: + """Async run when the tool ends running. + + Args: + output: The output of the tool. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_tool_end", + "ignore_agent", + output, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + async def on_tool_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when tool errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_tool_error", + "ignore_agent", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class CallbackManagerForRetrieverRun(ParentRunManager, RetrieverManagerMixin): + """Callback manager for retriever run.""" + + def on_retriever_end( + self, + documents: Sequence[Document], + **kwargs: Any, + ) -> None: + """Run when retriever ends running. + + Args: + documents: The retrieved documents. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_retriever_end", + "ignore_retriever", + documents, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + def on_retriever_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when retriever errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + handle_event( + self.handlers, + "on_retriever_error", + "ignore_retriever", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class AsyncCallbackManagerForRetrieverRun( + AsyncParentRunManager, + RetrieverManagerMixin, +): + """Async callback manager for retriever run.""" + + def get_sync(self) -> CallbackManagerForRetrieverRun: + """Get the equivalent sync `RunManager`. + + Returns: + The sync `RunManager`. + + """ + return CallbackManagerForRetrieverRun( + run_id=self.run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @shielded + async def on_retriever_end( + self, documents: Sequence[Document], **kwargs: Any + ) -> None: + """Run when the retriever ends running. + + Args: + documents: The retrieved documents. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_retriever_end", + "ignore_retriever", + documents, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + @shielded + async def on_retriever_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when retriever errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + if not self.handlers: + return + await ahandle_event( + self.handlers, + "on_retriever_error", + "ignore_retriever", + error, + run_id=self.run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + **kwargs, + ) + + +class CallbackManager(BaseCallbackManager): + """Callback manager for LangChain.""" + + def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + run_id: UUID | None = None, + **kwargs: Any, + ) -> list[CallbackManagerForLLMRun]: + """Run when LLM starts running. + + Args: + serialized: The serialized LLM. + prompts: The list of prompts. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + A callback manager for each prompt as an LLM run. + + """ + managers = [] + for i, prompt in enumerate(prompts): + # Can't have duplicate runs with the same run ID (if provided) + run_id_ = run_id if i == 0 and run_id is not None else uuid7() + handle_event( + self.handlers, + "on_llm_start", + "ignore_llm", + serialized, + [prompt], + run_id=run_id_, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + managers.append( + CallbackManagerForLLMRun( + run_id=run_id_, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + ) + + return managers + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + run_id: UUID | None = None, + **kwargs: Any, + ) -> list[CallbackManagerForLLMRun]: + """Run when chat model starts running. + + Args: + serialized: The serialized LLM. + messages: The list of messages. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + A callback manager for each list of messages as an LLM run. + + """ + managers = [] + for message_list in messages: + if run_id is not None: + run_id_ = run_id + run_id = None + else: + run_id_ = uuid7() + handle_event( + self.handlers, + "on_chat_model_start", + "ignore_chat_model", + serialized, + [message_list], + run_id=run_id_, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + managers.append( + CallbackManagerForLLMRun( + run_id=run_id_, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + ) + + return managers + + def on_chain_start( + self, + serialized: dict[str, Any] | None, + inputs: dict[str, Any] | Any, + run_id: UUID | None = None, + **kwargs: Any, + ) -> CallbackManagerForChainRun: + """Run when chain starts running. + + Args: + serialized: The serialized chain. + inputs: The inputs to the chain. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + The callback manager for the chain run. + + """ + if run_id is None: + run_id = uuid7() + handle_event( + self.handlers, + "on_chain_start", + "ignore_chain", + serialized, + inputs, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + return CallbackManagerForChainRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @override + def on_tool_start( + self, + serialized: dict[str, Any] | None, + input_str: str, + run_id: UUID | None = None, + parent_run_id: UUID | None = None, + inputs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> CallbackManagerForToolRun: + """Run when tool starts running. + + Args: + serialized: Serialized representation of the tool. + input_str: The input to the tool as a string. + + Non-string inputs are cast to strings. + run_id: ID for the run. + parent_run_id: The ID of the parent run. + inputs: The original input to the tool if provided. + + Recommended for usage instead of input_str when the original input is + needed. + + If provided, the inputs are expected to be formatted as a dict. The keys + will correspond to the named-arguments in the tool. + **kwargs: The keyword arguments to pass to the event handler + + Returns: + The callback manager for the tool run. + + """ + if run_id is None: + run_id = uuid7() + + handle_event( + self.handlers, + "on_tool_start", + "ignore_agent", + serialized, + input_str, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + inputs=inputs, + **kwargs, + ) + + return CallbackManagerForToolRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @override + def on_retriever_start( + self, + serialized: dict[str, Any] | None, + query: str, + run_id: UUID | None = None, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> CallbackManagerForRetrieverRun: + """Run when the retriever starts running. + + Args: + serialized: The serialized retriever. + query: The query. + run_id: The ID of the run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + + Returns: + The callback manager for the retriever run. + """ + if run_id is None: + run_id = uuid7() + + handle_event( + self.handlers, + "on_retriever_start", + "ignore_retriever", + serialized, + query, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + return CallbackManagerForRetrieverRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + def on_custom_event( + self, + name: str, + data: Any, + run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + """Dispatch an adhoc event to the handlers (async version). + + This event should NOT be used in any internal LangChain code. The event is meant + specifically for users of the library to dispatch custom events that are + tailored to their application. + + Args: + name: The name of the adhoc event. + data: The data for the adhoc event. + run_id: The ID of the run. + + Raises: + ValueError: If additional keyword arguments are passed. + """ + if not self.handlers: + return + if kwargs: + msg = ( + "The dispatcher API does not accept additional keyword arguments." + "Please do not pass any additional keyword arguments, instead " + "include them in the data field." + ) + raise ValueError(msg) + if run_id is None: + run_id = uuid7() + + handle_event( + self.handlers, + "on_custom_event", + "ignore_custom_event", + name, + data, + run_id=run_id, + tags=self.tags, + metadata=self.metadata, + ) + + @classmethod + def configure( + cls, + inheritable_callbacks: Callbacks = None, + local_callbacks: Callbacks = None, + verbose: bool = False, # noqa: FBT001,FBT002 + inheritable_tags: list[str] | None = None, + local_tags: list[str] | None = None, + inheritable_metadata: dict[str, Any] | None = None, + local_metadata: dict[str, Any] | None = None, + *, + langsmith_inheritable_metadata: Mapping[str, Any] | None = None, + langsmith_inheritable_tags: list[str] | None = None, + ) -> CallbackManager: + """Configure the callback manager. + + Args: + inheritable_callbacks: The inheritable callbacks. + local_callbacks: The local callbacks. + verbose: Whether to enable verbose mode. + inheritable_tags: The inheritable tags. + local_tags: The local tags. + inheritable_metadata: The inheritable metadata. + local_metadata: The local metadata. + langsmith_inheritable_metadata: Default inheritable metadata applied + to any `LangChainTracer` handlers via `set_defaults`. + langsmith_inheritable_tags: Default inheritable tags applied to any + `LangChainTracer` handlers via `set_defaults`. + + Returns: + The configured callback manager. + """ + return _configure( + cls, + inheritable_callbacks, + local_callbacks, + inheritable_tags, + local_tags, + inheritable_metadata, + local_metadata, + verbose=verbose, + langsmith_inheritable_metadata=langsmith_inheritable_metadata, + langsmith_inheritable_tags=langsmith_inheritable_tags, + ) + + +class CallbackManagerForChainGroup(CallbackManager): + """Callback manager for the chain group.""" + + def __init__( + self, + handlers: list[BaseCallbackHandler], + inheritable_handlers: list[BaseCallbackHandler] | None = None, + parent_run_id: UUID | None = None, + *, + parent_run_manager: CallbackManagerForChainRun, + **kwargs: Any, + ) -> None: + """Initialize the callback manager. + + Args: + handlers: The list of handlers. + inheritable_handlers: The list of inheritable handlers. + parent_run_id: The ID of the parent run. + parent_run_manager: The parent run manager. + **kwargs: Additional keyword arguments. + + """ + super().__init__( + handlers, + inheritable_handlers, + parent_run_id, + **kwargs, + ) + self.parent_run_manager = parent_run_manager + self.ended = False + + @override + def copy(self) -> CallbackManagerForChainGroup: + return self.__class__( + handlers=self.handlers.copy(), + inheritable_handlers=self.inheritable_handlers.copy(), + parent_run_id=self.parent_run_id, + tags=self.tags.copy(), + inheritable_tags=self.inheritable_tags.copy(), + metadata=self.metadata.copy(), + inheritable_metadata=self.inheritable_metadata.copy(), + parent_run_manager=self.parent_run_manager, + ) + + def merge( + self: CallbackManagerForChainGroup, other: BaseCallbackManager + ) -> CallbackManagerForChainGroup: + """Merge the group callback manager with another callback manager. + + Overwrites the merge method in the base class to ensure that the parent run + manager is preserved. Keeps the `parent_run_manager` from the current object. + + Returns: + A copy of the current object with the handlers, tags, and other attributes + merged from the other object. + + Example: + ```python + # Merging two callback managers + from langchain_core.callbacks.manager import ( + CallbackManager, + trace_as_chain_group, + ) + from langchain_core.callbacks.stdout import StdOutCallbackHandler + + manager = CallbackManager(handlers=[StdOutCallbackHandler()], tags=["tag2"]) + with trace_as_chain_group("My Group Name", tags=["tag1"]) as group_manager: + merged_manager = group_manager.merge(manager) + print(type(merged_manager)) + # + + print(merged_manager.handlers) + # [ + # , + # , + # ] + + print(merged_manager.tags) + # ['tag2', 'tag1'] + ``` + """ # noqa: E501 + manager = self.__class__( + parent_run_id=self.parent_run_id or other.parent_run_id, + handlers=[], + inheritable_handlers=[], + tags=list(set(self.tags + other.tags)), + inheritable_tags=list(set(self.inheritable_tags + other.inheritable_tags)), + metadata={ + **self.metadata, + **other.metadata, + }, + parent_run_manager=self.parent_run_manager, + ) + + handlers = self.handlers + other.handlers + inheritable_handlers = self.inheritable_handlers + other.inheritable_handlers + + for handler in handlers: + manager.add_handler(handler) + + for handler in inheritable_handlers: + manager.add_handler(handler, inherit=True) + return manager + + def on_chain_end(self, outputs: dict[str, Any] | Any, **kwargs: Any) -> None: + """Run when traced chain group ends. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + + """ + self.ended = True + return self.parent_run_manager.on_chain_end(outputs, **kwargs) + + def on_chain_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when chain errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + + """ + self.ended = True + return self.parent_run_manager.on_chain_error(error, **kwargs) + + +class AsyncCallbackManager(BaseCallbackManager): + """Async callback manager that handles callbacks from LangChain.""" + + @property + def is_async(self) -> bool: + """Return whether the handler is async.""" + return True + + async def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + run_id: UUID | None = None, + **kwargs: Any, + ) -> list[AsyncCallbackManagerForLLMRun]: + """Run when LLM starts running. + + Args: + serialized: The serialized LLM. + prompts: The list of prompts. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + The list of async callback managers, one for each LLM run corresponding to + each prompt. + """ + inline_tasks = [] + non_inline_tasks = [] + inline_handlers = [handler for handler in self.handlers if handler.run_inline] + non_inline_handlers = [ + handler for handler in self.handlers if not handler.run_inline + ] + managers = [] + + for prompt in prompts: + if run_id is not None: + run_id_ = run_id + run_id = None + else: + run_id_ = uuid7() + + if inline_handlers: + inline_tasks.append( + ahandle_event( + inline_handlers, + "on_llm_start", + "ignore_llm", + serialized, + [prompt], + run_id=run_id_, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + ) + else: + non_inline_tasks.append( + ahandle_event( + non_inline_handlers, + "on_llm_start", + "ignore_llm", + serialized, + [prompt], + run_id=run_id_, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + ) + + managers.append( + AsyncCallbackManagerForLLMRun( + run_id=run_id_, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + ) + + # Run inline tasks sequentially + for inline_task in inline_tasks: + await inline_task + + # Run non-inline tasks concurrently + if non_inline_tasks: + await asyncio.gather(*non_inline_tasks) + + return managers + + async def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + run_id: UUID | None = None, + **kwargs: Any, + ) -> list[AsyncCallbackManagerForLLMRun]: + """Async run when LLM starts running. + + Args: + serialized: The serialized LLM. + messages: The list of messages. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + The list of async callback managers, one for each LLM run corresponding to + each inner message list. + """ + inline_tasks = [] + non_inline_tasks = [] + managers = [] + + for message_list in messages: + if run_id is not None: + run_id_ = run_id + run_id = None + else: + run_id_ = uuid7() + + for handler in self.handlers: + task = ahandle_event( + [handler], + "on_chat_model_start", + "ignore_chat_model", + serialized, + [message_list], + run_id=run_id_, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + if handler.run_inline: + inline_tasks.append(task) + else: + non_inline_tasks.append(task) + + managers.append( + AsyncCallbackManagerForLLMRun( + run_id=run_id_, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + ) + + # Run inline tasks sequentially + for task in inline_tasks: + await task + + # Run non-inline tasks concurrently + if non_inline_tasks: + await asyncio.gather(*non_inline_tasks) + + return managers + + async def on_chain_start( + self, + serialized: dict[str, Any] | None, + inputs: dict[str, Any] | Any, + run_id: UUID | None = None, + **kwargs: Any, + ) -> AsyncCallbackManagerForChainRun: + """Async run when chain starts running. + + Args: + serialized: The serialized chain. + inputs: The inputs to the chain. + run_id: The ID of the run. + **kwargs: Additional keyword arguments. + + Returns: + The async callback manager for the chain run. + """ + if run_id is None: + run_id = uuid7() + + await ahandle_event( + self.handlers, + "on_chain_start", + "ignore_chain", + serialized, + inputs, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + return AsyncCallbackManagerForChainRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @override + async def on_tool_start( + self, + serialized: dict[str, Any] | None, + input_str: str, + run_id: UUID | None = None, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> AsyncCallbackManagerForToolRun: + """Run when the tool starts running. + + Args: + serialized: The serialized tool. + input_str: The input to the tool. + run_id: The ID of the run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + + Returns: + The async callback manager for the tool run. + """ + if run_id is None: + run_id = uuid7() + + await ahandle_event( + self.handlers, + "on_tool_start", + "ignore_agent", + serialized, + input_str, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + return AsyncCallbackManagerForToolRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + async def on_custom_event( + self, + name: str, + data: Any, + run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + """Dispatch an adhoc event to the handlers (async version). + + This event should NOT be used in any internal LangChain code. The event is meant + specifically for users of the library to dispatch custom events that are + tailored to their application. + + Args: + name: The name of the adhoc event. + data: The data for the adhoc event. + run_id: The ID of the run. + + Raises: + ValueError: If additional keyword arguments are passed. + """ + if not self.handlers: + return + if run_id is None: + run_id = uuid7() + + if kwargs: + msg = ( + "The dispatcher API does not accept additional keyword arguments." + "Please do not pass any additional keyword arguments, instead " + "include them in the data field." + ) + raise ValueError(msg) + await ahandle_event( + self.handlers, + "on_custom_event", + "ignore_custom_event", + name, + data, + run_id=run_id, + tags=self.tags, + metadata=self.metadata, + ) + + @override + async def on_retriever_start( + self, + serialized: dict[str, Any] | None, + query: str, + run_id: UUID | None = None, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> AsyncCallbackManagerForRetrieverRun: + """Run when the retriever starts running. + + Args: + serialized: The serialized retriever. + query: The query. + run_id: The ID of the run. + parent_run_id: The ID of the parent run. + **kwargs: Additional keyword arguments. + + Returns: + The async callback manager for the retriever run. + """ + if run_id is None: + run_id = uuid7() + + await ahandle_event( + self.handlers, + "on_retriever_start", + "ignore_retriever", + serialized, + query, + run_id=run_id, + parent_run_id=self.parent_run_id, + tags=self.tags, + metadata=self.metadata, + **kwargs, + ) + + return AsyncCallbackManagerForRetrieverRun( + run_id=run_id, + handlers=self.handlers, + inheritable_handlers=self.inheritable_handlers, + parent_run_id=self.parent_run_id, + tags=self.tags, + inheritable_tags=self.inheritable_tags, + metadata=self.metadata, + inheritable_metadata=self.inheritable_metadata, + ) + + @classmethod + def configure( + cls, + inheritable_callbacks: Callbacks = None, + local_callbacks: Callbacks = None, + verbose: bool = False, # noqa: FBT001,FBT002 + inheritable_tags: list[str] | None = None, + local_tags: list[str] | None = None, + inheritable_metadata: dict[str, Any] | None = None, + local_metadata: dict[str, Any] | None = None, + *, + langsmith_inheritable_metadata: Mapping[str, Any] | None = None, + langsmith_inheritable_tags: list[str] | None = None, + ) -> AsyncCallbackManager: + """Configure the async callback manager. + + Args: + inheritable_callbacks: The inheritable callbacks. + local_callbacks: The local callbacks. + verbose: Whether to enable verbose mode. + inheritable_tags: The inheritable tags. + local_tags: The local tags. + inheritable_metadata: The inheritable metadata. + local_metadata: The local metadata. + langsmith_inheritable_metadata: Default inheritable metadata applied + to any `LangChainTracer` handlers via `set_defaults`. + langsmith_inheritable_tags: Default inheritable tags applied to any + `LangChainTracer` handlers via `set_defaults`. + + Returns: + The configured async callback manager. + """ + return _configure( + cls, + inheritable_callbacks, + local_callbacks, + inheritable_tags, + local_tags, + inheritable_metadata, + local_metadata, + verbose=verbose, + langsmith_inheritable_metadata=langsmith_inheritable_metadata, + langsmith_inheritable_tags=langsmith_inheritable_tags, + ) + + +class AsyncCallbackManagerForChainGroup(AsyncCallbackManager): + """Async callback manager for the chain group.""" + + def __init__( + self, + handlers: list[BaseCallbackHandler], + inheritable_handlers: list[BaseCallbackHandler] | None = None, + parent_run_id: UUID | None = None, + *, + parent_run_manager: AsyncCallbackManagerForChainRun, + **kwargs: Any, + ) -> None: + """Initialize the async callback manager. + + Args: + handlers: The list of handlers. + inheritable_handlers: The list of inheritable handlers. + parent_run_id: The ID of the parent run. + parent_run_manager: The parent run manager. + **kwargs: Additional keyword arguments. + """ + super().__init__( + handlers, + inheritable_handlers, + parent_run_id, + **kwargs, + ) + self.parent_run_manager = parent_run_manager + self.ended = False + + def copy(self) -> AsyncCallbackManagerForChainGroup: + """Return a copy the async callback manager.""" + return self.__class__( + handlers=self.handlers.copy(), + inheritable_handlers=self.inheritable_handlers.copy(), + parent_run_id=self.parent_run_id, + tags=self.tags.copy(), + inheritable_tags=self.inheritable_tags.copy(), + metadata=self.metadata.copy(), + inheritable_metadata=self.inheritable_metadata.copy(), + parent_run_manager=self.parent_run_manager, + ) + + def merge( + self: AsyncCallbackManagerForChainGroup, other: BaseCallbackManager + ) -> AsyncCallbackManagerForChainGroup: + """Merge the group callback manager with another callback manager. + + Overwrites the merge method in the base class to ensure that the parent run + manager is preserved. Keeps the `parent_run_manager` from the current object. + + Returns: + A copy of the current `AsyncCallbackManagerForChainGroup` with the handlers, + tags, etc. of the other callback manager merged in. + + Example: + ```python + # Merging two callback managers + from langchain_core.callbacks.manager import ( + CallbackManager, + atrace_as_chain_group, + ) + from langchain_core.callbacks.stdout import StdOutCallbackHandler + + manager = CallbackManager(handlers=[StdOutCallbackHandler()], tags=["tag2"]) + async with atrace_as_chain_group( + "My Group Name", tags=["tag1"] + ) as group_manager: + merged_manager = group_manager.merge(manager) + print(type(merged_manager)) + # + + print(merged_manager.handlers) + # [ + # , + # , + # ] + + print(merged_manager.tags) + # ['tag2', 'tag1'] + ``` + """ # noqa: E501 + manager = self.__class__( + parent_run_id=self.parent_run_id or other.parent_run_id, + handlers=[], + inheritable_handlers=[], + tags=list(set(self.tags + other.tags)), + inheritable_tags=list(set(self.inheritable_tags + other.inheritable_tags)), + metadata={ + **self.metadata, + **other.metadata, + }, + parent_run_manager=self.parent_run_manager, + ) + + handlers = self.handlers + other.handlers + inheritable_handlers = self.inheritable_handlers + other.inheritable_handlers + + for handler in handlers: + manager.add_handler(handler) + + for handler in inheritable_handlers: + manager.add_handler(handler, inherit=True) + return manager + + async def on_chain_end(self, outputs: dict[str, Any] | Any, **kwargs: Any) -> None: + """Run when traced chain group ends. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + """ + self.ended = True + await self.parent_run_manager.on_chain_end(outputs, **kwargs) + + async def on_chain_error( + self, + error: BaseException, + **kwargs: Any, + ) -> None: + """Run when chain errors. + + Args: + error: The error. + **kwargs: Additional keyword arguments. + """ + self.ended = True + await self.parent_run_manager.on_chain_error(error, **kwargs) + + +T = TypeVar("T", CallbackManager, AsyncCallbackManager) + + +def _configure( + callback_manager_cls: type[T], + inheritable_callbacks: Callbacks = None, + local_callbacks: Callbacks = None, + inheritable_tags: list[str] | None = None, + local_tags: list[str] | None = None, + inheritable_metadata: dict[str, Any] | None = None, + local_metadata: dict[str, Any] | None = None, + *, + verbose: bool = False, + langsmith_inheritable_metadata: Mapping[str, Any] | None = None, + langsmith_inheritable_tags: list[str] | None = None, +) -> T: + """Configure the callback manager. + + Args: + callback_manager_cls: The callback manager class. + inheritable_callbacks: The inheritable callbacks. + local_callbacks: The local callbacks. + inheritable_tags: The inheritable tags. + local_tags: The local tags. + inheritable_metadata: The inheritable metadata. + local_metadata: The local metadata. + verbose: Whether to enable verbose mode. + langsmith_inheritable_metadata: Default inheritable metadata applied to + any `LangChainTracer` handlers via `set_defaults`. + langsmith_inheritable_tags: Default inheritable tags applied to any + `LangChainTracer` handlers via `set_defaults`. + + Raises: + RuntimeError: If `LANGCHAIN_TRACING` is set but `LANGCHAIN_TRACING_V2` is not. + + Returns: + The configured callback manager. + """ + # Deferred to avoid importing langsmith at module level (~132ms). + from langsmith.run_helpers import get_tracing_context # noqa: PLC0415 + + from langchain_core.tracers.context import ( # noqa: PLC0415 + _configure_hooks, + _get_tracer_project, + _tracing_v2_is_enabled, + tracing_v2_callback_var, + ) + from langchain_core.tracers.langchain import LangChainTracer # noqa: PLC0415 + from langchain_core.tracers.stdout import ConsoleCallbackHandler # noqa: PLC0415 + + tracing_context = get_tracing_context() + tracing_metadata = tracing_context["metadata"] + tracing_tags = tracing_context["tags"] + run_tree: Run | None = tracing_context["parent"] + parent_run_id = None if run_tree is None else run_tree.id + callback_manager = callback_manager_cls( + handlers=[], + parent_run_id=parent_run_id, + ) + if inheritable_callbacks or local_callbacks: + if isinstance(inheritable_callbacks, list) or inheritable_callbacks is None: + inheritable_callbacks_ = inheritable_callbacks or [] + callback_manager = callback_manager_cls( + handlers=inheritable_callbacks_.copy(), + inheritable_handlers=inheritable_callbacks_.copy(), + parent_run_id=parent_run_id, + ) + else: + parent_run_id_ = inheritable_callbacks.parent_run_id + # Break ties between the external tracing context and inherited context + if parent_run_id is not None and ( + parent_run_id_ is None + # If the LC parent has already been reflected + # in the run tree, we know the run_tree is either the + # same parent or a child of the parent. + or (run_tree and str(parent_run_id_) in run_tree.dotted_order) + ): + parent_run_id_ = parent_run_id + # Otherwise, we assume the LC context has progressed + # beyond the run tree and we should not inherit the parent. + callback_manager = callback_manager_cls( + handlers=inheritable_callbacks.handlers.copy(), + inheritable_handlers=inheritable_callbacks.inheritable_handlers.copy(), + parent_run_id=parent_run_id_, + tags=inheritable_callbacks.tags.copy(), + inheritable_tags=inheritable_callbacks.inheritable_tags.copy(), + metadata=inheritable_callbacks.metadata.copy(), + inheritable_metadata=inheritable_callbacks.inheritable_metadata.copy(), + ) + local_handlers_ = ( + local_callbacks + if isinstance(local_callbacks, list) + else (local_callbacks.handlers if local_callbacks else []) + ) + for handler in local_handlers_: + callback_manager.add_handler(handler, inherit=False) + if inheritable_tags or local_tags: + callback_manager.add_tags(inheritable_tags or []) + callback_manager.add_tags(local_tags or [], inherit=False) + if inheritable_metadata or local_metadata: + callback_manager.add_metadata(inheritable_metadata or {}) + callback_manager.add_metadata(local_metadata or {}, inherit=False) + if tracing_tags: + callback_manager.add_tags(tracing_tags.copy()) + + v1_tracing_enabled_ = env_var_is_set("LANGCHAIN_TRACING") or env_var_is_set( + "LANGCHAIN_HANDLER" + ) + + tracer_v2 = tracing_v2_callback_var.get() + tracing_v2_enabled_ = _tracing_v2_is_enabled() + + if v1_tracing_enabled_ and not tracing_v2_enabled_: + # if both are enabled, can silently ignore the v1 tracer + msg = ( + "Tracing using LangChainTracerV1 is no longer supported. " + "Please set the LANGCHAIN_TRACING_V2 environment variable to enable " + "tracing instead." + ) + raise RuntimeError(msg) + + tracer_project = _get_tracer_project() + debug = _get_debug() + if verbose or debug or tracing_v2_enabled_: + if verbose and not any( + isinstance(handler, StdOutCallbackHandler) + for handler in callback_manager.handlers + ): + if debug: + pass + else: + callback_manager.add_handler(StdOutCallbackHandler(), inherit=False) + if debug and not any( + isinstance(handler, ConsoleCallbackHandler) + for handler in callback_manager.handlers + ): + callback_manager.add_handler(ConsoleCallbackHandler()) + if tracing_v2_enabled_ and not any( + isinstance(handler, LangChainTracer) + for handler in callback_manager.handlers + ): + if tracer_v2: + callback_manager.add_handler(tracer_v2) + else: + try: + handler = LangChainTracer( + project_name=tracer_project, + client=( + run_tree.client + if run_tree is not None + else tracing_context["client"] + ), + tags=tracing_tags, + metadata=tracing_metadata, + ) + callback_manager.add_handler(handler) + except Exception as e: + logger.warning( + "Unable to load requested LangChainTracer." + " To disable this warning," + " unset the LANGCHAIN_TRACING_V2 environment variables.\n" + "%s", + repr(e), + ) + if run_tree is not None: + for handler in callback_manager.handlers: + if isinstance(handler, LangChainTracer): + handler.order_map[run_tree.id] = ( + run_tree.trace_id, + run_tree.dotted_order, + ) + run_id_str = str(run_tree.id) + if run_id_str not in handler.run_map: + handler.run_map[run_id_str] = run_tree + handler._external_run_ids.setdefault( # noqa: SLF001 + run_id_str, 0 + ) + for var, inheritable, handler_class, env_var in _configure_hooks: + create_one = ( + env_var is not None + and env_var_is_set(env_var) + and handler_class is not None + ) + if var.get() is not None or create_one: + var_handler = ( + var.get() or cast("type[BaseCallbackHandler]", handler_class)() + ) + if handler_class is None: + if not any( + handler is var_handler # direct pointer comparison + for handler in callback_manager.handlers + ): + callback_manager.add_handler(var_handler, inheritable) + elif not any( + isinstance(handler, handler_class) + for handler in callback_manager.handlers + ): + callback_manager.add_handler(var_handler, inheritable) + + if tracing_metadata: + langsmith_inheritable_metadata = { + **tracing_metadata, + **(langsmith_inheritable_metadata or {}), + } + + if langsmith_inheritable_metadata or langsmith_inheritable_tags: + callback_manager.handlers = [ + handler.copy_with_metadata_defaults( + metadata=langsmith_inheritable_metadata, + tags=langsmith_inheritable_tags, + ) + if isinstance(handler, LangChainTracer) + else handler + for handler in callback_manager.handlers + ] + callback_manager.inheritable_handlers = [ + handler.copy_with_metadata_defaults( + metadata=langsmith_inheritable_metadata, + tags=langsmith_inheritable_tags, + ) + if isinstance(handler, LangChainTracer) + else handler + for handler in callback_manager.inheritable_handlers + ] + return callback_manager + + +async def adispatch_custom_event( + name: str, data: Any, *, config: RunnableConfig | None = None +) -> None: + """Dispatch an adhoc event to the handlers. + + Args: + name: The name of the adhoc event. + data: The data for the adhoc event. + + Free form data. Ideally should be JSON serializable to avoid serialization + issues downstream, but this is not enforced. + config: Optional config object. + + Mirrors the async API but not strictly needed. + + Raises: + RuntimeError: If there is no parent run ID available to associate the event + with. + + Example: + ```python + from langchain_core.callbacks import ( + AsyncCallbackHandler, + adispatch_custom_event + ) + from langchain_core.runnable import RunnableLambda + + class CustomCallbackManager(AsyncCallbackHandler): + async def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: UUID, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + print(f"Received custom event: {name} with data: {data}") + + callback = CustomCallbackManager() + + async def foo(inputs): + await adispatch_custom_event("my_event", {"bar": "buzz}) + return inputs + + foo_ = RunnableLambda(foo) + await foo_.ainvoke({"a": "1"}, {"callbacks": [CustomCallbackManager()]}) + ``` + + Example: Use with astream events + + ```python + from langchain_core.callbacks import ( + AsyncCallbackHandler, + adispatch_custom_event + ) + from langchain_core.runnable import RunnableLambda + + class CustomCallbackManager(AsyncCallbackHandler): + async def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: UUID, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + print(f"Received custom event: {name} with data: {data}") + + callback = CustomCallbackManager() + + async def foo(inputs): + await adispatch_custom_event("event_type_1", {"bar": "buzz}) + await adispatch_custom_event("event_type_2", 5) + return inputs + + foo_ = RunnableLambda(foo) + + async for event in foo_.ainvoke_stream( + {"a": "1"}, + version="v2", + config={"callbacks": [CustomCallbackManager()]} + ): + print(event) + ``` + + !!! warning + + If using python 3.10 and async, you MUST specify the `config` parameter or the + function will raise an error. This is due to a limitation in asyncio for python + 3.10 that prevents LangChain from automatically propagating the config object on + the user's behalf. + """ + # Import locally to prevent circular imports. + from langchain_core.runnables.config import ( # noqa: PLC0415 + ensure_config, + get_async_callback_manager_for_config, + ) + + config = ensure_config(config) + callback_manager = get_async_callback_manager_for_config(config) + # We want to get the callback manager for the parent run. + # This is a work-around for now to be able to dispatch adhoc events from + # within a tool or a lambda and have the metadata events associated + # with the parent run rather than have a new run id generated for each. + if callback_manager.parent_run_id is None: + msg = ( + "Unable to dispatch an adhoc event without a parent run id." + "This function can only be called from within an existing run (e.g.," + "inside a tool or a RunnableLambda or a RunnableGenerator.)" + "If you are doing that and still seeing this error, try explicitly" + "passing the config parameter to this function." + ) + raise RuntimeError(msg) + + await callback_manager.on_custom_event( + name, + data, + run_id=callback_manager.parent_run_id, + ) + + +def dispatch_custom_event( + name: str, data: Any, *, config: RunnableConfig | None = None +) -> None: + """Dispatch an adhoc event. + + Args: + name: The name of the adhoc event. + data: The data for the adhoc event. + + Free form data. Ideally should be JSON serializable to avoid serialization + issues downstream, but this is not enforced. + config: Optional config object. + + Mirrors the async API but not strictly needed. + + Raises: + RuntimeError: If there is no parent run ID available to associate the event + with. + + Example: + ```python + from langchain_core.callbacks import BaseCallbackHandler + from langchain_core.callbacks import dispatch_custom_event + from langchain_core.runnable import RunnableLambda + + class CustomCallbackManager(BaseCallbackHandler): + def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: UUID, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + print(f"Received custom event: {name} with data: {data}") + + def foo(inputs): + dispatch_custom_event("my_event", {"bar": "buzz}) + return inputs + + foo_ = RunnableLambda(foo) + foo_.invoke({"a": "1"}, {"callbacks": [CustomCallbackManager()]}) + ``` + """ + # Import locally to prevent circular imports. + from langchain_core.runnables.config import ( # noqa: PLC0415 + ensure_config, + get_callback_manager_for_config, + ) + + config = ensure_config(config) + callback_manager = get_callback_manager_for_config(config) + # We want to get the callback manager for the parent run. + # This is a work-around for now to be able to dispatch adhoc events from + # within a tool or a lambda and have the metadata events associated + # with the parent run rather than have a new run id generated for each. + if callback_manager.parent_run_id is None: + msg = ( + "Unable to dispatch an adhoc event without a parent run id." + "This function can only be called from within an existing run (e.g.," + "inside a tool or a RunnableLambda or a RunnableGenerator.)" + "If you are doing that and still seeing this error, try explicitly" + "passing the config parameter to this function." + ) + raise RuntimeError(msg) + callback_manager.on_custom_event( + name, + data, + run_id=callback_manager.parent_run_id, + ) + + +@functools.lru_cache(maxsize=1) +def _executor() -> ThreadPoolExecutor: + # If the user is specifying ASYNC callback handlers to be run from a + # SYNC context, and an event loop is already running, + # we cannot submit the coroutine to the running loop, because it + # would result in a deadlock. Instead we have to schedule them + # on a background thread. To avoid creating & shutting down + # a new executor every time, we use a lazily-created, shared + # executor. If you're using regular langgchain parallelism (batch, etc.) + # you'd only ever need 1 worker, but we permit more for now to reduce the chance + # of slowdown if you are mixing with your own executor. + cutie = ThreadPoolExecutor(max_workers=10) + atexit.register(cutie.shutdown, wait=True) + return cutie diff --git a/libs/core/langchain_core/callbacks/stdout.py b/libs/core/langchain_core/callbacks/stdout.py new file mode 100644 index 0000000..8cfce2c --- /dev/null +++ b/libs/core/langchain_core/callbacks/stdout.py @@ -0,0 +1,123 @@ +"""Callback handler that prints to std out.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from typing_extensions import override + +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.utils import print_text + +if TYPE_CHECKING: + from langchain_core.agents import AgentAction, AgentFinish + + +class StdOutCallbackHandler(BaseCallbackHandler): + """Callback handler that prints to std out.""" + + def __init__(self, color: str | None = None) -> None: + """Initialize callback handler. + + Args: + color: The color to use for the text. + """ + self.color = color + + @override + def on_chain_start( + self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any + ) -> None: + """Print out that we are entering a chain. + + Args: + serialized: The serialized chain. + inputs: The inputs to the chain. + **kwargs: Additional keyword arguments. + """ + if "name" in kwargs: + name = kwargs["name"] + elif serialized: + name = serialized.get("name", serialized.get("id", [""])[-1]) + else: + name = "" + print(f"\n\n\033[1m> Entering new {name} chain...\033[0m") # noqa: T201 + + @override + def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None: + """Print out that we finished a chain. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + """ + print("\n\033[1m> Finished chain.\033[0m") # noqa: T201 + + @override + def on_agent_action( + self, action: AgentAction, color: str | None = None, **kwargs: Any + ) -> Any: + """Run on agent action. + + Args: + action: The agent action. + color: The color to use for the text. + **kwargs: Additional keyword arguments. + """ + print_text(action.log, color=color or self.color) + + @override + def on_tool_end( + self, + output: Any, + color: str | None = None, + observation_prefix: str | None = None, + llm_prefix: str | None = None, + **kwargs: Any, + ) -> None: + """If not the final action, print out observation. + + Args: + output: The output to print. + color: The color to use for the text. + observation_prefix: The observation prefix. + llm_prefix: The LLM prefix. + **kwargs: Additional keyword arguments. + """ + output = str(output) + if observation_prefix is not None: + print_text(f"\n{observation_prefix}") + print_text(output, color=color or self.color) + if llm_prefix is not None: + print_text(f"\n{llm_prefix}") + + @override + def on_text( + self, + text: str, + color: str | None = None, + end: str = "", + **kwargs: Any, + ) -> None: + """Run when the agent ends. + + Args: + text: The text to print. + color: The color to use for the text. + end: The end character to use. + **kwargs: Additional keyword arguments. + """ + print_text(text, color=color or self.color, end=end) + + @override + def on_agent_finish( + self, finish: AgentFinish, color: str | None = None, **kwargs: Any + ) -> None: + """Run on the agent end. + + Args: + finish: The agent finish. + color: The color to use for the text. + **kwargs: Additional keyword arguments. + """ + print_text(finish.log, color=color or self.color, end="\n") diff --git a/libs/core/langchain_core/callbacks/streaming_stdout.py b/libs/core/langchain_core/callbacks/streaming_stdout.py new file mode 100644 index 0000000..5cba233 --- /dev/null +++ b/libs/core/langchain_core/callbacks/streaming_stdout.py @@ -0,0 +1,154 @@ +"""Callback Handler streams to stdout on new llm token.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from typing_extensions import override + +from langchain_core.callbacks.base import BaseCallbackHandler + +if TYPE_CHECKING: + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.messages import BaseMessage + from langchain_core.outputs import LLMResult + + +class StreamingStdOutCallbackHandler(BaseCallbackHandler): + """Callback handler for streaming. + + !!! warning "Only works with LLMs that support streaming." + """ + + def on_llm_start( + self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any + ) -> None: + """Run when LLM starts running. + + Args: + serialized: The serialized LLM. + prompts: The prompts to run. + **kwargs: Additional keyword arguments. + """ + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + **kwargs: Any, + ) -> None: + """Run when LLM starts running. + + Args: + serialized: The serialized LLM. + messages: The messages to run. + **kwargs: Additional keyword arguments. + """ + + @override + def on_llm_new_token( + self, token: str | list[str | dict[str, Any]], **kwargs: Any + ) -> None: + """Run on new LLM token. Only available when streaming is enabled. + + Args: + token: The new token, or a list of content blocks. + **kwargs: Additional keyword arguments. + """ + sys.stdout.write(str(token)) + sys.stdout.flush() + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Run when LLM ends running. + + Args: + response: The response from the LLM. + **kwargs: Additional keyword arguments. + """ + + def on_llm_error(self, error: BaseException, **kwargs: Any) -> None: + """Run when LLM errors. + + Args: + error: The error that occurred. + **kwargs: Additional keyword arguments. + """ + + def on_chain_start( + self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any + ) -> None: + """Run when a chain starts running. + + Args: + serialized: The serialized chain. + inputs: The inputs to the chain. + **kwargs: Additional keyword arguments. + """ + + def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None: + """Run when a chain ends running. + + Args: + outputs: The outputs of the chain. + **kwargs: Additional keyword arguments. + """ + + def on_chain_error(self, error: BaseException, **kwargs: Any) -> None: + """Run when chain errors. + + Args: + error: The error that occurred. + **kwargs: Additional keyword arguments. + """ + + def on_tool_start( + self, serialized: dict[str, Any], input_str: str, **kwargs: Any + ) -> None: + """Run when the tool starts running. + + Args: + serialized: The serialized tool. + input_str: The input string. + **kwargs: Additional keyword arguments. + """ + + def on_agent_action(self, action: AgentAction, **kwargs: Any) -> Any: + """Run on agent action. + + Args: + action: The agent action. + **kwargs: Additional keyword arguments. + """ + + def on_tool_end(self, output: Any, **kwargs: Any) -> None: + """Run when tool ends running. + + Args: + output: The output of the tool. + **kwargs: Additional keyword arguments. + """ + + def on_tool_error(self, error: BaseException, **kwargs: Any) -> None: + """Run when tool errors. + + Args: + error: The error that occurred. + **kwargs: Additional keyword arguments. + """ + + def on_text(self, text: str, **kwargs: Any) -> None: + """Run on an arbitrary text. + + Args: + text: The text to print. + **kwargs: Additional keyword arguments. + """ + + def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None: + """Run on the agent end. + + Args: + finish: The agent finish. + **kwargs: Additional keyword arguments. + """ diff --git a/libs/core/langchain_core/callbacks/usage.py b/libs/core/langchain_core/callbacks/usage.py new file mode 100644 index 0000000..9fbcbb7 --- /dev/null +++ b/libs/core/langchain_core/callbacks/usage.py @@ -0,0 +1,119 @@ +"""Callback Handler that tracks `AIMessage.usage_metadata`.""" + +import threading +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any + +from typing_extensions import override + +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.messages import AIMessage +from langchain_core.messages.ai import UsageMetadata, add_usage +from langchain_core.outputs import ChatGeneration, LLMResult +from langchain_core.tracers.context import register_configure_hook + + +class UsageMetadataCallbackHandler(BaseCallbackHandler): + """Callback Handler that tracks `AIMessage.usage_metadata`. + + Example: + ```python + from langchain.chat_models import init_chat_model + from langchain_core.callbacks import UsageMetadataCallbackHandler + + llm_1 = init_chat_model(model="openai:gpt-5.5") + llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001") + + callback = UsageMetadataCallbackHandler() + result_1 = llm_1.invoke("Hello", config={"callbacks": [callback]}) + result_2 = llm_2.invoke("Hello", config={"callbacks": [callback]}) + callback.usage_metadata + ``` + + !!! version-added "Added in `langchain-core` 0.3.49" + + """ + + def __init__(self) -> None: + """Initialize the `UsageMetadataCallbackHandler`.""" + super().__init__() + self._lock = threading.Lock() + self.usage_metadata: dict[str, UsageMetadata] = {} + + @override + def __repr__(self) -> str: + return str(self.usage_metadata) + + @override + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Collect token usage.""" + # Check for usage_metadata (langchain-core >= 0.2.2) + try: + generation = response.generations[0][0] + except IndexError: + generation = None + + usage_metadata = None + model_name = None + if isinstance(generation, ChatGeneration): + try: + message = generation.message + if isinstance(message, AIMessage): + usage_metadata = message.usage_metadata + model_name = message.response_metadata.get("model_name") + except AttributeError: + pass + + # update shared state behind lock + if usage_metadata and model_name: + with self._lock: + if model_name not in self.usage_metadata: + self.usage_metadata[model_name] = usage_metadata + else: + self.usage_metadata[model_name] = add_usage( + self.usage_metadata[model_name], usage_metadata + ) + + +@contextmanager +def get_usage_metadata_callback( + name: str = "usage_metadata_callback", +) -> Generator[UsageMetadataCallbackHandler, None, None]: + """Get usage metadata callback. + + Get context manager for tracking usage metadata across chat model calls using + [`AIMessage.usage_metadata`][langchain.messages.AIMessage.usage_metadata]. + + Args: + name: The name of the context variable. + + Yields: + The usage metadata callback. + + Example: + ```python + from langchain.chat_models import init_chat_model + from langchain_core.callbacks import get_usage_metadata_callback + + llm_1 = init_chat_model(model="openai:gpt-5.5") + llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001") + + with get_usage_metadata_callback() as cb: + llm_1.invoke("Hello") + llm_2.invoke("Hello") + print(cb.usage_metadata) + ``` + + !!! version-added "Added in `langchain-core` 0.3.49" + + """ + usage_metadata_callback_var: ContextVar[UsageMetadataCallbackHandler | None] = ( + ContextVar(name, default=None) + ) + register_configure_hook(usage_metadata_callback_var, inheritable=True) + cb = UsageMetadataCallbackHandler() + usage_metadata_callback_var.set(cb) + yield cb + usage_metadata_callback_var.set(None) diff --git a/libs/core/langchain_core/chat_history.py b/libs/core/langchain_core/chat_history.py new file mode 100644 index 0000000..a4a76f5 --- /dev/null +++ b/libs/core/langchain_core/chat_history.py @@ -0,0 +1,246 @@ +"""Chat message history stores a history of the message interactions in a chat.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field + +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + get_buffer_string, +) +from langchain_core.runnables.config import run_in_executor + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class BaseChatMessageHistory(ABC): + """Abstract base class for storing chat message history. + + Implementations guidelines: + + Implementations are expected to over-ride all or some of the following methods: + + * `add_messages`: sync variant for bulk addition of messages + * `aadd_messages`: async variant for bulk addition of messages + * `messages`: sync variant for getting messages + * `aget_messages`: async variant for getting messages + * `clear`: sync variant for clearing messages + * `aclear`: async variant for clearing messages + + `add_messages` contains a default implementation that calls `add_message` + for each message in the sequence. This is provided for backwards compatibility + with existing implementations which only had `add_message`. + + Async variants all have default implementations that call the sync variants. + Implementers can choose to override the async implementations to provide + truly async implementations. + + Usage guidelines: + + When used for updating history, users should favor usage of `add_messages` + over `add_message` or other variants like `add_user_message` and `add_ai_message` + to avoid unnecessary round-trips to the underlying persistence layer. + + Example: + ```python + import json + import os + from langchain_core.messages import messages_from_dict, message_to_dict + + + class FileChatMessageHistory(BaseChatMessageHistory): + storage_path: str + session_id: str + + @property + def messages(self) -> list[BaseMessage]: + try: + with open( + os.path.join(self.storage_path, self.session_id), + "r", + encoding="utf-8", + ) as f: + messages_data = json.load(f) + return messages_from_dict(messages_data) + except FileNotFoundError: + return [] + + def add_messages(self, messages: Sequence[BaseMessage]) -> None: + all_messages = list(self.messages) # Existing messages + all_messages.extend(messages) # Add new messages + + serialized = [message_to_dict(message) for message in all_messages] + file_path = os.path.join(self.storage_path, self.session_id) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(serialized, f) + + def clear(self) -> None: + file_path = os.path.join(self.storage_path, self.session_id) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump([], f) + ``` + """ + + messages: list[BaseMessage] + """A property or attribute that returns a list of messages. + + In general, getting the messages may involve IO to the underlying persistence + layer, so this operation is expected to incur some latency. + """ + + async def aget_messages(self) -> list[BaseMessage]: + """Async version of getting messages. + + Can over-ride this method to provide an efficient async implementation. + + In general, fetching messages may involve IO to the underlying persistence + layer. + + Returns: + The messages. + """ + return await run_in_executor(None, lambda: self.messages) + + def add_user_message(self, message: HumanMessage | str) -> None: + """Convenience method for adding a human message string to the store. + + !!! note + + This is a convenience method. Code should favor the bulk `add_messages` + interface instead to save on round-trips to the persistence layer. + + This method may be deprecated in a future release. + + Args: + message: The `HumanMessage` to add to the store. + """ + if isinstance(message, HumanMessage): + self.add_message(message) + else: + self.add_message(HumanMessage(content=message)) + + def add_ai_message(self, message: AIMessage | str) -> None: + """Convenience method for adding an `AIMessage` string to the store. + + !!! note + + This is a convenience method. Code should favor the bulk `add_messages` + interface instead to save on round-trips to the persistence layer. + + This method may be deprecated in a future release. + + Args: + message: The `AIMessage` to add. + """ + if isinstance(message, AIMessage): + self.add_message(message) + else: + self.add_message(AIMessage(content=message)) + + def add_message(self, message: BaseMessage) -> None: + """Add a Message object to the store. + + Args: + message: A `BaseMessage` object to store. + + Raises: + NotImplementedError: If the sub-class has not implemented an efficient + `add_messages` method. + """ + if type(self).add_messages != BaseChatMessageHistory.add_messages: + # This means that the sub-class has implemented an efficient add_messages + # method, so we should use it. + self.add_messages([message]) + else: + msg = ( + "add_message is not implemented for this class. " + "Please implement add_message or add_messages." + ) + raise NotImplementedError(msg) + + def add_messages(self, messages: Sequence[BaseMessage]) -> None: + """Add a list of messages. + + Implementations should over-ride this method to handle bulk addition of messages + in an efficient manner to avoid unnecessary round-trips to the underlying store. + + Args: + messages: A sequence of `BaseMessage` objects to store. + """ + for message in messages: + self.add_message(message) + + async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: + """Async add a list of messages. + + Args: + messages: A sequence of `BaseMessage` objects to store. + """ + await run_in_executor(None, self.add_messages, messages) + + @abstractmethod + def clear(self) -> None: + """Remove all messages from the store.""" + + async def aclear(self) -> None: + """Async remove all messages from the store.""" + await run_in_executor(None, self.clear) + + def __str__(self) -> str: + """Return a string representation of the chat history.""" + return get_buffer_string(self.messages) + + +class InMemoryChatMessageHistory(BaseChatMessageHistory, BaseModel): + """In memory implementation of chat message history. + + Stores messages in a memory list. + """ + + messages: list[BaseMessage] = Field(default_factory=list) + """A list of messages stored in memory.""" + + async def aget_messages(self) -> list[BaseMessage]: + """Async version of getting messages. + + Can over-ride this method to provide an efficient async implementation. + + In general, fetching messages may involve IO to the underlying persistence + layer. + + Returns: + List of messages. + """ + return self.messages + + def add_message(self, message: BaseMessage) -> None: + """Add a self-created message to the store. + + Args: + message: The message to add. + """ + self.messages.append(message) + + async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None: + """Async add messages to the store. + + Args: + messages: The messages to add. + """ + self.add_messages(messages) + + def clear(self) -> None: + """Clear all messages from the store.""" + self.messages = [] + + async def aclear(self) -> None: + """Async clear all messages from the store.""" + self.clear() diff --git a/libs/core/langchain_core/chat_loaders.py b/libs/core/langchain_core/chat_loaders.py new file mode 100644 index 0000000..dfb01eb --- /dev/null +++ b/libs/core/langchain_core/chat_loaders.py @@ -0,0 +1,26 @@ +"""Chat loaders.""" + +from abc import ABC, abstractmethod +from collections.abc import Iterator + +from langchain_core.chat_sessions import ChatSession + + +class BaseChatLoader(ABC): + """Base class for chat loaders.""" + + @abstractmethod + def lazy_load(self) -> Iterator[ChatSession]: + """Lazy load the chat sessions. + + Returns: + An iterator of chat sessions. + """ + + def load(self) -> list[ChatSession]: + """Eagerly load the chat sessions into memory. + + Returns: + A list of chat sessions. + """ + return list(self.lazy_load()) diff --git a/libs/core/langchain_core/chat_sessions.py b/libs/core/langchain_core/chat_sessions.py new file mode 100644 index 0000000..c421921 --- /dev/null +++ b/libs/core/langchain_core/chat_sessions.py @@ -0,0 +1,19 @@ +"""**Chat Sessions** are a collection of messages and function calls.""" + +from collections.abc import Sequence +from typing import Any, TypedDict + +from langchain_core.messages import BaseMessage + + +class ChatSession(TypedDict, total=False): + """Chat Session. + + Chat Session represents a single conversation, channel, or other group of messages. + """ + + messages: Sequence[BaseMessage] + """A sequence of the LangChain chat messages loaded from the source.""" + + functions: Sequence[dict[str, Any]] + """A sequence of the function calling specs for the messages.""" diff --git a/libs/core/langchain_core/cross_encoders.py b/libs/core/langchain_core/cross_encoders.py new file mode 100644 index 0000000..7872daa --- /dev/null +++ b/libs/core/langchain_core/cross_encoders.py @@ -0,0 +1,18 @@ +"""Cross Encoder interface.""" + +from abc import ABC, abstractmethod + + +class BaseCrossEncoder(ABC): + """Interface for cross encoder models.""" + + @abstractmethod + def score(self, text_pairs: list[tuple[str, str]]) -> list[float]: + """Score pairs' similarity. + + Args: + text_pairs: List of pairs of texts. + + Returns: + List of scores. + """ diff --git a/libs/core/langchain_core/document_loaders/__init__.py b/libs/core/langchain_core/document_loaders/__init__.py new file mode 100644 index 0000000..e591df9 --- /dev/null +++ b/libs/core/langchain_core/document_loaders/__init__.py @@ -0,0 +1,39 @@ +"""Document loaders.""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.document_loaders.base import BaseBlobParser, BaseLoader + from langchain_core.document_loaders.blob_loaders import Blob, BlobLoader, PathLike + from langchain_core.document_loaders.langsmith import LangSmithLoader + +__all__ = ( + "BaseBlobParser", + "BaseLoader", + "Blob", + "BlobLoader", + "LangSmithLoader", + "PathLike", +) + +_dynamic_imports = { + "BaseBlobParser": "base", + "BaseLoader": "base", + "Blob": "blob_loaders", + "BlobLoader": "blob_loaders", + "PathLike": "blob_loaders", + "LangSmithLoader": "langsmith", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/document_loaders/base.py b/libs/core/langchain_core/document_loaders/base.py new file mode 100644 index 0000000..448c859 --- /dev/null +++ b/libs/core/langchain_core/document_loaders/base.py @@ -0,0 +1,155 @@ +"""Abstract interface for document loader implementations.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from langchain_core.runnables import run_in_executor + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + + from langchain_text_splitters import TextSplitter + + from langchain_core.documents import Document + from langchain_core.documents.base import Blob + +try: + from langchain_text_splitters import RecursiveCharacterTextSplitter + + _HAS_TEXT_SPLITTERS = True +except ImportError: + _HAS_TEXT_SPLITTERS = False + + +class BaseLoader(ABC): # noqa: B024 + """Interface for document loader. + + Implementations should implement the lazy-loading method using generators to avoid + loading all documents into memory at once. + + `load` is provided just for user convenience and should not be overridden. + """ + + # Sub-classes should not implement this method directly. Instead, they + # should implement the lazy load method. + def load(self) -> list[Document]: + """Load data into `Document` objects. + + Returns: + The documents. + """ + return list(self.lazy_load()) + + async def aload(self) -> list[Document]: + """Load data into `Document` objects. + + Returns: + The documents. + """ + return [document async for document in self.alazy_load()] + + def load_and_split( + self, text_splitter: TextSplitter | None = None + ) -> list[Document]: + """Load `Document` and split into chunks. Chunks are returned as `Document`. + + !!! danger + + Do not override this method. It should be considered to be deprecated! + + Args: + text_splitter: `TextSplitter` instance to use for splitting documents. + + Defaults to `RecursiveCharacterTextSplitter`. + + Raises: + ImportError: If `langchain-text-splitters` is not installed and no + `text_splitter` is provided. + + Returns: + List of `Document` objects. + """ + if text_splitter is None: + if not _HAS_TEXT_SPLITTERS: + msg = ( + "Unable to import from langchain_text_splitters. Please specify " + "text_splitter or install langchain_text_splitters with " + "`pip install -U langchain-text-splitters`." + ) + raise ImportError(msg) + + text_splitter_: TextSplitter = RecursiveCharacterTextSplitter() + else: + text_splitter_ = text_splitter + docs = self.load() + return text_splitter_.split_documents(docs) + + # Attention: This method will be upgraded into an abstractmethod once it's + # implemented in all the existing subclasses. + def lazy_load(self) -> Iterator[Document]: + """A lazy loader for `Document`. + + Yields: + The `Document` objects. + """ + if type(self).load != BaseLoader.load: + return iter(self.load()) + msg = f"{self.__class__.__name__} does not implement lazy_load()" + raise NotImplementedError(msg) + + async def alazy_load(self) -> AsyncIterator[Document]: + """A lazy loader for `Document`. + + Yields: + The `Document` objects. + """ + iterator = await run_in_executor(None, self.lazy_load) + done = object() + while True: + doc = await run_in_executor(None, next, iterator, done) + if doc is done: + break + yield doc # type: ignore[misc] + + +class BaseBlobParser(ABC): + """Abstract interface for blob parsers. + + A blob parser provides a way to parse raw data stored in a blob into one or more + `Document` objects. + + The parser can be composed with blob loaders, making it easy to reuse a parser + independent of how the blob was originally loaded. + """ + + @abstractmethod + def lazy_parse(self, blob: Blob) -> Iterator[Document]: + """Lazy parsing interface. + + Subclasses are required to implement this method. + + Args: + blob: `Blob` instance + + Returns: + Generator of `Document` objects + """ + + def parse(self, blob: Blob) -> list[Document]: + """Eagerly parse the blob into a `Document` or list of `Document` objects. + + This is a convenience method for interactive development environment. + + Production applications should favor the `lazy_parse` method instead. + + Subclasses should generally not over-ride this parse method. + + Args: + blob: `Blob` instance + + Returns: + List of `Document` objects + """ + return list(self.lazy_parse(blob)) diff --git a/libs/core/langchain_core/document_loaders/blob_loaders.py b/libs/core/langchain_core/document_loaders/blob_loaders.py new file mode 100644 index 0000000..399cd4a --- /dev/null +++ b/libs/core/langchain_core/document_loaders/blob_loaders.py @@ -0,0 +1,38 @@ +"""Schema for Blobs and Blob Loaders. + +The goal is to facilitate decoupling of content loading from content parsing code. In +addition, content loading code should provide a lazy loading interface by default. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +# Re-export Blob and PathLike for backwards compatibility +from langchain_core.documents.base import Blob, PathLike + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class BlobLoader(ABC): + """Abstract interface for blob loaders implementation. + + Implementer should be able to load raw content from a storage system according to + some criteria and return the raw content lazily as a stream of blobs. + """ + + @abstractmethod + def yield_blobs( + self, + ) -> Iterator[Blob]: + """A lazy loader for raw data represented by LangChain's `Blob` object. + + Yields: + `Blob` objects. + """ + + +# Re-export Blob and Pathlike for backwards compatibility +__all__ = ["Blob", "BlobLoader", "PathLike"] diff --git a/libs/core/langchain_core/document_loaders/langsmith.py b/libs/core/langchain_core/document_loaders/langsmith.py new file mode 100644 index 0000000..cea2b92 --- /dev/null +++ b/libs/core/langchain_core/document_loaders/langsmith.py @@ -0,0 +1,183 @@ +"""LangSmith document loader.""" + +import datetime +import json +import uuid +from collections.abc import Callable, Iterator, Mapping, Sequence +from typing import Any + +from langsmith import Client as LangSmithClient +from typing_extensions import override + +from langchain_core.document_loaders.base import BaseLoader +from langchain_core.documents import Document +from langchain_core.tracers._compat import pydantic_to_dict + + +class LangSmithLoader(BaseLoader): + """Load LangSmith Dataset examples as `Document` objects. + + Loads the example inputs as the `Document` page content and places the entire + example into the `Document` metadata. This allows you to easily create few-shot + example retrievers from the loaded documents. + + ??? example "Lazy loading" + + ```python + from langchain_core.document_loaders import LangSmithLoader + + loader = LangSmithLoader(dataset_id="...", limit=100) + docs = [] + for doc in loader.lazy_load(): + docs.append(doc) + ``` + + ```python + # -> [Document("...", metadata={"inputs": {...}, "outputs": {...}, ...}), ...] + ``` + """ + + def __init__( + self, + *, + dataset_id: uuid.UUID | str | None = None, + dataset_name: str | None = None, + example_ids: Sequence[uuid.UUID | str] | None = None, + as_of: datetime.datetime | str | None = None, + splits: Sequence[str] | None = None, + inline_s3_urls: bool = True, + offset: int = 0, + limit: int | None = None, + metadata: dict[str, Any] | None = None, + filter: str | None = None, # noqa: A002 + content_key: str = "", + format_content: Callable[..., str] | None = None, + client: LangSmithClient | None = None, + **client_kwargs: Any, + ) -> None: + """Create a LangSmith loader. + + Args: + dataset_id: The ID of the dataset to filter by. + dataset_name: The name of the dataset to filter by. + content_key: The inputs key to set as `Document` page content. + + `'.'` characters are interpreted as nested keys, e.g. + `content_key="first.second"` will result in + `Document(page_content=format_content(example.inputs["first"]["second"]))` + format_content: Function for converting the content extracted from the example + inputs into a string. + + Defaults to JSON-encoding the contents. + example_ids: The IDs of the examples to filter by. + as_of: The dataset version tag or timestamp to retrieve the examples as of. + + Response examples will only be those that were present at the time of + the tagged (or timestamped) version. + splits: A list of dataset splits, which are divisions of your dataset such + as `train`, `test`, or `validation`. + + Returns examples only from the specified splits. + inline_s3_urls: Whether to inline S3 URLs. + offset: The offset to start from. + limit: The maximum number of examples to return. + metadata: Metadata to filter by. + filter: A structured filter string to apply to the examples. + client: LangSmith Client. + + If not provided will be initialized from below args. + client_kwargs: Keyword args to pass to LangSmith client init. + + Should only be specified if `client` isn't. + + Raises: + ValueError: If both `client` and `client_kwargs` are provided. + """ # noqa: E501 + if client and client_kwargs: + msg = ( + "Received both `client` and `client_kwargs`. " + "Pass `client_kwargs` only when `client` is not provided." + ) + raise ValueError(msg) + self._client = client or LangSmithClient(**client_kwargs) + self.content_key = list(content_key.split(".")) if content_key else [] + self.format_content = format_content or _stringify + self.dataset_id = dataset_id + self.dataset_name = dataset_name + self.example_ids = example_ids + self.as_of = as_of + self.splits = splits + self.inline_s3_urls = inline_s3_urls + self.offset = offset + self.limit = limit + self.metadata = metadata + self.filter = filter + + @override + def lazy_load(self) -> Iterator[Document]: + for example in self._client.list_examples( + dataset_id=self.dataset_id, + dataset_name=self.dataset_name, + example_ids=self.example_ids, + as_of=self.as_of, + splits=self.splits, + inline_s3_urls=self.inline_s3_urls, + offset=self.offset, + limit=self.limit, + metadata=self.metadata, + filter=self.filter, + ): + content = _get_content_from_inputs(example.inputs, self.content_key) + content_str = self.format_content(content) + metadata = pydantic_to_dict(example) + # Stringify datetime and UUID types. + for k in ("dataset_id", "created_at", "modified_at", "source_run_id", "id"): + metadata[k] = str(metadata[k]) if metadata[k] else metadata[k] + yield Document(content_str, metadata=metadata) + + +def _get_content_from_inputs(inputs: Any, content_key: Sequence[str]) -> Any: + """Resolve nested example input content for `LangSmithLoader`. + + Args: + inputs: Example input payload returned by LangSmith. + content_key: Ordered key path used to extract the document content. + + Returns: + The extracted content value. + + Raises: + ValueError: If a key in `content_key` is missing, or a value along the path + (including `inputs` itself) is not a mapping. + """ + content = inputs + full_path = ".".join(content_key) + + for i, key in enumerate(content_key): + current_path = ".".join(content_key[:i]) or "" + if not isinstance(content, Mapping): + msg = ( + f"Could not resolve content_key {full_path!r}: expected a mapping at " + f"{current_path!r}, but found {type(content).__name__}." + ) + # A too-deep `content_key` is an invalid-argument error, not a runtime + # type bug, so it is unified with the missing-key case as `ValueError`. + raise ValueError(msg) # noqa: TRY004 + if key not in content: + msg = ( + f"Could not resolve content_key {full_path!r}: missing key {key!r} " + f"under {current_path!r}." + ) + raise ValueError(msg) + content = content[key] + + return content + + +def _stringify(x: str | dict[str, Any]) -> str: + if isinstance(x, str): + return x + try: + return json.dumps(x, indent=2) + except Exception: + return str(x) diff --git a/libs/core/langchain_core/documents/__init__.py b/libs/core/langchain_core/documents/__init__.py new file mode 100644 index 0000000..128a9dc --- /dev/null +++ b/libs/core/langchain_core/documents/__init__.py @@ -0,0 +1,55 @@ +"""Documents module for data retrieval and processing workflows. + +This module provides core abstractions for handling data in retrieval-augmented +generation (RAG) pipelines, vector stores, and document processing workflows. + +!!! warning "Documents vs. message content" + + This module is distinct from `langchain_core.messages.content`, which provides + multimodal content blocks for **LLM chat I/O** (text, images, audio, etc. within + messages). + + **Key distinction:** + + - **Documents** (this module): For **data retrieval and processing workflows** + - Vector stores, retrievers, RAG pipelines + - Text chunking, embedding, and semantic search + - Example: Chunks of a PDF stored in a vector database + + - **Content Blocks** (`messages.content`): For **LLM conversational I/O** + - Multimodal message content sent to/from models + - Tool calls, reasoning, citations within chat + - Example: An image sent to a vision model in a chat message (via + [`ImageContentBlock`][langchain.messages.ImageContentBlock]) + + While both can represent similar data types (text, files), they serve different + architectural purposes in LangChain applications. +""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.documents.base import Document + from langchain_core.documents.compressor import BaseDocumentCompressor + from langchain_core.documents.transformers import BaseDocumentTransformer + +__all__ = ("BaseDocumentCompressor", "BaseDocumentTransformer", "Document") + +_dynamic_imports = { + "Document": "base", + "BaseDocumentCompressor": "compressor", + "BaseDocumentTransformer": "transformers", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/documents/base.py b/libs/core/langchain_core/documents/base.py new file mode 100644 index 0000000..efb2e23 --- /dev/null +++ b/libs/core/langchain_core/documents/base.py @@ -0,0 +1,347 @@ +"""Base classes for media and documents. + +This module contains core abstractions for **data retrieval and processing workflows**: + +- `BaseMedia`: Base class providing `id` and `metadata` fields +- `Blob`: Raw data loading (files, binary data) - used by document loaders +- `Document`: Text content for retrieval (RAG, vector stores, semantic search) + +!!! note "Not for LLM chat messages" + + These classes are for data processing pipelines, not LLM I/O. For multimodal + content in chat messages (images, audio in conversations), see + `langchain.messages` content blocks instead. +""" + +from __future__ import annotations + +import contextlib +import mimetypes +from io import BufferedReader, BytesIO +from pathlib import Path, PurePath +from typing import TYPE_CHECKING, Any, Literal, cast + +from pydantic import ConfigDict, Field, model_validator + +from langchain_core.load.serializable import Serializable + +if TYPE_CHECKING: + from collections.abc import Generator + +PathLike = str | PurePath + + +class BaseMedia(Serializable): + """Base class for content used in retrieval and data processing workflows. + + Provides common fields for content that needs to be stored, indexed, or searched. + + !!! note + + For multimodal content in **chat messages** (images, audio sent to/from LLMs), + use `langchain.messages` content blocks instead. + """ + + # The ID field is optional at the moment. + # It will likely become required in a future major release after + # it has been adopted by enough VectorStore implementations. + id: str | None = Field(default=None, coerce_numbers_to_str=True) + """An optional identifier for the document. + + Ideally this should be unique across the document collection and formatted + as a UUID, but this will not be enforced. + """ + + metadata: dict[Any, Any] = Field(default_factory=dict) + """Arbitrary metadata associated with the content.""" + + +class Blob(BaseMedia): + """Raw data abstraction for document loading and file processing. + + Represents raw bytes or text, either in-memory or by file reference. Used + primarily by document loaders to decouple data loading from parsing. + + Inspired by [Mozilla's `Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) + + ???+ example "Initialize a blob from in-memory data" + + ```python + from langchain_core.documents import Blob + + blob = Blob.from_data("Hello, world!") + + # Read the blob as a string + print(blob.as_string()) + + # Read the blob as bytes + print(blob.as_bytes()) + + # Read the blob as a byte stream + with blob.as_bytes_io() as f: + print(f.read()) + ``` + + ??? example "Load from memory and specify MIME type and metadata" + + ```python + from langchain_core.documents import Blob + + blob = Blob.from_data( + data="Hello, world!", + mime_type="text/plain", + metadata={"source": "https://example.com"}, + ) + ``` + + ??? example "Load the blob from a file" + + ```python + from langchain_core.documents import Blob + + blob = Blob.from_path("path/to/file.txt") + + # Read the blob as a string + print(blob.as_string()) + + # Read the blob as bytes + print(blob.as_bytes()) + + # Read the blob as a byte stream + with blob.as_bytes_io() as f: + print(f.read()) + ``` + """ + + data: bytes | str | None = None + """Raw data associated with the `Blob`.""" + + mimetype: str | None = None + """MIME type, not to be confused with a file extension.""" + + encoding: str = "utf-8" + """Encoding to use if decoding the bytes into a string. + + Uses `utf-8` as default encoding if decoding to string. + """ + + path: PathLike | None = None + """Location where the original content was found.""" + + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + + @property + def source(self) -> str | None: + """The source location of the blob as string if known otherwise none. + + If a path is associated with the `Blob`, it will default to the path location. + + Unless explicitly set via a metadata field called `'source'`, in which + case that value will be used instead. + """ + if self.metadata and "source" in self.metadata: + return cast("str | None", self.metadata["source"]) + return str(self.path) if self.path else None + + @model_validator(mode="before") + @classmethod + def check_blob_is_valid(cls, values: dict[str, Any]) -> Any: + """Verify that either data or path is provided.""" + if "data" not in values and "path" not in values: + msg = "Either data or path must be provided" + raise ValueError(msg) + return values + + def as_string(self) -> str: + """Read data as a string. + + Raises: + ValueError: If the blob cannot be represented as a string. + + Returns: + The data as a string. + """ + if self.data is None and self.path: + return Path(self.path).read_text(encoding=self.encoding) + if isinstance(self.data, bytes): + return self.data.decode(self.encoding) + if isinstance(self.data, str): + return self.data + msg = f"Unable to get string for blob {self}" + raise ValueError(msg) + + def as_bytes(self) -> bytes: + """Read data as bytes. + + Raises: + ValueError: If the blob cannot be represented as bytes. + + Returns: + The data as bytes. + """ + if isinstance(self.data, bytes): + return self.data + if isinstance(self.data, str): + return self.data.encode(self.encoding) + if self.data is None and self.path: + return Path(self.path).read_bytes() + msg = f"Unable to get bytes for blob {self}" + raise ValueError(msg) + + @contextlib.contextmanager + def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]: + """Read data as a byte stream. + + Raises: + NotImplementedError: If the blob cannot be represented as a byte stream. + + Yields: + The data as a byte stream. + """ + if isinstance(self.data, bytes): + yield BytesIO(self.data) + elif self.data is None and self.path: + with Path(self.path).open("rb") as f: + yield f + else: + msg = f"Unable to convert blob {self}" + raise NotImplementedError(msg) + + @classmethod + def from_path( + cls, + path: PathLike, + *, + encoding: str = "utf-8", + mime_type: str | None = None, + guess_type: bool = True, + metadata: dict[Any, Any] | None = None, + ) -> Blob: + """Load the blob from a path like object. + + Args: + path: Path-like object to file to be read + encoding: Encoding to use if decoding the bytes into a string + mime_type: If provided, will be set as the MIME type of the data + guess_type: If `True`, the MIME type will be guessed from the file + extension, if a MIME type was not provided + metadata: Metadata to associate with the `Blob` + + Returns: + `Blob` instance + """ + if mime_type is None and guess_type: + mimetype = mimetypes.guess_type(path)[0] + else: + mimetype = mime_type + # We do not load the data immediately, instead we treat the blob as a + # reference to the underlying data. + return cls( + data=None, + mimetype=mimetype, + encoding=encoding, + path=path, + metadata=metadata if metadata is not None else {}, + ) + + @classmethod + def from_data( + cls, + data: str | bytes, + *, + encoding: str = "utf-8", + mime_type: str | None = None, + path: str | None = None, + metadata: dict[Any, Any] | None = None, + ) -> Blob: + """Initialize the `Blob` from in-memory data. + + Args: + data: The in-memory data associated with the `Blob` + encoding: Encoding to use if decoding the bytes into a string + mime_type: If provided, will be set as the MIME type of the data + path: If provided, will be set as the source from which the data came + metadata: Metadata to associate with the `Blob` + + Returns: + `Blob` instance + """ + return cls( + data=data, + mimetype=mime_type, + encoding=encoding, + path=path, + metadata=metadata if metadata is not None else {}, + ) + + def __repr__(self) -> str: + """Return the blob representation.""" + str_repr = f"Blob {id(self)}" + if self.source: + str_repr += f" {self.source}" + return str_repr + + +class Document(BaseMedia): + """Class for storing a piece of text and associated metadata. + + !!! note + + `Document` is for **retrieval workflows**, not chat I/O. For sending text + to an LLM in a conversation, use message types from `langchain.messages`. + + Example: + ```python + from langchain_core.documents import Document + + document = Document( + page_content="Hello, world!", metadata={"source": "https://example.com"} + ) + ``` + """ + + page_content: str + """String text.""" + + type: Literal["Document"] = "Document" + + def __init__(self, page_content: str, **kwargs: Any) -> None: + """Pass page_content in as positional or named arg.""" + # my-py is complaining that page_content is not defined on the base class. + # Here, we're relying on pydantic base class to handle the validation. + super().__init__(page_content=page_content, **kwargs) # type: ignore[call-arg,unused-ignore] + + @classmethod + def is_lc_serializable(cls) -> bool: + """Return `True` as this class is serializable.""" + return True + + @classmethod + def get_lc_namespace(cls) -> list[str]: + """Get the namespace of the LangChain object. + + Returns: + `["langchain", "schema", "document"]` + """ + return ["langchain", "schema", "document"] + + def __str__(self) -> str: + """Override `__str__` to restrict it to page_content and metadata. + + Returns: + A string representation of the `Document`. + """ + # The format matches pydantic format for __str__. + # + # The purpose of this change is to make sure that user code that feeds + # Document objects directly into prompts remains unchanged due to the addition + # of the id field (or any other fields in the future). + # + # This override will likely be removed in the future in favor of a more general + # solution of formatting content directly inside the prompts. + if self.metadata: + return f"page_content='{self.page_content}' metadata={self.metadata}" + return f"page_content='{self.page_content}'" diff --git a/libs/core/langchain_core/documents/compressor.py b/libs/core/langchain_core/documents/compressor.py new file mode 100644 index 0000000..c765b37 --- /dev/null +++ b/libs/core/langchain_core/documents/compressor.py @@ -0,0 +1,74 @@ +"""Document compressor.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from langchain_core.runnables import run_in_executor + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langchain_core.callbacks import Callbacks + from langchain_core.documents import Document + + +class BaseDocumentCompressor(BaseModel, ABC): + """Base class for document compressors. + + This abstraction is primarily used for post-processing of retrieved documents. + + `Document` objects matching a given query are first retrieved. + + Then the list of documents can be further processed. + + For example, one could re-rank the retrieved documents using an LLM. + + !!! note + Users should favor using a `RunnableLambda` instead of sub-classing from this + interface. + + """ + + @abstractmethod + def compress_documents( + self, + documents: Sequence[Document], + query: str, + callbacks: Callbacks | None = None, + ) -> Sequence[Document]: + """Compress retrieved documents given the query context. + + Args: + documents: The retrieved `Document` objects. + query: The query context. + callbacks: Optional `Callbacks` to run during compression. + + Returns: + The compressed documents. + + """ + + async def acompress_documents( + self, + documents: Sequence[Document], + query: str, + callbacks: Callbacks | None = None, + ) -> Sequence[Document]: + """Async compress retrieved documents given the query context. + + Args: + documents: The retrieved `Document` objects. + query: The query context. + callbacks: Optional `Callbacks` to run during compression. + + Returns: + The compressed documents. + + """ + return await run_in_executor( + None, self.compress_documents, documents, query, callbacks + ) diff --git a/libs/core/langchain_core/documents/transformers.py b/libs/core/langchain_core/documents/transformers.py new file mode 100644 index 0000000..c05fa29 --- /dev/null +++ b/libs/core/langchain_core/documents/transformers.py @@ -0,0 +1,79 @@ +"""Document transformers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from langchain_core.runnables.config import run_in_executor + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langchain_core.documents import Document + + +class BaseDocumentTransformer(ABC): + """Abstract base class for document transformation. + + A document transformation takes a sequence of `Document` objects and returns a + sequence of transformed `Document` objects. + + Example: + ```python + class EmbeddingsRedundantFilter(BaseDocumentTransformer, BaseModel): + embeddings: Embeddings + similarity_fn: Callable = cosine_similarity + similarity_threshold: float = 0.95 + + class Config: + arbitrary_types_allowed = True + + def transform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + stateful_documents = get_stateful_documents(documents) + embedded_documents = _get_embeddings_from_stateful_docs( + self.embeddings, stateful_documents + ) + included_idxs = _filter_similar_embeddings( + embedded_documents, + self.similarity_fn, + self.similarity_threshold, + ) + return [stateful_documents[i] for i in sorted(included_idxs)] + + async def atransform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + raise NotImplementedError + ``` + """ + + @abstractmethod + def transform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + """Transform a list of documents. + + Args: + documents: A sequence of `Document` objects to be transformed. + + Returns: + A sequence of transformed `Document` objects. + """ + + async def atransform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + """Asynchronously transform a list of documents. + + Args: + documents: A sequence of `Document` objects to be transformed. + + Returns: + A sequence of transformed `Document` objects. + """ + return await run_in_executor( + None, self.transform_documents, documents, **kwargs + ) diff --git a/libs/core/langchain_core/embeddings/__init__.py b/libs/core/langchain_core/embeddings/__init__.py new file mode 100644 index 0000000..66acae1 --- /dev/null +++ b/libs/core/langchain_core/embeddings/__init__.py @@ -0,0 +1,31 @@ +"""Embeddings.""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.embeddings.embeddings import Embeddings + from langchain_core.embeddings.fake import ( + DeterministicFakeEmbedding, + FakeEmbeddings, + ) + +__all__ = ("DeterministicFakeEmbedding", "Embeddings", "FakeEmbeddings") + +_dynamic_imports = { + "Embeddings": "embeddings", + "DeterministicFakeEmbedding": "fake", + "FakeEmbeddings": "fake", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/embeddings/embeddings.py b/libs/core/langchain_core/embeddings/embeddings.py new file mode 100644 index 0000000..39c0eb4 --- /dev/null +++ b/libs/core/langchain_core/embeddings/embeddings.py @@ -0,0 +1,78 @@ +"""**Embeddings** interface.""" + +from abc import ABC, abstractmethod + +from langchain_core.runnables.config import run_in_executor + + +class Embeddings(ABC): + """Interface for embedding models. + + This is an interface meant for implementing text embedding models. + + Text embedding models are used to map text to a vector (a point in n-dimensional + space). + + Texts that are similar will usually be mapped to points that are close to each + other in this space. The exact details of what's considered "similar" and how + "distance" is measured in this space are dependent on the specific embedding model. + + This abstraction contains a method for embedding a list of documents and a method + for embedding a query text. The embedding of a query text is expected to be a single + vector, while the embedding of a list of documents is expected to be a list of + vectors. + + Usually the query embedding is identical to the document embedding, but the + abstraction allows treating them independently. + + In addition to the synchronous methods, this interface also provides asynchronous + versions of the methods. + + By default, the asynchronous methods are implemented using the synchronous methods; + however, implementations may choose to override the asynchronous methods with + an async native implementation for performance reasons. + """ + + @abstractmethod + def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed search docs. + + Args: + texts: List of text to embed. + + Returns: + List of embeddings. + """ + + @abstractmethod + def embed_query(self, text: str) -> list[float]: + """Embed query text. + + Args: + text: Text to embed. + + Returns: + Embedding. + """ + + async def aembed_documents(self, texts: list[str]) -> list[list[float]]: + """Asynchronous Embed search docs. + + Args: + texts: List of text to embed. + + Returns: + List of embeddings. + """ + return await run_in_executor(None, self.embed_documents, texts) + + async def aembed_query(self, text: str) -> list[float]: + """Asynchronous Embed query text. + + Args: + text: Text to embed. + + Returns: + Embedding. + """ + return await run_in_executor(None, self.embed_query, text) diff --git a/libs/core/langchain_core/embeddings/fake.py b/libs/core/langchain_core/embeddings/fake.py new file mode 100644 index 0000000..0a252ef --- /dev/null +++ b/libs/core/langchain_core/embeddings/fake.py @@ -0,0 +1,129 @@ +"""Module contains a few fake embedding models for testing purposes.""" + +# Please do not add additional fake embedding model implementations here. +import contextlib +import hashlib + +from pydantic import BaseModel +from typing_extensions import override + +from langchain_core.embeddings import Embeddings + +with contextlib.suppress(ImportError): + import numpy as np + + +class FakeEmbeddings(Embeddings, BaseModel): + """Fake embedding model for unit testing purposes. + + This embedding model creates embeddings by sampling from a normal distribution. + + !!! danger "Toy model" + Do not use this outside of testing, as it is not a real embedding model. + + Instantiate: + ```python + from langchain_core.embeddings import FakeEmbeddings + + embed = FakeEmbeddings(size=100) + ``` + + Embed single text: + ```python + input_text = "The meaning of life is 42" + vector = embed.embed_query(input_text) + print(vector[:3]) + ``` + ```python + [-0.700234640213188, -0.581266257710429, -1.1328482266445354] + ``` + + Embed multiple texts: + ```python + input_texts = ["Document 1...", "Document 2..."] + vectors = embed.embed_documents(input_texts) + print(len(vectors)) + # The first 3 coordinates for the first vector + print(vectors[0][:3]) + ``` + ```python + 2 + [-0.5670477847544458, -0.31403828652395727, -0.5840547508955257] + ``` + """ + + size: int + """The size of the embedding vector.""" + + def _get_embedding(self) -> list[float]: + return list(np.random.default_rng().normal(size=self.size)) + + @override + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [self._get_embedding() for _ in texts] + + @override + def embed_query(self, text: str) -> list[float]: + return self._get_embedding() + + +class DeterministicFakeEmbedding(Embeddings, BaseModel): + """Deterministic fake embedding model for unit testing purposes. + + This embedding model creates embeddings by sampling from a normal distribution + with a seed based on the hash of the text. + + !!! danger "Toy model" + Do not use this outside of testing, as it is not a real embedding model. + + Instantiate: + ```python + from langchain_core.embeddings import DeterministicFakeEmbedding + + embed = DeterministicFakeEmbedding(size=100) + ``` + + Embed single text: + ```python + input_text = "The meaning of life is 42" + vector = embed.embed_query(input_text) + print(vector[:3]) + ``` + ```python + [-0.700234640213188, -0.581266257710429, -1.1328482266445354] + ``` + + Embed multiple texts: + ```python + input_texts = ["Document 1...", "Document 2..."] + vectors = embed.embed_documents(input_texts) + print(len(vectors)) + # The first 3 coordinates for the first vector + print(vectors[0][:3]) + ``` + ```python + 2 + [-0.5670477847544458, -0.31403828652395727, -0.5840547508955257] + ``` + """ + + size: int + """The size of the embedding vector.""" + + def _get_embedding(self, seed: int) -> list[float]: + # set the seed for the random generator + rng = np.random.default_rng(seed) + return list(rng.normal(size=self.size)) + + @staticmethod + def _get_seed(text: str) -> int: + """Get a seed for the random generator, using the hash of the text.""" + return int(hashlib.sha256(text.encode("utf-8")).hexdigest(), 16) % 10**8 + + @override + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [self._get_embedding(seed=self._get_seed(_)) for _ in texts] + + @override + def embed_query(self, text: str) -> list[float]: + return self._get_embedding(seed=self._get_seed(text)) diff --git a/libs/core/langchain_core/env.py b/libs/core/langchain_core/env.py new file mode 100644 index 0000000..20d384e --- /dev/null +++ b/libs/core/langchain_core/env.py @@ -0,0 +1,22 @@ +"""Utilities for getting information about the runtime environment.""" + +import platform +from functools import lru_cache + +from langchain_core import __version__ + + +@lru_cache(maxsize=1) +def get_runtime_environment() -> dict[str, str]: + """Get information about the LangChain runtime environment. + + Returns: + A dictionary with information about the runtime environment. + """ + return { + "library_version": __version__, + "library": "langchain-core", + "platform": platform.platform(), + "runtime": "python", + "runtime_version": platform.python_version(), + } diff --git a/libs/core/langchain_core/example_selectors/__init__.py b/libs/core/langchain_core/example_selectors/__init__.py new file mode 100644 index 0000000..db079c9 --- /dev/null +++ b/libs/core/langchain_core/example_selectors/__init__.py @@ -0,0 +1,47 @@ +"""Example selectors. + +**Example selector** implements logic for selecting examples to include them in prompts. +This allows us to select examples that are most relevant to the input. +""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.example_selectors.base import BaseExampleSelector + from langchain_core.example_selectors.length_based import ( + LengthBasedExampleSelector, + ) + from langchain_core.example_selectors.semantic_similarity import ( + MaxMarginalRelevanceExampleSelector, + SemanticSimilarityExampleSelector, + sorted_values, + ) + +__all__ = ( + "BaseExampleSelector", + "LengthBasedExampleSelector", + "MaxMarginalRelevanceExampleSelector", + "SemanticSimilarityExampleSelector", + "sorted_values", +) + +_dynamic_imports = { + "BaseExampleSelector": "base", + "LengthBasedExampleSelector": "length_based", + "MaxMarginalRelevanceExampleSelector": "semantic_similarity", + "SemanticSimilarityExampleSelector": "semantic_similarity", + "sorted_values": "semantic_similarity", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/example_selectors/base.py b/libs/core/langchain_core/example_selectors/base.py new file mode 100644 index 0000000..7297c75 --- /dev/null +++ b/libs/core/langchain_core/example_selectors/base.py @@ -0,0 +1,60 @@ +"""Interface for selecting examples to include in prompts.""" + +from abc import ABC, abstractmethod +from typing import Any + +from langchain_core.runnables import run_in_executor + + +class BaseExampleSelector(ABC): + """Interface for selecting examples to include in prompts.""" + + @abstractmethod + def add_example(self, example: dict[str, str]) -> Any: + """Add new example to store. + + Args: + example: A dictionary with keys as input variables + and values as their values. + + Returns: + Any return value. + """ + + async def aadd_example(self, example: dict[str, str]) -> Any: + """Async add new example to store. + + Args: + example: A dictionary with keys as input variables + and values as their values. + + Returns: + Any return value. + """ + return await run_in_executor(None, self.add_example, example) + + @abstractmethod + def select_examples(self, input_variables: dict[str, str]) -> list[dict[str, Any]]: + """Select which examples to use based on the inputs. + + Args: + input_variables: A dictionary with keys as input variables + and values as their values. + + Returns: + A list of examples. + """ + + async def aselect_examples( + self, input_variables: dict[str, str] + ) -> list[dict[str, Any]]: + """Async select which examples to use based on the inputs. + + Args: + input_variables: A dictionary with keys as input variables + and values as their values. + + Returns: + A list of examples. + """ + return await run_in_executor(None, self.select_examples, input_variables) diff --git a/libs/core/langchain_core/example_selectors/length_based.py b/libs/core/langchain_core/example_selectors/length_based.py new file mode 100644 index 0000000..7205635 --- /dev/null +++ b/libs/core/langchain_core/example_selectors/length_based.py @@ -0,0 +1,131 @@ +"""Select examples based on length.""" + +import re +from collections.abc import Callable +from typing import Any + +from pydantic import BaseModel, Field, model_validator +from typing_extensions import Self + +from langchain_core.example_selectors.base import BaseExampleSelector +from langchain_core.prompts.prompt import PromptTemplate + + +def _get_length_based(text: str) -> int: + return len(re.split(r"\n| ", text)) + + +class LengthBasedExampleSelector(BaseExampleSelector, BaseModel): + r"""Select examples based on length. + + Example: + ```python + from langchain_core.example_selectors import LengthBasedExampleSelector + from langchain_core.prompts import PromptTemplate + + # Define examples + examples = [ + {"input": "happy", "output": "sad"}, + {"input": "tall", "output": "short"}, + {"input": "fast", "output": "slow"}, + ] + + # Create prompt template + example_prompt = PromptTemplate( + input_variables=["input", "output"], + template="Input: {input}\nOutput: {output}", + ) + + # Create selector with max length constraint + selector = LengthBasedExampleSelector( + examples=examples, + example_prompt=example_prompt, + max_length=50, # Maximum prompt length + ) + + # Select examples for a new input + selected = selector.select_examples({"input": "large", "output": "tiny"}) + # Returns examples that fit within max_length constraint + ``` + """ + + examples: list[dict[str, Any]] + """A list of the examples that the prompt template expects.""" + + example_prompt: PromptTemplate + """Prompt template used to format the examples.""" + + get_text_length: Callable[[str], int] = _get_length_based + """Function to measure prompt length. Defaults to word count.""" + + max_length: int = 2048 + """Max length for the prompt, beyond which examples are cut.""" + + example_text_lengths: list[int] = Field(default_factory=list) + """Length of each example.""" + + def add_example(self, example: dict[str, str]) -> None: + """Add new example to list. + + Args: + example: A dictionary with keys as input variables + and values as their values. + """ + self.examples.append(example) + string_example = self.example_prompt.format(**example) + self.example_text_lengths.append(self.get_text_length(string_example)) + + async def aadd_example(self, example: dict[str, str]) -> None: + """Async add new example to list. + + Args: + example: A dictionary with keys as input variables + and values as their values. + """ + self.add_example(example) + + @model_validator(mode="after") + def post_init(self) -> Self: + """Validate that the examples are formatted correctly.""" + if self.example_text_lengths: + return self + string_examples = [self.example_prompt.format(**eg) for eg in self.examples] + self.example_text_lengths = [self.get_text_length(eg) for eg in string_examples] + return self + + def select_examples(self, input_variables: dict[str, str]) -> list[dict[str, Any]]: + """Select which examples to use based on the input lengths. + + Args: + input_variables: A dictionary with keys as input variables + and values as their values. + + Returns: + A list of examples to include in the prompt. + """ + inputs = " ".join(input_variables.values()) + remaining_length = self.max_length - self.get_text_length(inputs) + i = 0 + examples = [] + while remaining_length > 0 and i < len(self.examples): + new_length = remaining_length - self.example_text_lengths[i] + if new_length < 0: + break + examples.append(self.examples[i]) + remaining_length = new_length + i += 1 + return examples + + async def aselect_examples( + self, input_variables: dict[str, str] + ) -> list[dict[str, Any]]: + """Async select which examples to use based on the input lengths. + + Args: + input_variables: A dictionary with keys as input variables + and values as their values. + + Returns: + A list of examples to include in the prompt. + """ + return self.select_examples(input_variables) diff --git a/libs/core/langchain_core/example_selectors/semantic_similarity.py b/libs/core/langchain_core/example_selectors/semantic_similarity.py new file mode 100644 index 0000000..42e711a --- /dev/null +++ b/libs/core/langchain_core/example_selectors/semantic_similarity.py @@ -0,0 +1,366 @@ +"""Example selector that selects examples based on SemanticSimilarity.""" + +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict + +from langchain_core.example_selectors.base import BaseExampleSelector +from langchain_core.vectorstores import VectorStore + +if TYPE_CHECKING: + from langchain_core.documents import Document + from langchain_core.embeddings import Embeddings + + +def sorted_values(values: dict[str, str]) -> list[str]: + """Return a list of values in dict sorted by key. + + Args: + values: A dictionary with keys as input variables + and values as their values. + + Returns: + A list of values in dict sorted by key. + """ + return [values[val] for val in sorted(values)] + + +class _VectorStoreExampleSelector(BaseExampleSelector, BaseModel, ABC): + """Example selector that selects examples based on SemanticSimilarity.""" + + vectorstore: VectorStore + """VectorStore that contains information about examples.""" + + k: int = 4 + """Number of examples to select.""" + + example_keys: list[str] | None = None + """Optional keys to filter examples to.""" + + input_keys: list[str] | None = None + """Optional keys to filter input to. If provided, the search is based on + the input variables instead of all variables.""" + + vectorstore_kwargs: dict[str, Any] | None = None + """Extra arguments passed to similarity_search function of the `VectorStore`.""" + + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + ) + + @staticmethod + def _example_to_text(example: dict[str, str], input_keys: list[str] | None) -> str: + if input_keys: + return " ".join(sorted_values({key: example[key] for key in input_keys})) + return " ".join(sorted_values(example)) + + def _documents_to_examples(self, documents: list[Document]) -> list[dict[str, Any]]: + # Get the examples from the metadata. + # This assumes that examples are stored in metadata. + examples = [dict(e.metadata) for e in documents] + # If example keys are provided, filter examples to those keys. + if self.example_keys: + examples = [{k: eg[k] for k in self.example_keys} for eg in examples] + return examples + + def add_example(self, example: dict[str, str]) -> str: + """Add a new example to vectorstore. + + Args: + example: A dictionary with keys as input variables + and values as their values. + + Returns: + The ID of the added example. + """ + ids = self.vectorstore.add_texts( + [self._example_to_text(example, self.input_keys)], metadatas=[example] + ) + return ids[0] + + async def aadd_example(self, example: dict[str, str]) -> str: + """Async add new example to vectorstore. + + Args: + example: A dictionary with keys as input variables + and values as their values. + + Returns: + The ID of the added example. + """ + ids = await self.vectorstore.aadd_texts( + [self._example_to_text(example, self.input_keys)], metadatas=[example] + ) + return ids[0] + + +class SemanticSimilarityExampleSelector(_VectorStoreExampleSelector): + """Select examples based on semantic similarity.""" + + def select_examples(self, input_variables: dict[str, str]) -> list[dict[str, Any]]: + """Select examples based on semantic similarity. + + Args: + input_variables: The input variables to use for search. + + Returns: + The selected examples. + """ + # Get the docs with the highest similarity. + vectorstore_kwargs = self.vectorstore_kwargs or {} + example_docs = self.vectorstore.similarity_search( + self._example_to_text(input_variables, self.input_keys), + k=self.k, + **vectorstore_kwargs, + ) + return self._documents_to_examples(example_docs) + + async def aselect_examples( + self, input_variables: dict[str, str] + ) -> list[dict[str, Any]]: + """Asynchronously select examples based on semantic similarity. + + Args: + input_variables: The input variables to use for search. + + Returns: + The selected examples. + """ + # Get the docs with the highest similarity. + vectorstore_kwargs = self.vectorstore_kwargs or {} + example_docs = await self.vectorstore.asimilarity_search( + self._example_to_text(input_variables, self.input_keys), + k=self.k, + **vectorstore_kwargs, + ) + return self._documents_to_examples(example_docs) + + @classmethod + def from_examples( + cls, + examples: list[dict[str, str]], + embeddings: Embeddings, + vectorstore_cls: type[VectorStore], + k: int = 4, + input_keys: list[str] | None = None, + *, + example_keys: list[str] | None = None, + vectorstore_kwargs: dict[str, Any] | None = None, + **vectorstore_cls_kwargs: Any, + ) -> SemanticSimilarityExampleSelector: + """Create k-shot example selector using example list and embeddings. + + Reshuffles examples dynamically based on query similarity. + + Args: + examples: List of examples to use in the prompt. + embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings(). + vectorstore_cls: A vector store DB interface class, e.g. FAISS. + k: Number of examples to select. + input_keys: If provided, the search is based on the input variables + instead of all variables. + example_keys: If provided, keys to filter examples to. + vectorstore_kwargs: Extra arguments passed to similarity_search function + of the `VectorStore`. + vectorstore_cls_kwargs: optional kwargs containing url for vector store + + Returns: + The ExampleSelector instantiated, backed by a vector store. + """ + string_examples = [cls._example_to_text(eg, input_keys) for eg in examples] + vectorstore = vectorstore_cls.from_texts( + string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs + ) + return cls( + vectorstore=vectorstore, + k=k, + input_keys=input_keys, + example_keys=example_keys, + vectorstore_kwargs=vectorstore_kwargs, + ) + + @classmethod + async def afrom_examples( + cls, + examples: list[dict[str, str]], + embeddings: Embeddings, + vectorstore_cls: type[VectorStore], + k: int = 4, + input_keys: list[str] | None = None, + *, + example_keys: list[str] | None = None, + vectorstore_kwargs: dict[str, Any] | None = None, + **vectorstore_cls_kwargs: Any, + ) -> SemanticSimilarityExampleSelector: + """Async create k-shot example selector using example list and embeddings. + + Reshuffles examples dynamically based on query similarity. + + Args: + examples: List of examples to use in the prompt. + embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings(). + vectorstore_cls: A vector store DB interface class, e.g. FAISS. + k: Number of examples to select. + input_keys: If provided, the search is based on the input variables + instead of all variables. + example_keys: If provided, keys to filter examples to. + vectorstore_kwargs: Extra arguments passed to similarity_search function + of the `VectorStore`. + vectorstore_cls_kwargs: optional kwargs containing url for vector store + + Returns: + The ExampleSelector instantiated, backed by a vector store. + """ + string_examples = [cls._example_to_text(eg, input_keys) for eg in examples] + vectorstore = await vectorstore_cls.afrom_texts( + string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs + ) + return cls( + vectorstore=vectorstore, + k=k, + input_keys=input_keys, + example_keys=example_keys, + vectorstore_kwargs=vectorstore_kwargs, + ) + + +class MaxMarginalRelevanceExampleSelector(_VectorStoreExampleSelector): + """Select examples based on Max Marginal Relevance. + + This was shown to improve performance in this paper: + https://arxiv.org/pdf/2211.13892.pdf + """ + + fetch_k: int = 20 + """Number of examples to fetch to rerank.""" + + def select_examples(self, input_variables: dict[str, str]) -> list[dict[str, Any]]: + """Select examples based on Max Marginal Relevance. + + Args: + input_variables: The input variables to use for search. + + Returns: + The selected examples. + """ + example_docs = self.vectorstore.max_marginal_relevance_search( + self._example_to_text(input_variables, self.input_keys), + k=self.k, + fetch_k=self.fetch_k, + ) + return self._documents_to_examples(example_docs) + + async def aselect_examples( + self, input_variables: dict[str, str] + ) -> list[dict[str, Any]]: + """Asynchronously select examples based on Max Marginal Relevance. + + Args: + input_variables: The input variables to use for search. + + Returns: + The selected examples. + """ + example_docs = await self.vectorstore.amax_marginal_relevance_search( + self._example_to_text(input_variables, self.input_keys), + k=self.k, + fetch_k=self.fetch_k, + ) + return self._documents_to_examples(example_docs) + + @classmethod + def from_examples( + cls, + examples: list[dict[str, str]], + embeddings: Embeddings, + vectorstore_cls: type[VectorStore], + k: int = 4, + input_keys: list[str] | None = None, + fetch_k: int = 20, + example_keys: list[str] | None = None, + vectorstore_kwargs: dict[str, Any] | None = None, + **vectorstore_cls_kwargs: Any, + ) -> MaxMarginalRelevanceExampleSelector: + """Create k-shot example selector using example list and embeddings. + + Reshuffles examples dynamically based on Max Marginal Relevance. + + Args: + examples: List of examples to use in the prompt. + embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings(). + vectorstore_cls: A vector store DB interface class, e.g. FAISS. + k: Number of examples to select. + fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm. + input_keys: If provided, the search is based on the input variables + instead of all variables. + example_keys: If provided, keys to filter examples to. + vectorstore_kwargs: Extra arguments passed to similarity_search function + of the `VectorStore`. + vectorstore_cls_kwargs: optional kwargs containing url for vector store + + Returns: + The ExampleSelector instantiated, backed by a vector store. + """ + string_examples = [cls._example_to_text(eg, input_keys) for eg in examples] + vectorstore = vectorstore_cls.from_texts( + string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs + ) + return cls( + vectorstore=vectorstore, + k=k, + fetch_k=fetch_k, + input_keys=input_keys, + example_keys=example_keys, + vectorstore_kwargs=vectorstore_kwargs, + ) + + @classmethod + async def afrom_examples( + cls, + examples: list[dict[str, str]], + embeddings: Embeddings, + vectorstore_cls: type[VectorStore], + *, + k: int = 4, + input_keys: list[str] | None = None, + fetch_k: int = 20, + example_keys: list[str] | None = None, + vectorstore_kwargs: dict[str, Any] | None = None, + **vectorstore_cls_kwargs: Any, + ) -> MaxMarginalRelevanceExampleSelector: + """Create k-shot example selector using example list and embeddings. + + Reshuffles examples dynamically based on Max Marginal Relevance. + + Args: + examples: List of examples to use in the prompt. + embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings(). + vectorstore_cls: A vector store DB interface class, e.g. FAISS. + k: Number of examples to select. + fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm. + input_keys: If provided, the search is based on the input variables + instead of all variables. + example_keys: If provided, keys to filter examples to. + vectorstore_kwargs: Extra arguments passed to similarity_search function + of the `VectorStore`. + vectorstore_cls_kwargs: optional kwargs containing url for vector store + + Returns: + The ExampleSelector instantiated, backed by a vector store. + """ + string_examples = [cls._example_to_text(eg, input_keys) for eg in examples] + vectorstore = await vectorstore_cls.afrom_texts( + string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs + ) + return cls( + vectorstore=vectorstore, + k=k, + fetch_k=fetch_k, + input_keys=input_keys, + example_keys=example_keys, + vectorstore_kwargs=vectorstore_kwargs, + ) diff --git a/libs/core/langchain_core/exceptions.py b/libs/core/langchain_core/exceptions.py new file mode 100644 index 0000000..f58754c --- /dev/null +++ b/libs/core/langchain_core/exceptions.py @@ -0,0 +1,111 @@ +"""Custom **exceptions** for LangChain.""" + +from enum import Enum +from typing import Any + + +class LangChainException(Exception): # noqa: N818 + """General LangChain exception.""" + + +class TracerException(LangChainException): + """Base class for exceptions in tracers module.""" + + +class OutputParserException(ValueError, LangChainException): # noqa: N818 + """Exception that output parsers should raise to signify a parsing error. + + This exists to differentiate parsing errors from other code or execution errors + that also may arise inside the output parser. + + `OutputParserException` will be available to catch and handle in ways to fix the + parsing error, while other errors will be raised. + """ + + def __init__( + self, + error: Any, + observation: str | None = None, + llm_output: str | None = None, + send_to_llm: bool = False, # noqa: FBT001,FBT002 + ): + """Create an `OutputParserException`. + + Args: + error: The error that's being re-raised or an error message. + observation: String explanation of error which can be passed to a model to + try and remediate the issue. + llm_output: String model output which is error-ing. + + send_to_llm: Whether to send the observation and llm_output back to an Agent + after an `OutputParserException` has been raised. + + This gives the underlying model driving the agent the context that the + previous output was improperly structured, in the hopes that it will + update the output to the correct format. + + Raises: + ValueError: If `send_to_llm` is `True` but either observation or + `llm_output` are not provided. + """ + if isinstance(error, str): + error = create_message( + message=error, error_code=ErrorCode.OUTPUT_PARSING_FAILURE + ) + + super().__init__(error) + if send_to_llm and (observation is None or llm_output is None): + msg = ( + "Arguments 'observation' & 'llm_output'" + " are required if 'send_to_llm' is True" + ) + raise ValueError(msg) + self.observation = observation + self.llm_output = llm_output + self.send_to_llm = send_to_llm + + +class ContextOverflowError(LangChainException): + """Exception raised when input exceeds the model's context limit. + + This exception is raised by chat models when the input tokens exceed + the maximum context window supported by the model. + """ + + +class ErrorCode(Enum): + """Error codes.""" + + INVALID_PROMPT_INPUT = "INVALID_PROMPT_INPUT" + INVALID_TOOL_RESULTS = "INVALID_TOOL_RESULTS" # Used in JS; not Py (yet) + MESSAGE_COERCION_FAILURE = "MESSAGE_COERCION_FAILURE" + MODEL_AUTHENTICATION = "MODEL_AUTHENTICATION" # Used in JS; not Py (yet) + MODEL_NOT_FOUND = "MODEL_NOT_FOUND" # Used in JS; not Py (yet) + MODEL_RATE_LIMIT = "MODEL_RATE_LIMIT" # Used in JS; not Py (yet) + OUTPUT_PARSING_FAILURE = "OUTPUT_PARSING_FAILURE" + + +def create_message(*, message: str, error_code: ErrorCode) -> str: + """Create a message with a link to the LangChain troubleshooting guide. + + Args: + message: The message to display. + error_code: The error code to display. + + Returns: + The full message with the troubleshooting link. + + Example: + ```python + create_message( + message="Failed to parse output", + error_code=ErrorCode.OUTPUT_PARSING_FAILURE, + ) + "Failed to parse output. For troubleshooting, visit: ..." + ``` + """ + return ( + f"{message}\n" + "For troubleshooting, visit: https://docs.langchain.com/oss/python/langchain" + f"/errors/{error_code.value} " + ) diff --git a/libs/core/langchain_core/globals.py b/libs/core/langchain_core/globals.py new file mode 100644 index 0000000..6880f67 --- /dev/null +++ b/libs/core/langchain_core/globals.py @@ -0,0 +1,72 @@ +"""Global values and configuration that apply to all of LangChain.""" + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from langchain_core.caches import BaseCache + + +# DO NOT USE THESE VALUES DIRECTLY! +# Use them only via `get_()` and `set_()` below, +# or else your code may behave unexpectedly with other uses of these global settings: +# https://github.com/langchain-ai/langchain/pull/11311#issuecomment-1743780004 +_verbose: bool = False +_debug: bool = False +_llm_cache: Optional["BaseCache"] = None + + +def set_verbose(value: bool) -> None: # noqa: FBT001 + """Set a new value for the `verbose` global setting. + + Args: + value: The new value for the `verbose` global setting. + """ + global _verbose # noqa: PLW0603 + _verbose = value + + +def get_verbose() -> bool: + """Get the value of the `verbose` global setting. + + Returns: + The value of the `verbose` global setting. + """ + return _verbose + + +def set_debug(value: bool) -> None: # noqa: FBT001 + """Set a new value for the `debug` global setting. + + Args: + value: The new value for the `debug` global setting. + """ + global _debug # noqa: PLW0603 + _debug = value + + +def get_debug() -> bool: + """Get the value of the `debug` global setting. + + Returns: + The value of the `debug` global setting. + """ + return _debug + + +def set_llm_cache(value: Optional["BaseCache"]) -> None: + """Set a new LLM cache, overwriting the previous value, if any. + + Args: + value: The new LLM cache to use. If `None`, the LLM cache is disabled. + """ + global _llm_cache # noqa: PLW0603 + _llm_cache = value + + +def get_llm_cache() -> Optional["BaseCache"]: + """Get the value of the `llm_cache` global setting. + + Returns: + The value of the `llm_cache` global setting. + """ + return _llm_cache diff --git a/libs/core/langchain_core/indexing/__init__.py b/libs/core/langchain_core/indexing/__init__.py new file mode 100644 index 0000000..ceb25d2 --- /dev/null +++ b/libs/core/langchain_core/indexing/__init__.py @@ -0,0 +1,53 @@ +"""Code to help indexing data into a vectorstore. + +This package contains helper logic to help deal with indexing data into +a `VectorStore` while avoiding duplicated content and over-writing content +if it's unchanged. +""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.indexing.api import IndexingResult, aindex, index + from langchain_core.indexing.base import ( + DeleteResponse, + DocumentIndex, + InMemoryRecordManager, + RecordManager, + UpsertResponse, + ) + +__all__ = ( + "DeleteResponse", + "DocumentIndex", + "InMemoryRecordManager", + "IndexingResult", + "RecordManager", + "UpsertResponse", + "aindex", + "index", +) + +_dynamic_imports = { + "aindex": "api", + "index": "api", + "IndexingResult": "api", + "DeleteResponse": "base", + "DocumentIndex": "base", + "InMemoryRecordManager": "base", + "RecordManager": "base", + "UpsertResponse": "base", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/indexing/api.py b/libs/core/langchain_core/indexing/api.py new file mode 100644 index 0000000..19f1a47 --- /dev/null +++ b/libs/core/langchain_core/indexing/api.py @@ -0,0 +1,954 @@ +"""Module contains logic for indexing documents into vector stores.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +import warnings +from itertools import islice +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypedDict, + TypeVar, + cast, +) + +from langchain_core.document_loaders.base import BaseLoader +from langchain_core.documents import Document +from langchain_core.exceptions import LangChainException +from langchain_core.indexing.base import DocumentIndex, RecordManager +from langchain_core.vectorstores import VectorStore + +if TYPE_CHECKING: + from collections.abc import ( + AsyncIterable, + AsyncIterator, + Callable, + Iterable, + Iterator, + Sequence, + ) + +# Magic UUID to use as a namespace for hashing. +# Used to try and generate a unique UUID for each document +# from hashing the document content and metadata. +NAMESPACE_UUID = uuid.UUID(int=1984) + + +T = TypeVar("T") + + +def _hash_string_to_uuid(input_string: str) -> str: + """Hashes a string and returns the corresponding UUID.""" + hash_value = hashlib.sha1( + input_string.encode("utf-8"), usedforsecurity=False + ).hexdigest() + return str(uuid.uuid5(NAMESPACE_UUID, hash_value)) + + +_WARNED_ABOUT_SHA1: bool = False + + +def _warn_about_sha1() -> None: + """Emit a one-time warning about SHA-1 collision weaknesses.""" + # Global variable OK in this case + global _WARNED_ABOUT_SHA1 # noqa: PLW0603 + if not _WARNED_ABOUT_SHA1: + warnings.warn( + "Using SHA-1 for document hashing. SHA-1 is *not* " + "collision-resistant; a motivated attacker can construct distinct inputs " + "that map to the same fingerprint. If this matters in your " + "threat model, switch to a stronger algorithm such " + "as 'blake2b', 'sha256', or 'sha512' by specifying " + " `key_encoder` parameter in the `index` or `aindex` function. ", + category=UserWarning, + stacklevel=2, + ) + _WARNED_ABOUT_SHA1 = True + + +def _hash_string( + input_string: str, *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"] +) -> uuid.UUID: + """Hash *input_string* to a deterministic UUID using the configured algorithm.""" + if algorithm == "sha1": + _warn_about_sha1() + hash_value = _calculate_hash(input_string, algorithm) + return uuid.uuid5(NAMESPACE_UUID, hash_value) + + +def _hash_nested_dict( + data: dict[Any, Any], *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"] +) -> uuid.UUID: + """Hash a nested dictionary to a UUID using the configured algorithm.""" + serialized_data = json.dumps(data, sort_keys=True) + return _hash_string(serialized_data, algorithm=algorithm) + + +def _batch(size: int, iterable: Iterable[T]) -> Iterator[list[T]]: + """Utility batching function.""" + if size <= 0: + msg = f"Batch size must be a positive integer, got {size}." + raise ValueError(msg) + it = iter(iterable) + while True: + chunk = list(islice(it, size)) + if not chunk: + return + yield chunk + + +async def _abatch(size: int, iterable: AsyncIterable[T]) -> AsyncIterator[list[T]]: + """Utility batching function.""" + if size <= 0: + msg = f"Batch size must be a positive integer, got {size}." + raise ValueError(msg) + batch: list[T] = [] + async for element in iterable: + if len(batch) < size: + batch.append(element) + + if len(batch) >= size: + yield batch + batch = [] + + if batch: + yield batch + + +def _get_source_id_assigner( + source_id_key: str | Callable[[Document], str] | None, +) -> Callable[[Document], str | None]: + """Get the source id from the document.""" + if source_id_key is None: + return lambda _doc: None + if isinstance(source_id_key, str): + return lambda doc: doc.metadata[source_id_key] + if callable(source_id_key): + return source_id_key + msg = ( # type: ignore[unreachable] + f"source_id_key should be either None, a string or a callable. " + f"Got {source_id_key} of type {type(source_id_key)}." + ) + raise ValueError(msg) + + +def _deduplicate_in_order( + hashed_documents: Iterable[Document], +) -> Iterator[Document]: + """Deduplicate a list of hashed documents while preserving order.""" + seen: set[str] = set() + + for hashed_doc in hashed_documents: + if hashed_doc.id not in seen: + # At this stage, the id is guaranteed to be a string. + # Avoiding unnecessary run time checks. + seen.add(cast("str", hashed_doc.id)) + yield hashed_doc + + +class IndexingException(LangChainException): + """Raised when an indexing operation fails.""" + + +def _calculate_hash( + text: str, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"] +) -> str: + """Return a hexadecimal digest of *text* using *algorithm*.""" + if algorithm == "sha1": + # Calculate the SHA-1 hash and return it as a UUID. + digest = hashlib.sha1(text.encode("utf-8"), usedforsecurity=False).hexdigest() + return str(uuid.uuid5(NAMESPACE_UUID, digest)) + if algorithm == "blake2b": + return hashlib.blake2b(text.encode("utf-8")).hexdigest() + if algorithm == "sha256": + return hashlib.sha256(text.encode("utf-8")).hexdigest() + if algorithm == "sha512": + return hashlib.sha512(text.encode("utf-8")).hexdigest() + msg = f"Unsupported hashing algorithm: {algorithm}" # type: ignore[unreachable] + raise ValueError(msg) + + +def _get_document_with_hash( + document: Document, + *, + key_encoder: Callable[[Document], str] + | Literal["sha1", "sha256", "sha512", "blake2b"], +) -> Document: + """Calculate a hash of the document, and assign it to the uid. + + When using one of the predefined hashing algorithms, the hash is calculated + by hashing the content and the metadata of the document. + + Args: + document: Document to hash. + key_encoder: Hashing algorithm to use for hashing the document. + If not provided, a default encoder using SHA-1 will be used. + SHA-1 is not collision-resistant, and a motivated attacker + could craft two different texts that hash to the + same cache key. + + New applications should use one of the alternative encoders + or provide a custom and strong key encoder function to avoid this risk. + + When changing the key encoder, you must change the + index as well to avoid duplicated documents in the cache. + + Raises: + ValueError: If the metadata cannot be serialized using json. + + Returns: + Document with a unique identifier based on the hash of the content and metadata. + """ + metadata: dict[str, Any] = dict(document.metadata or {}) + + if callable(key_encoder): + # If key_encoder is a callable, we use it to generate the hash. + hash_ = key_encoder(document) + else: + # The hashes are calculated separate for the content and the metadata. + content_hash = _calculate_hash(document.page_content, algorithm=key_encoder) + try: + serialized_meta = json.dumps(metadata, sort_keys=True) + except Exception as e: + msg = ( + f"Failed to hash metadata: {e}. " + f"Please use a dict that can be serialized using json." + ) + raise ValueError(msg) from e + metadata_hash = _calculate_hash(serialized_meta, algorithm=key_encoder) + hash_ = _calculate_hash(content_hash + metadata_hash, algorithm=key_encoder) + + return Document( + # Assign a unique identifier based on the hash. + id=hash_, + page_content=document.page_content, + metadata=document.metadata, + ) + + +# This internal abstraction was imported by the langchain package internally, so +# we keep it here for backwards compatibility. +class _HashedDocument: + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Raise an error if this class is instantiated.""" + msg = ( + "_HashedDocument is an internal abstraction that was deprecated in " + " langchain-core 0.3.63. This abstraction is marked as private and " + " should not have been used directly. If you are seeing this error, please " + " update your code appropriately." + ) + raise NotImplementedError(msg) + + +def _delete( + vector_store: VectorStore | DocumentIndex, + ids: list[str], +) -> None: + """Delete documents from a vector store or document index by their IDs. + + Args: + vector_store: The vector store or document index to delete from. + ids: List of document IDs to delete. + + Raises: + IndexingException: If the delete operation fails. + TypeError: If the `vector_store` is neither a `VectorStore` nor a + `DocumentIndex`. + """ + if isinstance(vector_store, VectorStore): + delete_ok = vector_store.delete(ids) + if delete_ok is not None and delete_ok is False: + msg = "The delete operation to VectorStore failed." + raise IndexingException(msg) + elif isinstance(vector_store, DocumentIndex): + delete_response = vector_store.delete(ids) + if "num_failed" in delete_response and delete_response["num_failed"] > 0: + msg = "The delete operation to DocumentIndex failed." + raise IndexingException(msg) + else: + msg = ( # type: ignore[unreachable] + f"Vectorstore should be either a VectorStore or a DocumentIndex. " + f"Got {type(vector_store)}." + ) + raise TypeError(msg) + + +# PUBLIC API + + +class IndexingResult(TypedDict): + """Return a detailed a breakdown of the result of the indexing operation.""" + + num_added: int + """Number of added documents.""" + num_updated: int + """Number of updated documents because they were not up to date.""" + num_deleted: int + """Number of deleted documents.""" + num_skipped: int + """Number of skipped documents because they were already up to date.""" + + +def index( + docs_source: BaseLoader | Iterable[Document], + record_manager: RecordManager, + vector_store: VectorStore | DocumentIndex, + *, + batch_size: int = 100, + cleanup: Literal["incremental", "full", "scoped_full"] | None = None, + source_id_key: str | Callable[[Document], str] | None = None, + cleanup_batch_size: int = 1_000, + force_update: bool = False, + key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"] + | Callable[[Document], str] = "sha1", + upsert_kwargs: dict[str, Any] | None = None, +) -> IndexingResult: + """Index data from the loader into the vector store. + + Indexing functionality uses a manager to keep track of which documents + are in the vector store. + + This allows us to keep track of which documents were updated, and which + documents were deleted, which documents should be skipped. + + For the time being, documents are indexed using their hashes, and users + are not able to specify the uid of the document. + + !!! warning "Behavior changed in `langchain-core` 0.3.25" + + Added `scoped_full` cleanup mode. + + !!! warning + + * In full mode, the loader should be returning + the entire dataset, and not just a subset of the dataset. + Otherwise, the auto_cleanup will remove documents that it is not + supposed to. + * In incremental mode, if documents associated with a particular + source id appear across different batches, the indexing API + will do some redundant work. This will still result in the + correct end state of the index, but will unfortunately not be + 100% efficient. For example, if a given document is split into 15 + chunks, and we index them using a batch size of 5, we'll have 3 batches + all with the same source id. In general, to avoid doing too much + redundant work select as big a batch size as possible. + * The `scoped_full` mode is suitable if determining an appropriate batch size + is challenging or if your data loader cannot return the entire dataset at + once. This mode keeps track of source IDs in memory, which should be fine + for most use cases. If your dataset is large (10M+ docs), you will likely + need to parallelize the indexing process regardless. + + Args: + docs_source: Data loader or iterable of documents to index. + record_manager: Timestamped set to keep track of which documents were + updated. + vector_store: `VectorStore` or DocumentIndex to index the documents into. + batch_size: Batch size to use when indexing. + cleanup: How to handle clean up of documents. + + - incremental: Cleans up all documents that haven't been updated AND + that are associated with source IDs that were seen during indexing. + Clean up is done continuously during indexing helping to minimize the + probability of users seeing duplicated content. + - full: Delete all documents that have not been returned by the loader + during this run of indexing. + Clean up runs after all documents have been indexed. + This means that users may see duplicated content during indexing. + - scoped_full: Similar to Full, but only deletes all documents + that haven't been updated AND that are associated with + source IDs that were seen during indexing. + - None: Do not delete any documents. + source_id_key: Optional key that helps identify the original source + of the document. + cleanup_batch_size: Batch size to use when cleaning up documents. + force_update: Force update documents even if they are present in the + record manager. Useful if you are re-indexing with updated embeddings. + key_encoder: Hashing algorithm to use for hashing the document content and + metadata. Options include "blake2b", "sha256", and "sha512". + + !!! version-added "Added in `langchain-core` 0.3.66" + + key_encoder: Hashing algorithm to use for hashing the document. + If not provided, a default encoder using SHA-1 will be used. + SHA-1 is not collision-resistant, and a motivated attacker + could craft two different texts that hash to the + same cache key. + + New applications should use one of the alternative encoders + or provide a custom and strong key encoder function to avoid this risk. + + When changing the key encoder, you must change the + index as well to avoid duplicated documents in the cache. + upsert_kwargs: Additional keyword arguments to pass to the add_documents + method of the `VectorStore` or the upsert method of the DocumentIndex. + For example, you can use this to specify a custom vector_field: + upsert_kwargs={"vector_field": "embedding"} + !!! version-added "Added in `langchain-core` 0.3.10" + + Returns: + Indexing result which contains information about how many documents + were added, updated, deleted, or skipped. + + Raises: + ValueError: If cleanup mode is not one of 'incremental', 'full' or None + ValueError: If cleanup mode is incremental and source_id_key is None. + ValueError: If `VectorStore` does not have + "delete" and "add_documents" required methods. + ValueError: If source_id_key is not None, but is not a string or callable. + TypeError: If `vectorstore` is not a `VectorStore` or a DocumentIndex. + AssertionError: If `source_id` is None when cleanup mode is incremental. + (should be unreachable code). + """ + # Behavior is deprecated, but we keep it for backwards compatibility. + # # Warn only once per process. + if key_encoder == "sha1": + _warn_about_sha1() + + if cleanup not in {"incremental", "full", "scoped_full", None}: + msg = ( + f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. " + f"Got {cleanup}." + ) + raise ValueError(msg) + + if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None: + msg = ( + "Source id key is required when cleanup mode is incremental or scoped_full." + ) + raise ValueError(msg) + + destination = vector_store # Renaming internally for clarity + + # If it's a vectorstore, let's check if it has the required methods. + if isinstance(destination, VectorStore): + # Check that the Vectorstore has required methods implemented + methods = ["delete", "add_documents"] + + for method in methods: + if not hasattr(destination, method): + msg = ( + f"Vectorstore {destination} does not have required method {method}" + ) + raise ValueError(msg) + + if type(destination).delete == VectorStore.delete: + # Checking if the VectorStore has overridden the default delete method + # implementation which just raises a NotImplementedError + msg = "Vectorstore has not implemented the delete method" + raise ValueError(msg) + elif isinstance(destination, DocumentIndex): + pass + else: + msg = ( # type: ignore[unreachable] + f"Vectorstore should be either a VectorStore or a DocumentIndex. " + f"Got {type(destination)}." + ) + raise TypeError(msg) + + if isinstance(docs_source, BaseLoader): + try: + doc_iterator = docs_source.lazy_load() + except NotImplementedError: + doc_iterator = iter(docs_source.load()) + else: + doc_iterator = iter(docs_source) + + source_id_assigner = _get_source_id_assigner(source_id_key) + + # Mark when the update started. + index_start_dt = record_manager.get_time() + num_added = 0 + num_skipped = 0 + num_updated = 0 + num_deleted = 0 + scoped_full_cleanup_source_ids: set[str] = set() + + for doc_batch in _batch(batch_size, doc_iterator): + # Track original batch size before deduplication + original_batch_size = len(doc_batch) + + hashed_docs = list( + _deduplicate_in_order( + [ + _get_document_with_hash(doc, key_encoder=key_encoder) + for doc in doc_batch + ] + ) + ) + # Count documents removed by within-batch deduplication + num_skipped += original_batch_size - len(hashed_docs) + + source_ids: Sequence[str | None] = [ + source_id_assigner(hashed_doc) for hashed_doc in hashed_docs + ] + + if cleanup in {"incremental", "scoped_full"}: + # Source IDs are required. + for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False): + if source_id is None: + msg = ( + f"Source IDs are required when cleanup mode is " + f"incremental or scoped_full. " + f"Document that starts with " + f"content: {hashed_doc.page_content[:100]} " + f"was not assigned as source id." + ) + raise ValueError(msg) + if cleanup == "scoped_full": + scoped_full_cleanup_source_ids.add(source_id) + # Source IDs cannot be None after for loop above. + source_ids = cast("Sequence[str]", source_ids) + + exists_batch = record_manager.exists( + cast("Sequence[str]", [doc.id for doc in hashed_docs]) + ) + + # Filter out documents that already exist in the record store. + uids = [] + docs_to_index = [] + uids_to_refresh = [] + seen_docs: set[str] = set() + for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False): + hashed_id = cast("str", hashed_doc.id) + if doc_exists: + if force_update: + seen_docs.add(hashed_id) + else: + uids_to_refresh.append(hashed_id) + continue + uids.append(hashed_id) + docs_to_index.append(hashed_doc) + + # Update refresh timestamp + if uids_to_refresh: + record_manager.update(uids_to_refresh, time_at_least=index_start_dt) + num_skipped += len(uids_to_refresh) + + # Be pessimistic and assume that all vector store write will fail. + # First write to vector store + if docs_to_index: + if isinstance(destination, VectorStore): + destination.add_documents( + docs_to_index, + ids=uids, + batch_size=batch_size, + **(upsert_kwargs or {}), + ) + elif isinstance(destination, DocumentIndex): + destination.upsert( + docs_to_index, + **(upsert_kwargs or {}), + ) + + num_added += len(docs_to_index) - len(seen_docs) + num_updated += len(seen_docs) + + # And only then update the record store. + # Update ALL records, even if they already exist since we want to refresh + # their timestamp. + record_manager.update( + cast("Sequence[str]", [doc.id for doc in hashed_docs]), + group_ids=source_ids, + time_at_least=index_start_dt, + ) + + # If source IDs are provided, we can do the deletion incrementally! + if cleanup == "incremental": + # Get the uids of the documents that were not returned by the loader. + # mypy isn't good enough to determine that source IDs cannot be None + # here due to a check that's happening above, so we check again. + for source_id in source_ids: + if source_id is None: + msg = ( + "source_id cannot be None at this point. " + "Reached unreachable code." + ) + raise AssertionError(msg) + + source_ids_ = cast("Sequence[str]", source_ids) + + while uids_to_delete := record_manager.list_keys( + group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size + ): + # Then delete from vector store. + _delete(destination, uids_to_delete) + # First delete from record store. + record_manager.delete_keys(uids_to_delete) + num_deleted += len(uids_to_delete) + + if cleanup == "full" or ( + cleanup == "scoped_full" and scoped_full_cleanup_source_ids + ): + delete_group_ids: Sequence[str] | None = None + if cleanup == "scoped_full": + delete_group_ids = list(scoped_full_cleanup_source_ids) + while uids_to_delete := record_manager.list_keys( + group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size + ): + # First delete from record store. + _delete(destination, uids_to_delete) + # Then delete from record manager. + record_manager.delete_keys(uids_to_delete) + num_deleted += len(uids_to_delete) + + return { + "num_added": num_added, + "num_updated": num_updated, + "num_skipped": num_skipped, + "num_deleted": num_deleted, + } + + +# Define an asynchronous generator function +async def _to_async_iterator(iterator: Iterable[T]) -> AsyncIterator[T]: + """Convert an iterable to an async iterator.""" + for item in iterator: + yield item + + +async def _adelete( + vector_store: VectorStore | DocumentIndex, + ids: list[str], +) -> None: + if isinstance(vector_store, VectorStore): + delete_ok = await vector_store.adelete(ids) + if delete_ok is not None and delete_ok is False: + msg = "The delete operation to VectorStore failed." + raise IndexingException(msg) + elif isinstance(vector_store, DocumentIndex): + delete_response = await vector_store.adelete(ids) + if "num_failed" in delete_response and delete_response["num_failed"] > 0: + msg = "The delete operation to DocumentIndex failed." + raise IndexingException(msg) + else: + msg = ( # type: ignore[unreachable] + f"Vectorstore should be either a VectorStore or a DocumentIndex. " + f"Got {type(vector_store)}." + ) + raise TypeError(msg) + + +async def aindex( + docs_source: BaseLoader | Iterable[Document] | AsyncIterator[Document], + record_manager: RecordManager, + vector_store: VectorStore | DocumentIndex, + *, + batch_size: int = 100, + cleanup: Literal["incremental", "full", "scoped_full"] | None = None, + source_id_key: str | Callable[[Document], str] | None = None, + cleanup_batch_size: int = 1_000, + force_update: bool = False, + key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"] + | Callable[[Document], str] = "sha1", + upsert_kwargs: dict[str, Any] | None = None, +) -> IndexingResult: + """Async index data from the loader into the vector store. + + Indexing functionality uses a manager to keep track of which documents + are in the vector store. + + This allows us to keep track of which documents were updated, and which + documents were deleted, which documents should be skipped. + + For the time being, documents are indexed using their hashes, and users + are not able to specify the uid of the document. + + !!! warning "Behavior changed in `langchain-core` 0.3.25" + + Added `scoped_full` cleanup mode. + + !!! warning + + * In full mode, the loader should be returning + the entire dataset, and not just a subset of the dataset. + Otherwise, the auto_cleanup will remove documents that it is not + supposed to. + * In incremental mode, if documents associated with a particular + source id appear across different batches, the indexing API + will do some redundant work. This will still result in the + correct end state of the index, but will unfortunately not be + 100% efficient. For example, if a given document is split into 15 + chunks, and we index them using a batch size of 5, we'll have 3 batches + all with the same source id. In general, to avoid doing too much + redundant work select as big a batch size as possible. + * The `scoped_full` mode is suitable if determining an appropriate batch size + is challenging or if your data loader cannot return the entire dataset at + once. This mode keeps track of source IDs in memory, which should be fine + for most use cases. If your dataset is large (10M+ docs), you will likely + need to parallelize the indexing process regardless. + + Args: + docs_source: Data loader or iterable of documents to index. + record_manager: Timestamped set to keep track of which documents were + updated. + vector_store: `VectorStore` or DocumentIndex to index the documents into. + batch_size: Batch size to use when indexing. + cleanup: How to handle clean up of documents. + + - incremental: Cleans up all documents that haven't been updated AND + that are associated with source IDs that were seen during indexing. + Clean up is done continuously during indexing helping to minimize the + probability of users seeing duplicated content. + - full: Delete all documents that have not been returned by the loader + during this run of indexing. + Clean up runs after all documents have been indexed. + This means that users may see duplicated content during indexing. + - scoped_full: Similar to Full, but only deletes all documents + that haven't been updated AND that are associated with + source IDs that were seen during indexing. + - None: Do not delete any documents. + source_id_key: Optional key that helps identify the original source + of the document. + cleanup_batch_size: Batch size to use when cleaning up documents. + force_update: Force update documents even if they are present in the + record manager. Useful if you are re-indexing with updated embeddings. + key_encoder: Hashing algorithm to use for hashing the document content and + metadata. Options include "blake2b", "sha256", and "sha512". + + !!! version-added "Added in `langchain-core` 0.3.66" + + key_encoder: Hashing algorithm to use for hashing the document. + If not provided, a default encoder using SHA-1 will be used. + SHA-1 is not collision-resistant, and a motivated attacker + could craft two different texts that hash to the + same cache key. + + New applications should use one of the alternative encoders + or provide a custom and strong key encoder function to avoid this risk. + + When changing the key encoder, you must change the + index as well to avoid duplicated documents in the cache. + upsert_kwargs: Additional keyword arguments to pass to the add_documents + method of the `VectorStore` or the upsert method of the DocumentIndex. + For example, you can use this to specify a custom vector_field: + upsert_kwargs={"vector_field": "embedding"} + !!! version-added "Added in `langchain-core` 0.3.10" + + Returns: + Indexing result which contains information about how many documents + were added, updated, deleted, or skipped. + + Raises: + ValueError: If cleanup mode is not one of 'incremental', 'full' or None + ValueError: If cleanup mode is incremental and source_id_key is None. + ValueError: If `VectorStore` does not have + "adelete" and "aadd_documents" required methods. + ValueError: If source_id_key is not None, but is not a string or callable. + TypeError: If `vector_store` is not a `VectorStore` or DocumentIndex. + AssertionError: If `source_id_key` is None when cleanup mode is + incremental or `scoped_full` (should be unreachable). + """ + # Behavior is deprecated, but we keep it for backwards compatibility. + # # Warn only once per process. + if key_encoder == "sha1": + _warn_about_sha1() + + if cleanup not in {"incremental", "full", "scoped_full", None}: + msg = ( + f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. " + f"Got {cleanup}." + ) + raise ValueError(msg) + + if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None: + msg = ( + "Source id key is required when cleanup mode is incremental or scoped_full." + ) + raise ValueError(msg) + + destination = vector_store # Renaming internally for clarity + + # If it's a vectorstore, let's check if it has the required methods. + if isinstance(destination, VectorStore): + # Check that the Vectorstore has required methods implemented + # Check that the Vectorstore has required methods implemented + methods = ["adelete", "aadd_documents"] + + for method in methods: + if not hasattr(destination, method): + msg = ( + f"Vectorstore {destination} does not have required method {method}" + ) + raise ValueError(msg) + + if ( + type(destination).adelete == VectorStore.adelete + and type(destination).delete == VectorStore.delete + ): + # Checking if the VectorStore has overridden the default adelete or delete + # methods implementation which just raises a NotImplementedError + msg = "Vectorstore has not implemented the adelete or delete method" + raise ValueError(msg) + elif isinstance(destination, DocumentIndex): + pass + else: + msg = ( # type: ignore[unreachable] + f"Vectorstore should be either a VectorStore or a DocumentIndex. " + f"Got {type(destination)}." + ) + raise TypeError(msg) + async_doc_iterator: AsyncIterator[Document] + if isinstance(docs_source, BaseLoader): + try: + async_doc_iterator = docs_source.alazy_load() + except NotImplementedError: + # Exception triggered when neither lazy_load nor alazy_load are implemented. + # * The default implementation of alazy_load uses lazy_load. + # * The default implementation of lazy_load raises NotImplementedError. + # In such a case, we use the load method and convert it to an async + # iterator. + async_doc_iterator = _to_async_iterator(docs_source.load()) + elif hasattr(docs_source, "__aiter__"): + async_doc_iterator = docs_source # type: ignore[assignment] + else: + async_doc_iterator = _to_async_iterator(docs_source) + + source_id_assigner = _get_source_id_assigner(source_id_key) + + # Mark when the update started. + index_start_dt = await record_manager.aget_time() + num_added = 0 + num_skipped = 0 + num_updated = 0 + num_deleted = 0 + scoped_full_cleanup_source_ids: set[str] = set() + + async for doc_batch in _abatch(batch_size, async_doc_iterator): + # Track original batch size before deduplication + original_batch_size = len(doc_batch) + + hashed_docs = list( + _deduplicate_in_order( + [ + _get_document_with_hash(doc, key_encoder=key_encoder) + for doc in doc_batch + ] + ) + ) + # Count documents removed by within-batch deduplication + num_skipped += original_batch_size - len(hashed_docs) + + source_ids: Sequence[str | None] = [ + source_id_assigner(doc) for doc in hashed_docs + ] + + if cleanup in {"incremental", "scoped_full"}: + # If the cleanup mode is incremental, source IDs are required. + for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False): + if source_id is None: + msg = ( + f"Source IDs are required when cleanup mode is " + f"incremental or scoped_full. " + f"Document that starts with " + f"content: {hashed_doc.page_content[:100]} " + f"was not assigned as source id." + ) + raise ValueError(msg) + if cleanup == "scoped_full": + scoped_full_cleanup_source_ids.add(source_id) + # Source IDs cannot be None after for loop above. + source_ids = cast("Sequence[str]", source_ids) + + exists_batch = await record_manager.aexists( + cast("Sequence[str]", [doc.id for doc in hashed_docs]) + ) + + # Filter out documents that already exist in the record store. + uids: list[str] = [] + docs_to_index: list[Document] = [] + uids_to_refresh = [] + seen_docs: set[str] = set() + for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False): + hashed_id = cast("str", hashed_doc.id) + if doc_exists: + if force_update: + seen_docs.add(hashed_id) + else: + uids_to_refresh.append(hashed_id) + continue + uids.append(hashed_id) + docs_to_index.append(hashed_doc) + + if uids_to_refresh: + # Must be updated to refresh timestamp. + await record_manager.aupdate(uids_to_refresh, time_at_least=index_start_dt) + num_skipped += len(uids_to_refresh) + + # Be pessimistic and assume that all vector store write will fail. + # First write to vector store + if docs_to_index: + if isinstance(destination, VectorStore): + await destination.aadd_documents( + docs_to_index, + ids=uids, + batch_size=batch_size, + **(upsert_kwargs or {}), + ) + elif isinstance(destination, DocumentIndex): + await destination.aupsert( + docs_to_index, + **(upsert_kwargs or {}), + ) + num_added += len(docs_to_index) - len(seen_docs) + num_updated += len(seen_docs) + + # And only then update the record store. + # Update ALL records, even if they already exist since we want to refresh + # their timestamp. + await record_manager.aupdate( + cast("Sequence[str]", [doc.id for doc in hashed_docs]), + group_ids=source_ids, + time_at_least=index_start_dt, + ) + + # If source IDs are provided, we can do the deletion incrementally! + + if cleanup == "incremental": + # Get the uids of the documents that were not returned by the loader. + + # mypy isn't good enough to determine that source IDs cannot be None + # here due to a check that's happening above, so we check again. + for source_id in source_ids: + if source_id is None: + msg = ( + "source_id cannot be None at this point. " + "Reached unreachable code." + ) + raise AssertionError(msg) + + source_ids_ = cast("Sequence[str]", source_ids) + + while uids_to_delete := await record_manager.alist_keys( + group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size + ): + # Then delete from vector store. + await _adelete(destination, uids_to_delete) + # First delete from record store. + await record_manager.adelete_keys(uids_to_delete) + num_deleted += len(uids_to_delete) + + if cleanup == "full" or ( + cleanup == "scoped_full" and scoped_full_cleanup_source_ids + ): + delete_group_ids: Sequence[str] | None = None + if cleanup == "scoped_full": + delete_group_ids = list(scoped_full_cleanup_source_ids) + while uids_to_delete := await record_manager.alist_keys( + group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size + ): + # First delete from record store. + await _adelete(destination, uids_to_delete) + # Then delete from record manager. + await record_manager.adelete_keys(uids_to_delete) + num_deleted += len(uids_to_delete) + + return { + "num_added": num_added, + "num_updated": num_updated, + "num_skipped": num_skipped, + "num_deleted": num_deleted, + } diff --git a/libs/core/langchain_core/indexing/base.py b/libs/core/langchain_core/indexing/base.py new file mode 100644 index 0000000..d8a891d --- /dev/null +++ b/libs/core/langchain_core/indexing/base.py @@ -0,0 +1,661 @@ +"""Base classes for indexing.""" + +from __future__ import annotations + +import abc +import time +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, TypedDict + +from typing_extensions import override + +from langchain_core._api import beta +from langchain_core.retrievers import BaseRetriever +from langchain_core.runnables import run_in_executor + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langchain_core.documents import Document + + +class RecordManager(ABC): + """Abstract base class representing the interface for a record manager. + + The record manager abstraction is used by the langchain indexing API. + + The record manager keeps track of which documents have been + written into a `VectorStore` and when they were written. + + The indexing API computes hashes for each document and stores the hash + together with the write time and the source id in the record manager. + + On subsequent indexing runs, the indexing API can check the record manager + to determine which documents have already been indexed and which have not. + + This allows the indexing API to avoid re-indexing documents that have + already been indexed, and to only index new documents. + + The main benefit of this abstraction is that it works across many vectorstores. + To be supported, a `VectorStore` needs to only support the ability to add and + delete documents by ID. Using the record manager, the indexing API will + be able to delete outdated documents and avoid redundant indexing of documents + that have already been indexed. + + The main constraints of this abstraction are: + + 1. It relies on the time-stamps to determine which documents have been + indexed and which have not. This means that the time-stamps must be + monotonically increasing. The timestamp should be the timestamp + as measured by the server to minimize issues. + 2. The record manager is currently implemented separately from the + vectorstore, which means that the overall system becomes distributed + and may create issues with consistency. For example, writing to + record manager succeeds, but corresponding writing to `VectorStore` fails. + """ + + def __init__( + self, + namespace: str, + ) -> None: + """Initialize the record manager. + + Args: + namespace: The namespace for the record manager. + """ + self.namespace = namespace + + @abstractmethod + def create_schema(self) -> None: + """Create the database schema for the record manager.""" + + @abstractmethod + async def acreate_schema(self) -> None: + """Asynchronously create the database schema for the record manager.""" + + @abstractmethod + def get_time(self) -> float: + """Get the current server time as a high resolution timestamp! + + It's important to get this from the server to ensure a monotonic clock, + otherwise there may be data loss when cleaning up old documents! + + Returns: + The current server time as a float timestamp. + """ + + @abstractmethod + async def aget_time(self) -> float: + """Asynchronously get the current server time as a high resolution timestamp. + + It's important to get this from the server to ensure a monotonic clock, + otherwise there may be data loss when cleaning up old documents! + + Returns: + The current server time as a float timestamp. + """ + + @abstractmethod + def update( + self, + keys: Sequence[str], + *, + group_ids: Sequence[str | None] | None = None, + time_at_least: float | None = None, + ) -> None: + """Upsert records into the database. + + Args: + keys: A list of record keys to upsert. + group_ids: A list of group IDs corresponding to the keys. + time_at_least: Optional timestamp. Implementation can use this + to optionally verify that the timestamp IS at least this time + in the system that stores the data. + + e.g., use to validate that the time in the postgres database + is equal to or larger than the given timestamp, if not + raise an error. + + This is meant to help prevent time-drift issues since + time may not be monotonically increasing! + + Raises: + ValueError: If the length of keys doesn't match the length of group_ids. + """ + + @abstractmethod + async def aupdate( + self, + keys: Sequence[str], + *, + group_ids: Sequence[str | None] | None = None, + time_at_least: float | None = None, + ) -> None: + """Asynchronously upsert records into the database. + + Args: + keys: A list of record keys to upsert. + group_ids: A list of group IDs corresponding to the keys. + time_at_least: Optional timestamp. Implementation can use this + to optionally verify that the timestamp IS at least this time + in the system that stores the data. + + e.g., use to validate that the time in the postgres database + is equal to or larger than the given timestamp, if not + raise an error. + + This is meant to help prevent time-drift issues since + time may not be monotonically increasing! + + Raises: + ValueError: If the length of keys doesn't match the length of group_ids. + """ + + @abstractmethod + def exists(self, keys: Sequence[str]) -> list[bool]: + """Check if the provided keys exist in the database. + + Args: + keys: A list of keys to check. + + Returns: + A list of boolean values indicating the existence of each key. + """ + + @abstractmethod + async def aexists(self, keys: Sequence[str]) -> list[bool]: + """Asynchronously check if the provided keys exist in the database. + + Args: + keys: A list of keys to check. + + Returns: + A list of boolean values indicating the existence of each key. + """ + + @abstractmethod + def list_keys( + self, + *, + before: float | None = None, + after: float | None = None, + group_ids: Sequence[str] | None = None, + limit: int | None = None, + ) -> list[str]: + """List records in the database based on the provided filters. + + Args: + before: Filter to list records updated before this time. + after: Filter to list records updated after this time. + group_ids: Filter to list records with specific group IDs. + limit: optional limit on the number of records to return. + + Returns: + A list of keys for the matching records. + """ + + @abstractmethod + async def alist_keys( + self, + *, + before: float | None = None, + after: float | None = None, + group_ids: Sequence[str] | None = None, + limit: int | None = None, + ) -> list[str]: + """Asynchronously list records in the database based on the provided filters. + + Args: + before: Filter to list records updated before this time. + after: Filter to list records updated after this time. + group_ids: Filter to list records with specific group IDs. + limit: optional limit on the number of records to return. + + Returns: + A list of keys for the matching records. + """ + + @abstractmethod + def delete_keys(self, keys: Sequence[str]) -> None: + """Delete specified records from the database. + + Args: + keys: A list of keys to delete. + """ + + @abstractmethod + async def adelete_keys(self, keys: Sequence[str]) -> None: + """Asynchronously delete specified records from the database. + + Args: + keys: A list of keys to delete. + """ + + +class _Record(TypedDict): + group_id: str | None + updated_at: float + + +class InMemoryRecordManager(RecordManager): + """An in-memory record manager for testing purposes.""" + + def __init__(self, namespace: str) -> None: + """Initialize the in-memory record manager. + + Args: + namespace: The namespace for the record manager. + """ + super().__init__(namespace) + # Each key points to a dictionary + # of {'group_id': group_id, 'updated_at': timestamp} + self.records: dict[str, _Record] = {} + self.namespace = namespace + + def create_schema(self) -> None: + """In-memory schema creation is simply ensuring the structure is initialized.""" + + async def acreate_schema(self) -> None: + """In-memory schema creation is simply ensuring the structure is initialized.""" + + @override + def get_time(self) -> float: + return time.time() + + @override + async def aget_time(self) -> float: + return self.get_time() + + def update( + self, + keys: Sequence[str], + *, + group_ids: Sequence[str | None] | None = None, + time_at_least: float | None = None, + ) -> None: + """Upsert records into the database. + + Args: + keys: A list of record keys to upsert. + group_ids: A list of group IDs corresponding to the keys. + + time_at_least: Optional timestamp. Implementation can use this + to optionally verify that the timestamp IS at least this time + in the system that stores. + E.g., use to validate that the time in the postgres database + is equal to or larger than the given timestamp, if not + raise an error. + This is meant to help prevent time-drift issues since + time may not be monotonically increasing! + + Raises: + ValueError: If the length of keys doesn't match the length of group + ids. + ValueError: If time_at_least is in the future. + """ + if group_ids and len(keys) != len(group_ids): + msg = "Length of keys must match length of group_ids" + raise ValueError(msg) + for index, key in enumerate(keys): + group_id = group_ids[index] if group_ids else None + if time_at_least and time_at_least > self.get_time(): + msg = "time_at_least must be in the past" + raise ValueError(msg) + self.records[key] = {"group_id": group_id, "updated_at": self.get_time()} + + async def aupdate( + self, + keys: Sequence[str], + *, + group_ids: Sequence[str | None] | None = None, + time_at_least: float | None = None, + ) -> None: + """Async upsert records into the database. + + Args: + keys: A list of record keys to upsert. + group_ids: A list of group IDs corresponding to the keys. + + time_at_least: Optional timestamp. Implementation can use this + to optionally verify that the timestamp IS at least this time + in the system that stores. + E.g., use to validate that the time in the postgres database + is equal to or larger than the given timestamp, if not + raise an error. + This is meant to help prevent time-drift issues since + time may not be monotonically increasing! + """ + self.update(keys, group_ids=group_ids, time_at_least=time_at_least) + + def exists(self, keys: Sequence[str]) -> list[bool]: + """Check if the provided keys exist in the database. + + Args: + keys: A list of keys to check. + + Returns: + A list of boolean values indicating the existence of each key. + """ + return [key in self.records for key in keys] + + async def aexists(self, keys: Sequence[str]) -> list[bool]: + """Async check if the provided keys exist in the database. + + Args: + keys: A list of keys to check. + + Returns: + A list of boolean values indicating the existence of each key. + """ + return self.exists(keys) + + def list_keys( + self, + *, + before: float | None = None, + after: float | None = None, + group_ids: Sequence[str] | None = None, + limit: int | None = None, + ) -> list[str]: + """List records in the database based on the provided filters. + + Args: + before: Filter to list records updated before this time. + + after: Filter to list records updated after this time. + + group_ids: Filter to list records with specific group IDs. + + limit: optional limit on the number of records to return. + + + Returns: + A list of keys for the matching records. + """ + result = [] + for key, data in self.records.items(): + if before and data["updated_at"] >= before: + continue + if after and data["updated_at"] <= after: + continue + if group_ids and data["group_id"] not in group_ids: + continue + result.append(key) + if limit: + return result[:limit] + return result + + async def alist_keys( + self, + *, + before: float | None = None, + after: float | None = None, + group_ids: Sequence[str] | None = None, + limit: int | None = None, + ) -> list[str]: + """Async list records in the database based on the provided filters. + + Args: + before: Filter to list records updated before this time. + + after: Filter to list records updated after this time. + + group_ids: Filter to list records with specific group IDs. + + limit: optional limit on the number of records to return. + + + Returns: + A list of keys for the matching records. + """ + return self.list_keys( + before=before, after=after, group_ids=group_ids, limit=limit + ) + + def delete_keys(self, keys: Sequence[str]) -> None: + """Delete specified records from the database. + + Args: + keys: A list of keys to delete. + """ + for key in keys: + if key in self.records: + del self.records[key] + + async def adelete_keys(self, keys: Sequence[str]) -> None: + """Async delete specified records from the database. + + Args: + keys: A list of keys to delete. + """ + self.delete_keys(keys) + + +class UpsertResponse(TypedDict): + """A generic response for upsert operations. + + The upsert response will be used by abstractions that implement an upsert + operation for content that can be upserted by ID. + + Upsert APIs that accept inputs with IDs and generate IDs internally + will return a response that includes the IDs that succeeded and the IDs + that failed. + + If there are no failures, the failed list will be empty, and the order + of the IDs in the succeeded list will match the order of the input documents. + + If there are failures, the response becomes ill defined, and a user of the API + cannot determine which generated ID corresponds to which input document. + + It is recommended for users explicitly attach the IDs to the items being + indexed to avoid this issue. + """ + + succeeded: list[str] + """The IDs that were successfully indexed.""" + failed: list[str] + """The IDs that failed to index.""" + + +class DeleteResponse(TypedDict, total=False): + """A generic response for delete operation. + + The fields in this response are optional and whether the `VectorStore` + returns them or not is up to the implementation. + """ + + num_deleted: int + """The number of items that were successfully deleted. + + If returned, this should only include *actual* deletions. + + If the ID did not exist to begin with, + it should not be included in this count. + """ + + succeeded: Sequence[str] + """The IDs that were successfully deleted. + + If returned, this should only include *actual* deletions. + + If the ID did not exist to begin with, + it should not be included in this list. + """ + + failed: Sequence[str] + """The IDs that failed to be deleted. + + !!! warning + Deleting an ID that does not exist is **NOT** considered a failure. + """ + + num_failed: int + """The number of items that failed to be deleted.""" + + +@beta(message="Added in 0.2.29. The abstraction is subject to change.") +class DocumentIndex(BaseRetriever): + """A document retriever that supports indexing operations. + + This indexing interface is designed to be a generic abstraction for storing and + querying documents that has an ID and metadata associated with it. + + The interface is designed to be agnostic to the underlying implementation of the + indexing system. + + The interface is designed to support the following operations: + + 1. Storing document in the index. + 2. Fetching document by ID. + 3. Searching for document using a query. + """ + + @abc.abstractmethod + def upsert(self, items: Sequence[Document], /, **kwargs: Any) -> UpsertResponse: + """Upsert documents into the index. + + The upsert functionality should utilize the ID field of the content object + if it is provided. If the ID is not provided, the upsert method is free + to generate an ID for the content. + + When an ID is specified and the content already exists in the `VectorStore`, + the upsert method should update the content with the new data. If the content + does not exist, the upsert method should add the item to the `VectorStore`. + + Args: + items: Sequence of documents to add to the `VectorStore`. + **kwargs: Additional keyword arguments. + + Returns: + A response object that contains the list of IDs that were + successfully added or updated in the `VectorStore` and the list of IDs that + failed to be added or updated. + """ + + async def aupsert( + self, items: Sequence[Document], /, **kwargs: Any + ) -> UpsertResponse: + """Add or update documents in the `VectorStore`. Async version of `upsert`. + + The upsert functionality should utilize the ID field of the item + if it is provided. If the ID is not provided, the upsert method is free + to generate an ID for the item. + + When an ID is specified and the item already exists in the `VectorStore`, + the upsert method should update the item with the new data. If the item + does not exist, the upsert method should add the item to the `VectorStore`. + + Args: + items: Sequence of documents to add to the `VectorStore`. + **kwargs: Additional keyword arguments. + + Returns: + A response object that contains the list of IDs that were + successfully added or updated in the `VectorStore` and the list of IDs that + failed to be added or updated. + """ + return await run_in_executor( + None, + self.upsert, + items, + **kwargs, + ) + + @abc.abstractmethod + def delete(self, ids: list[str] | None = None, **kwargs: Any) -> DeleteResponse: + """Delete by IDs or other criteria. + + Calling delete without any input parameters should raise a ValueError! + + Args: + ids: List of IDs to delete. + **kwargs: Additional keyword arguments. This is up to the implementation. + For example, can include an option to delete the entire index, + or else issue a non-blocking delete etc. + + Returns: + A response object that contains the list of IDs that were + successfully deleted and the list of IDs that failed to be deleted. + """ + + async def adelete( + self, ids: list[str] | None = None, **kwargs: Any + ) -> DeleteResponse: + """Delete by IDs or other criteria. Async variant. + + Calling adelete without any input parameters should raise a ValueError! + + Args: + ids: List of IDs to delete. + **kwargs: Additional keyword arguments. This is up to the implementation. + For example, can include an option to delete the entire index. + + Returns: + A response object that contains the list of IDs that were + successfully deleted and the list of IDs that failed to be deleted. + """ + return await run_in_executor( + None, + self.delete, + ids, + **kwargs, + ) + + @abc.abstractmethod + def get( + self, + ids: Sequence[str], + /, + **kwargs: Any, + ) -> list[Document]: + """Get documents by id. + + Fewer documents may be returned than requested if some IDs are not found or + if there are duplicated IDs. + + Users should not assume that the order of the returned documents matches + the order of the input IDs. Instead, users should rely on the ID field of the + returned documents. + + This method should **NOT** raise exceptions if no documents are found for + some IDs. + + Args: + ids: List of IDs to get. + **kwargs: Additional keyword arguments. These are up to the implementation. + + Returns: + List of documents that were found. + """ + + async def aget( + self, + ids: Sequence[str], + /, + **kwargs: Any, + ) -> list[Document]: + """Get documents by id. + + Fewer documents may be returned than requested if some IDs are not found or + if there are duplicated IDs. + + Users should not assume that the order of the returned documents matches + the order of the input IDs. Instead, users should rely on the ID field of the + returned documents. + + This method should **NOT** raise exceptions if no documents are found for + some IDs. + + Args: + ids: List of IDs to get. + **kwargs: Additional keyword arguments. These are up to the implementation. + + Returns: + List of documents that were found. + """ + return await run_in_executor( + None, + self.get, + ids, + **kwargs, + ) diff --git a/libs/core/langchain_core/indexing/in_memory.py b/libs/core/langchain_core/indexing/in_memory.py new file mode 100644 index 0000000..ae9cf84 --- /dev/null +++ b/libs/core/langchain_core/indexing/in_memory.py @@ -0,0 +1,104 @@ +"""In memory document index.""" + +import operator +import uuid +from collections.abc import Sequence +from typing import Any, cast + +from pydantic import Field +from typing_extensions import override + +from langchain_core._api import beta +from langchain_core.callbacks import CallbackManagerForRetrieverRun +from langchain_core.documents import Document +from langchain_core.indexing import UpsertResponse +from langchain_core.indexing.base import DeleteResponse, DocumentIndex + + +@beta(message="Introduced in version 0.2.29. Underlying abstraction subject to change.") +class InMemoryDocumentIndex(DocumentIndex): + """In memory document index. + + This is an in-memory document index that stores documents in a dictionary. + + It provides a simple search API that returns documents by the number of + counts the given query appears in the document. + """ + + store: dict[str, Document] = Field(default_factory=dict) + top_k: int = 4 + + @override + def upsert(self, items: Sequence[Document], /, **kwargs: Any) -> UpsertResponse: + """Upsert documents into the index. + + Args: + items: Sequence of documents to add to the index. + **kwargs: Additional keyword arguments. + + Returns: + A response object that contains the list of IDs that were + successfully added or updated in the index and the list of IDs that + failed to be added or updated. + """ + ok_ids = [] + + for item in items: + if item.id is None: + id_ = str(uuid.uuid4()) + item_ = item.model_copy() + item_.id = id_ + else: + item_ = item + id_ = item.id + + self.store[id_] = item_ + ok_ids.append(cast("str", item_.id)) + + return UpsertResponse(succeeded=ok_ids, failed=[]) + + @override + def delete(self, ids: list[str] | None = None, **kwargs: Any) -> DeleteResponse: + """Delete by IDs. + + Args: + ids: List of IDs to delete. + + Raises: + ValueError: If IDs is None. + + Returns: + A response object that contains the list of IDs that were successfully + deleted and the list of IDs that failed to be deleted. + """ + if ids is None: + msg = "IDs must be provided for deletion" + raise ValueError(msg) + + ok_ids = [] + + for id_ in ids: + if id_ in self.store: + del self.store[id_] + ok_ids.append(id_) + + return DeleteResponse( + succeeded=ok_ids, num_deleted=len(ok_ids), num_failed=0, failed=[] + ) + + @override + def get(self, ids: Sequence[str], /, **kwargs: Any) -> list[Document]: + return [self.store[id_] for id_ in ids if id_ in self.store] + + @override + def _get_relevant_documents( + self, query: str, *, run_manager: CallbackManagerForRetrieverRun + ) -> list[Document]: + counts_by_doc = [] + + for document in self.store.values(): + count = document.page_content.count(query) + counts_by_doc.append((document, count)) + + counts_by_doc.sort(key=operator.itemgetter(1), reverse=True) + return [doc.model_copy() for doc, count in counts_by_doc[: self.top_k]] diff --git a/libs/core/langchain_core/language_models/__init__.py b/libs/core/langchain_core/language_models/__init__.py new file mode 100644 index 0000000..722597d --- /dev/null +++ b/libs/core/langchain_core/language_models/__init__.py @@ -0,0 +1,116 @@ +"""Core language model abstractions. + +LangChain has two main classes to work with language models: chat models and +"old-fashioned" LLMs (string-in, string-out). + +**Chat models** + +Language models that use a sequence of messages as inputs and return chat messages +as outputs (as opposed to using plain text). + +Chat models support the assignment of distinct roles to conversation messages, helping +to distinguish messages from the AI, users, and instructions such as system messages. + +The key abstraction for chat models is +[`BaseChatModel`][langchain_core.language_models.BaseChatModel]. Implementations should +inherit from this class. + +See existing [chat model integrations](https://docs.langchain.com/oss/python/integrations/chat). + +**LLMs (legacy)** + +Language models that takes a string as input and returns a string. + +These are traditionally older models (newer models generally are chat models). + +Although the underlying models are string in, string out, the LangChain wrappers also +allow these models to take messages as input. This gives them the same interface as +chat models. When messages are passed in as input, they will be formatted into a string +under the hood before being passed to the underlying model. +""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr +from langchain_core.language_models._utils import is_openai_data_block + +if TYPE_CHECKING: + from langchain_core.language_models.base import ( + BaseLanguageModel, + LangSmithParams, + LanguageModelInput, + LanguageModelLike, + LanguageModelOutput, + get_tokenizer, + ) + from langchain_core.language_models.chat_models import ( + BaseChatModel, + SimpleChatModel, + ) + from langchain_core.language_models.fake import FakeListLLM, FakeStreamingListLLM + from langchain_core.language_models.fake_chat_models import ( + FakeListChatModel, + FakeMessagesListChatModel, + GenericFakeChatModel, + ParrotFakeChatModel, + ) + from langchain_core.language_models.llms import LLM, BaseLLM + from langchain_core.language_models.model_profile import ( + ModelProfile, + ModelProfileRegistry, + ) + +__all__ = ( + "LLM", + "BaseChatModel", + "BaseLLM", + "BaseLanguageModel", + "FakeListChatModel", + "FakeListLLM", + "FakeMessagesListChatModel", + "FakeStreamingListLLM", + "GenericFakeChatModel", + "LangSmithParams", + "LanguageModelInput", + "LanguageModelLike", + "LanguageModelOutput", + "ModelProfile", + "ModelProfileRegistry", + "ParrotFakeChatModel", + "SimpleChatModel", + "get_tokenizer", + "is_openai_data_block", +) + +_dynamic_imports = { + "BaseLanguageModel": "base", + "LangSmithParams": "base", + "LanguageModelInput": "base", + "LanguageModelLike": "base", + "LanguageModelOutput": "base", + "get_tokenizer": "base", + "BaseChatModel": "chat_models", + "SimpleChatModel": "chat_models", + "FakeListLLM": "fake", + "FakeStreamingListLLM": "fake", + "FakeListChatModel": "fake_chat_models", + "FakeMessagesListChatModel": "fake_chat_models", + "GenericFakeChatModel": "fake_chat_models", + "ParrotFakeChatModel": "fake_chat_models", + "LLM": "llms", + "ModelProfile": "model_profile", + "ModelProfileRegistry": "model_profile", + "BaseLLM": "llms", + "is_openai_data_block": "_utils", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/language_models/_compat_bridge.py b/libs/core/langchain_core/language_models/_compat_bridge.py new file mode 100644 index 0000000..81f592f --- /dev/null +++ b/libs/core/langchain_core/language_models/_compat_bridge.py @@ -0,0 +1,844 @@ +"""Compat bridge: convert `AIMessageChunk` streams to protocol events. + +The bridge trusts `AIMessageChunk.content_blocks` as the single +protocol view of any chunk. That property runs the three-tier lookup +(`output_version == "v1"` short-circuit, registered translator, or +best-effort parsing) and returns a `list[ContentBlock]` for every +well-formed message — whether the provider is a registered partner, an +unregistered community model, or not tagged at all. + +Per-chunk `content_blocks` output is a **delta slice**, not accumulated +state: providers in this ecosystem emit SSE-style chunks that each carry +their own increment. The bridge therefore forwards each slice straight +through as a `content-block-delta` event, and accumulates per-index +state only so the final `content-block-finish` event can report a +finalized block (e.g. `tool_call_chunk` args parsed to a dict). + +Lifecycle:: + + message-start + -> content-block-start (first time each index is observed) + -> content-block-delta* (per chunk, carrying the slice) + -> content-block-finish (finalized block) + -> message-finish + +Public API: + +- `chunks_to_events` / `achunks_to_events` — for live streams where + chunks arrive over time. +- `message_to_events` / `amessage_to_events` — for replaying a finalized + `AIMessage` (cache hit, checkpoint restore, graph-node return value) + as a synthetic event lifecycle. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, cast + +from langchain_protocol.protocol import ( + ContentBlock, + ContentBlockDeltaData, + ContentBlockFinishData, + ContentBlockStartData, + FinalizedContentBlock, + InvalidToolCall, + MessageFinishData, + MessageMetadata, + MessagesData, + MessageStartData, + ReasoningContentBlock, + ServerToolCall, + ServerToolCallChunk, + TextContentBlock, + ToolCall, + ToolCallChunk, + UsageInfo, +) + +from langchain_core.messages import AIMessageChunk, BaseMessage +from langchain_core.utils._merge import merge_dicts + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + + from langchain_protocol.protocol import ( + BlockDelta, + BlockDeltaFields, + ContentBlockDelta, + DataDelta, + ReasoningDelta, + TextDelta, + ) + + from langchain_core.messages.ai import UsageMetadata + from langchain_core.outputs import ChatGenerationChunk + + +CompatBlock = dict[str, Any] +"""Internal working type for a content block. + +The bridge works with plain dicts internally because two separate but +structurally similar `ContentBlock` Unions exist — one in +`langchain_core.messages.content` (returned by `msg.content_blocks`), +one in `langchain_protocol.protocol` (the wire/event shape). They are +not mypy-compatible despite being near-isomorphic. Passing through +`dict[str, Any]` launders between them. See `_to_protocol_block` for +the single seam where the laundering cast lives. +""" + + +# --------------------------------------------------------------------------- +# Type laundering between core and protocol `ContentBlock` unions +# --------------------------------------------------------------------------- + + +def _to_protocol_block(block: CompatBlock) -> ContentBlock: + """Narrow an internal working dict to a protocol `ContentBlock`. + + Single seam between the two `ContentBlock` type systems: + `langchain_core.messages.content` (what `msg.content_blocks` + returns) and `langchain_protocol.protocol` (what event payloads + require). The two Unions overlap structurally but are nominally + distinct to mypy, so we launder through `dict[str, Any]`. When the + Unions are unified, this helper and its finalized counterpart can be + deleted. + """ + return cast("ContentBlock", block) + + +def _to_finalized_block(block: CompatBlock) -> FinalizedContentBlock: + """Counterpart of `_to_protocol_block` for finalized blocks.""" + return cast("FinalizedContentBlock", block) + + +def _to_block_delta_fields(block: CompatBlock) -> BlockDeltaFields: + """Narrow an internal working dict to protocol block-delta fields.""" + return cast("BlockDeltaFields", block) + + +def _to_content_delta(block: CompatBlock) -> ContentBlockDelta: + """Convert a content-block slice/snapshot to an explicit protocol delta.""" + btype = block.get("type") + if btype == "text": + return cast("TextDelta", {"type": "text-delta", "text": block.get("text", "")}) + if btype == "reasoning": + return cast( + "ReasoningDelta", + { + "type": "reasoning-delta", + "reasoning": block.get("reasoning", ""), + }, + ) + if "data" in block: + delta = cast("DataDelta", {"type": "data-delta", "data": block.get("data", "")}) + if block.get("encoding") == "base64": + delta["encoding"] = "base64" + return delta + return cast( + "BlockDelta", + { + "type": "block-delta", + "fields": _to_block_delta_fields(block), + }, + ) + + +# --------------------------------------------------------------------------- +# Block iteration +# --------------------------------------------------------------------------- + + +def _iter_protocol_blocks(msg: BaseMessage) -> list[tuple[Any, CompatBlock]]: + """Read per-chunk protocol blocks from `msg.content_blocks`. + + Returns `(key, block)` pairs. The key is the block's stable identifier + across the stream: the block's `index` field when present (can be an + int or a string — some providers use string identifiers like + `"lc_rs_305f30"`), or the positional index within the message as a + fallback. Callers are responsible for allocating wire-level `uint` + indices; this helper only surfaces the source-side identity. + + For finalized `AIMessage`, also surfaces `invalid_tool_calls` + — which `AIMessage.content_blocks` currently omits from its return + value even though they are a defined protocol block type. + + The positional fallback is a known fragility: when a provider emits + blocks without an `index` field (e.g. Anthropic's `_stream` with + `coerce_content_to_string=True`, where text chunks lose their + source-side index), every such chunk gets positional key 0 and + successive chunks merge into one block. This works correctly for + single-type streams (pure-text responses merge cleanly) because all + chunks share the same key and the open-block logic collapses them. + It would miscategorise a stream that mixed indexed structured + blocks with non-indexed coerced-text blocks, since an indexed + block with `index == 0` would collide with the anonymous text + block's positional-0 key. In the anthropic integration this + cannot currently occur: coerce-to-string mode is only selected + when no tools, thinking, or documents are present, and any of + those flips the stream to structured mode where every block + carries an integer index. A native `_stream_chat_model_events` + hook per provider (or a bridge-level "continue the open block when + the source has no identity" rule) would close the gap if another + integration ever emits mixed content. + """ + try: + raw = msg.content_blocks + except Exception: + return [] + + result: list[tuple[Any, CompatBlock]] = [] + for i, block in enumerate(raw): + if not isinstance(block, dict): + continue # type: ignore[unreachable] + explicit_idx = block.get("index") + if explicit_idx is None: + # No source-side identity. Bucket by (sentinel, block type, + # positional `i`) so two blocks of different types at the + # same position across chunks (e.g. Gemini emitting a + # reasoning block in one chunk and a `tool_call` in the + # next, both at positional 0 because each chunk carries one + # block) get distinct wire blocks. Without this, the second + # type's incoming block hits `_accumulate`'s self-contained + # `else` branch and clobbers the first. Same-type chunks + # still share the bucket and merge cleanly, which is what + # streaming text / reasoning relies on. + key: Any = ("__lc_no_index__", block.get("type"), i) + else: + key = explicit_idx + result.append((key, dict(block))) + + if not isinstance(msg, AIMessageChunk): + # Finalized AIMessage: pull invalid_tool_calls from the dedicated + # field — AIMessage.content_blocks does not currently include them. + for itc in getattr(msg, "invalid_tool_calls", None) or []: + itc_block: CompatBlock = {"type": "invalid_tool_call"} + for key_name in ("id", "name", "args", "error"): + if itc.get(key_name) is not None: + itc_block[key_name] = itc[key_name] + result.append((len(result), itc_block)) + + return result + + +# --------------------------------------------------------------------------- +# Per-block helpers +# --------------------------------------------------------------------------- + + +# Fields that can carry large payloads (inline base64 media, parsed args, +# arbitrary dicts). Stripped from `content-block-start` for self-contained +# block types so the payload rides on `content-block-finish` alone instead +# of being serialized twice on the wire. +_HEAVY_FIELDS = frozenset({"args", "data", "output", "transcript", "value"}) + + +def _start_skeleton(block: CompatBlock) -> ContentBlock: + """Empty-content placeholder for the `content-block-start` event. + + Deltaable block types (text, reasoning, the `_chunk` tool variants) + get an empty payload so the lifecycle's "start" signal is distinct + from the first incremental delta. Self-contained types (image, + audio, video, file, non_standard, finalized tool calls) drop their + heavy payload fields; those are carried by `content-block-finish`. + Correlation fields (id, name, toolCallId) and small metadata + (mime_type, url, status, …) are preserved on the start event. + """ + btype = block.get("type", "text") + if btype == "text": + return TextContentBlock(type="text", text="") + if btype == "reasoning": + return ReasoningContentBlock(type="reasoning", reasoning="") + if btype == "tool_call_chunk": + return ToolCallChunk( + type="tool_call_chunk", + id=block.get("id"), + name=block.get("name"), + args="", + ) + if btype == "server_tool_call_chunk": + s_skel = ServerToolCallChunk( + type="server_tool_call_chunk", + args="", + ) + if block.get("id") is not None: + s_skel["id"] = block["id"] + if block.get("name") is not None: + s_skel["name"] = block["name"] + return s_skel + + stripped: CompatBlock = {k: v for k, v in block.items() if k not in _HEAVY_FIELDS} + # Restore required-but-heavy fields with minimal placeholders so the + # start event still validates against the CDDL shape of the block type. + if btype in {"tool_call", "server_tool_call"}: + stripped["args"] = {} + elif btype == "non_standard": + stripped["value"] = {} + return _to_protocol_block(stripped) + + +def _should_emit_delta(block: CompatBlock) -> bool: + """Whether a per-chunk block carries content worth a delta event. + + Deltaable types emit only when they have fresh content. Self-contained + / already-finalized types skip the delta entirely — the `finish` + event carries them. + """ + btype = block.get("type") + if btype == "text": + return bool(block.get("text")) + if btype == "reasoning": + return bool(block.get("reasoning")) + if btype in {"tool_call_chunk", "server_tool_call_chunk"}: + return bool( + block.get("args") or block.get("id") or block.get("name"), + ) + if "data" in block: + return bool(block.get("data")) + return False + + +def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock: + """Merge a per-chunk delta slice into accumulated per-index state. + + Used only for the finalization pass — live delta events are emitted + directly from the per-chunk block, without round-tripping through + accumulated state. + """ + if state is None: + return dict(delta) + btype = state.get("type") + dtype = delta.get("type") + if btype == "text" and dtype == "text": + state["text"] = state.get("text", "") + delta.get("text", "") + # Providers may send non-text fields (like `id`, or annotations) + # on later deltas. Merging (not replacing) keeps earlier keys + # intact while picking up these late-arriving fields. + for key, value in delta.items(): + if key in {"type", "text"} or value is None: + continue + if key == "extras" and isinstance(value, dict): + state["extras"] = {**(state.get("extras") or {}), **value} + else: + state[key] = value + elif btype == "reasoning" and dtype == "reasoning": + state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "") + # Providers may ship non-text fields on later deltas. Claude's + # `signature_delta` arrives after the reasoning text, surfaced + # as `extras.signature`; merging (not replacing) keeps earlier + # keys intact. + for key, value in delta.items(): + if key in {"type", "reasoning"} or value is None: + continue + if key == "extras" and isinstance(value, dict): + state["extras"] = {**(state.get("extras") or {}), **value} + else: + state[key] = value + elif btype in {"tool_call_chunk", "server_tool_call_chunk"} and dtype == btype: + state["args"] = (state.get("args", "") or "") + (delta.get("args") or "") + if delta.get("id") is not None: + state["id"] = delta["id"] + if delta.get("name") is not None: + state["name"] = delta["name"] + elif btype == dtype and "data" in delta: + state["data"] = (state.get("data", "") or "") + (delta.get("data") or "") + for key, value in delta.items(): + if key in {"type", "data"} or value is None: + continue + if key == "extras" and isinstance(value, dict): + state["extras"] = {**(state.get("extras") or {}), **value} + else: + state[key] = value + else: + # Self-contained or already-finalized types: replace wholesale. + state.clear() + state.update(delta) + return state + + +def finalize_tool_call_chunk( + *, + raw_args: str | None, + id_: str | None, + name: str | None, + extras: dict[str, Any], + finalized_type: str, +) -> FinalizedContentBlock: + """Parse accumulated tool-chunk args into a finalized block. + + Shared between the compat bridge's `_finalize_block` and the + `ChatModelStream` end-of-stream sweep. Parses `raw_args` as JSON: + on success builds the requested finalized type (`tool_call` or + `server_tool_call`) with provider-specific fields (`extras`) + preserved; on failure falls back to `invalid_tool_call` carrying + the raw string so downstream consumers can still introspect the + malformed payload. + + Args: + raw_args: Accumulated partial-JSON string; `None` or empty + treated as `{}`. + id_: Tool-call id collected across chunks. + name: Tool name collected across chunks. + extras: Provider-specific fields to carry onto the finalized + block. Callers are responsible for having already dropped + keys they don't want propagated (notably `type`, `id`, + `name`, `args`, and `index` on client-side `tool_call`). + finalized_type: `"tool_call"` or `"server_tool_call"`. + + Returns: + A `ToolCall`, `ServerToolCall`, or `InvalidToolCall` — the + latter when `raw_args` is non-empty but not valid JSON. + """ + raw = raw_args or "{}" + try: + parsed = json.loads(raw) if raw else {} + except (json.JSONDecodeError, TypeError): + invalid = InvalidToolCall( + type="invalid_tool_call", + id=id_, + name=name, + args=raw, + error="Failed to parse tool call arguments as JSON", + ) + invalid.update(extras) # type: ignore[typeddict-item] + return invalid + if finalized_type == "tool_call": + finalized_tc = ToolCall( + type="tool_call", + id=id_ or "", + name=name or "", + args=parsed, + ) + finalized_tc.update(extras) # type: ignore[typeddict-item] + return finalized_tc + finalized_stc = ServerToolCall( + type="server_tool_call", + id=id_ or "", + name=name or "", + args=parsed, + ) + finalized_stc.update(extras) # type: ignore[typeddict-item] + return finalized_stc + + +def _finalize_block(block: CompatBlock) -> FinalizedContentBlock: + """Promote chunk variants to their finalized form. + + `tool_call_chunk` becomes `tool_call` — or `invalid_tool_call` + if the accumulated `args` don't parse as JSON. + `server_tool_call_chunk` becomes `server_tool_call` under the same + rule. Everything else passes through: text/reasoning blocks carry + their accumulated snapshot, and self-contained types are already in + their terminal shape. + """ + btype = block.get("type") + if btype in {"tool_call_chunk", "server_tool_call_chunk"}: + # Carry provider-specific fields from the accumulated chunk onto + # the finalized block. Drop the chunk-only keys we rewrite + # explicitly. `index` is stripped on client-side + # `tool_call` / `invalid_tool_call` finalizations to match v1 + # (`AIMessage.init_tool_calls` rebuilds tool_call blocks without + # `index`), preventing `merge_lists` from re-merging further + # chunks into an already-parsed args dict. `server_tool_call` + # retains `index` because v1's `init_server_tool_calls` + # finalizes in-place and preserves it. + client_tool_call = btype == "tool_call_chunk" + extras_drop = {"type", "id", "name", "args"} + if client_tool_call: + extras_drop |= {"index"} + extras = { + k: v for k, v in block.items() if k not in extras_drop and v is not None + } + return finalize_tool_call_chunk( + raw_args=block.get("args"), + id_=block.get("id"), + name=block.get("name"), + extras=extras, + finalized_type="tool_call" if client_tool_call else "server_tool_call", + ) + return _to_finalized_block(block) + + +# --------------------------------------------------------------------------- +# Metadata, usage, finish-reason +# --------------------------------------------------------------------------- + + +def _extract_start_metadata(response_metadata: dict[str, Any]) -> MessageMetadata: + """Pull provider/model hints for the `message-start` event.""" + metadata: MessageMetadata = {} + if "model_provider" in response_metadata: + metadata["provider"] = response_metadata["model_provider"] + if "model_name" in response_metadata: + metadata["model"] = response_metadata["model_name"] + return metadata + + +def _accumulate_usage(current: UsageInfo | None, delta: UsageMetadata) -> UsageInfo: + """Sum usage counts and merge detail dicts across chunks. + + `delta` is a chunk's `usage_metadata`; `current` is the running total. + Both sides are read and written by literal key so the typed shape is + preserved end to end — no `dict[str, Any]` detour. + """ + new: UsageInfo = current if current is not None else {} + if "input_tokens" in delta: + new["input_tokens"] = new.get("input_tokens", 0) + delta["input_tokens"] + if "output_tokens" in delta: + new["output_tokens"] = new.get("output_tokens", 0) + delta["output_tokens"] + if "total_tokens" in delta: + new["total_tokens"] = new.get("total_tokens", 0) + delta["total_tokens"] + input_details = delta.get("input_token_details") + if input_details: + merged_input = new.get("input_token_details", {}) + merged_input.update(input_details) + new["input_token_details"] = merged_input + output_details = delta.get("output_token_details") + if output_details: + merged_output = new.get("output_token_details", {}) + merged_output.update(output_details) + new["output_token_details"] = merged_output + return new + + +def _isolate_usage(usage: UsageInfo | None) -> UsageInfo | None: + """Copy usage for the event so consumers can't mutate the source message. + + The replay path (`message_to_events`) feeds the live `msg.usage_metadata`, + so the emitted event must not share its dicts: copy the top level plus the + nested `input_token_details` / `output_token_details` to de-alias it. The + streaming accumulator already owns the dicts it builds, so the copy is a + harmless no-op on that path. + """ + if not usage: + return None + result: UsageInfo = usage.copy() + input_details = result.get("input_token_details") + if input_details is not None: + result["input_token_details"] = input_details.copy() + output_details = result.get("output_token_details") + if output_details is not None: + result["output_token_details"] = output_details.copy() + return result + + +# --------------------------------------------------------------------------- +# Event builders +# --------------------------------------------------------------------------- + + +def _build_message_start( + msg: BaseMessage, + message_id: str | None, +) -> MessageStartData: + start_data = MessageStartData(event="message-start", role="ai", id="") + resolved_id = message_id if message_id is not None else getattr(msg, "id", None) + if resolved_id: + start_data["id"] = resolved_id + start_metadata = _extract_start_metadata(msg.response_metadata or {}) + if start_metadata: + start_data["metadata"] = start_metadata + return start_data + + +def _build_message_finish( + *, + usage: UsageInfo | None, + response_metadata: dict[str, Any] | None, + additional_kwargs: dict[str, Any] | None = None, +) -> MessageFinishData: + # Protocol 0.0.9 removed the top-level `reason` field from + # `MessageFinishData`; the provider's raw `finish_reason` / + # `stop_reason` now rides inside `metadata` alongside other + # response metadata. Pass it through unchanged. + finish_data: dict[str, Any] = {"event": "message-finish"} + usage_info = _isolate_usage(usage) + if usage_info is not None: + finish_data["usage"] = usage_info + if response_metadata: + finish_data["metadata"] = dict(response_metadata) + # `additional_kwargs` is an off-spec extension on the message-finish + # event (parallel to `metadata`, which `MessageFinishData` also doesn't + # formally declare but the consumer reads). It carries provider-side + # kwargs that don't map onto a typed protocol field — notably Gemini's + # `__gemini_function_call_thought_signatures__`, which the model + # requires on follow-up turns to replay prior thinking. Without this, + # streaming-assembled messages would silently drop data that + # `ainvoke` preserves, breaking multi-turn streaming flows. + if additional_kwargs: + finish_data["additional_kwargs"] = dict(additional_kwargs) + return cast("MessageFinishData", finish_data) + + +def _finalize_and_build_finish( + wire_idx: int, + block: CompatBlock, +) -> MessagesData: + """Finalize a block and wrap it in a `content-block-finish` event.""" + return ContentBlockFinishData( + event="content-block-finish", + index=wire_idx, + content=_finalize_block(block), + ) + + +# --------------------------------------------------------------------------- +# Main generators +# --------------------------------------------------------------------------- + + +def chunks_to_events( + chunks: Iterator[ChatGenerationChunk], + *, + message_id: str | None = None, +) -> Iterator[MessagesData]: + """Convert a stream of `ChatGenerationChunk` to protocol events. + + Blocks are tracked independently by source-side identifier. Providers + such as Anthropic can interleave parallel tool-call chunks by index, so + each first-seen block gets a `content-block-start`, deltas keep their + stable wire index, and all open blocks are finalized at message end. + Source-side identifiers (from the block's `index` field, which may be + int or string) are translated to sequential `uint` wire indices. + + Args: + chunks: Iterator of `ChatGenerationChunk` from `_stream()`. + message_id: Optional stable message ID. + + Yields: + `MessagesData` lifecycle events. + """ + started = False + blocks: dict[Any, tuple[int, CompatBlock]] = {} + next_wire_idx = 0 + usage: UsageInfo | None = None + response_metadata: dict[str, Any] = {} + additional_kwargs: dict[str, Any] = {} + + for chunk in chunks: + msg = chunk.message + if not isinstance(msg, AIMessageChunk): + continue + + # The v1 `stream()` wrapper merges `generation_info` into + # `response_metadata` before yielding (`chat_models.py` via + # `_gen_info_and_msg_metadata`). We bypass that wrapper by reading + # `_stream` directly, so reproduce the merge here with the same + # priority: `generation_info` first, then `message.response_metadata` + # overlays. This is how provider fields like `model_name`, + # `system_fingerprint`, and `finish_reason` reach the bridge when + # a provider emits them via `generation_info` instead of the + # message's `response_metadata`. + merged_rm: dict[str, Any] = { + **(chunk.generation_info or {}), + **(msg.response_metadata or {}), + } + if merged_rm: + response_metadata.update(merged_rm) + + # Carry chunks' `additional_kwargs` through to the assembled + # message. Provider-side fields that don't map onto a typed + # protocol block (e.g. Gemini's per-tool-call thought signatures) + # live here on non-streaming `ainvoke` results; dropping them on + # the streaming path silently diverges multi-turn behavior. Use + # `merge_dicts` because the same key can arrive in pieces across + # chunks (e.g. an accumulating `function_call`), matching how + # `AIMessageChunk` merges itself. + if msg.additional_kwargs: + additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs) + + if not started: + started = True + yield _build_message_start(msg, message_id) + + for key, block in _iter_protocol_blocks(msg): + if key not in blocks: + wire_idx = next_wire_idx + next_wire_idx += 1 + blocks[key] = (wire_idx, dict(block)) + yield ContentBlockStartData( + event="content-block-start", + index=wire_idx, + content=_start_skeleton(block), + ) + else: + wire_idx, existing = blocks[key] + blocks[key] = (wire_idx, _accumulate(existing, block)) + if _should_emit_delta(block): + wire_idx, current = blocks[key] + is_block_delta = block.get("type") in { + "tool_call_chunk", + "server_tool_call_chunk", + } + delta_source = current if is_block_delta else block + yield ContentBlockDeltaData( + event="content-block-delta", + index=wire_idx, + delta=_to_content_delta(delta_source or block), + ) + + if msg.usage_metadata: + usage = _accumulate_usage(usage, msg.usage_metadata) + + if not started: + return + + for wire_idx, block in blocks.values(): + yield _finalize_and_build_finish(wire_idx, block) + + yield _build_message_finish( + usage=usage, + response_metadata=response_metadata, + additional_kwargs=additional_kwargs, + ) + + +async def achunks_to_events( + chunks: AsyncIterator[ChatGenerationChunk], + *, + message_id: str | None = None, +) -> AsyncIterator[MessagesData]: + """Async variant of `chunks_to_events`.""" + started = False + blocks: dict[Any, tuple[int, CompatBlock]] = {} + next_wire_idx = 0 + usage: UsageInfo | None = None + response_metadata: dict[str, Any] = {} + additional_kwargs: dict[str, Any] = {} + + async for chunk in chunks: + msg = chunk.message + if not isinstance(msg, AIMessageChunk): + continue + + # See sync twin for rationale: merge `generation_info` into the + # accumulated `response_metadata` with the same priority as the + # v1 `stream()` wrapper. + merged_rm: dict[str, Any] = { + **(chunk.generation_info or {}), + **(msg.response_metadata or {}), + } + if merged_rm: + response_metadata.update(merged_rm) + + # See sync twin: carry chunk `additional_kwargs` through so + # provider-specific data (e.g. Gemini thought signatures) reaches + # the assembled message instead of being dropped. + if msg.additional_kwargs: + additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs) + + if not started: + started = True + yield _build_message_start(msg, message_id) + + for key, block in _iter_protocol_blocks(msg): + if key not in blocks: + wire_idx = next_wire_idx + next_wire_idx += 1 + blocks[key] = (wire_idx, dict(block)) + yield ContentBlockStartData( + event="content-block-start", + index=wire_idx, + content=_start_skeleton(block), + ) + else: + wire_idx, existing = blocks[key] + blocks[key] = (wire_idx, _accumulate(existing, block)) + if _should_emit_delta(block): + wire_idx, current = blocks[key] + is_block_delta = block.get("type") in { + "tool_call_chunk", + "server_tool_call_chunk", + } + delta_source = current if is_block_delta else block + yield ContentBlockDeltaData( + event="content-block-delta", + index=wire_idx, + delta=_to_content_delta(delta_source or block), + ) + + if msg.usage_metadata: + usage = _accumulate_usage(usage, msg.usage_metadata) + + if not started: + return + + for wire_idx, block in blocks.values(): + yield _finalize_and_build_finish(wire_idx, block) + + yield _build_message_finish( + usage=usage, + response_metadata=response_metadata, + additional_kwargs=additional_kwargs, + ) + + +def message_to_events( + msg: BaseMessage, + *, + message_id: str | None = None, +) -> Iterator[MessagesData]: + """Replay a finalized message as a synthetic event lifecycle. + + For a message returned whole (from a graph node, checkpoint, or + cache), produce the same `message-start` / per-block / + `message-finish` event stream a live call would produce. Consumers + downstream see a uniform event shape regardless of source. + + Text and reasoning blocks emit a single `content-block-delta` with + the full accumulated content. Already-finalized blocks (tool_call, + server_tool_call, image, etc.) skip the delta and rely on the + `content-block-finish` event alone. + + Args: + msg: The finalized message — typically an `AIMessage`. + message_id: Optional stable message ID; falls back to `msg.id`. + + Yields: + `MessagesData` lifecycle events. + """ + response_metadata = msg.response_metadata or {} + yield _build_message_start(msg, message_id) + + for wire_idx, (_key, block) in enumerate(_iter_protocol_blocks(msg)): + yield ContentBlockStartData( + event="content-block-start", + index=wire_idx, + content=_start_skeleton(block), + ) + if _should_emit_delta(block): + yield ContentBlockDeltaData( + event="content-block-delta", + index=wire_idx, + delta=_to_content_delta(block), + ) + yield ContentBlockFinishData( + event="content-block-finish", + index=wire_idx, + content=_finalize_block(block), + ) + + yield _build_message_finish( + usage=getattr(msg, "usage_metadata", None), + response_metadata=response_metadata, + ) + + +async def amessage_to_events( + msg: BaseMessage, + *, + message_id: str | None = None, +) -> AsyncIterator[MessagesData]: + """Async variant of `message_to_events`.""" + for event in message_to_events(msg, message_id=message_id): + yield event + + +__all__ = [ + "CompatBlock", + "achunks_to_events", + "amessage_to_events", + "chunks_to_events", + "finalize_tool_call_chunk", + "message_to_events", +] diff --git a/libs/core/langchain_core/language_models/_utils.py b/libs/core/langchain_core/language_models/_utils.py new file mode 100644 index 0000000..c463db9 --- /dev/null +++ b/libs/core/langchain_core/language_models/_utils.py @@ -0,0 +1,343 @@ +import re +from collections.abc import Sequence +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypedDict, + TypeVar, +) + +if TYPE_CHECKING: + from langchain_core.messages import BaseMessage +from langchain_core.messages.content import ( + ContentBlock, +) + + +def _filter_invocation_params_for_tracing(params: dict[str, Any]) -> dict[str, Any]: + """Filter out large/inappropriate fields from invocation params for tracing. + + Removes fields like tools, functions, messages, response_format that can be large. + + Args: + params: The invocation parameters to filter. + + Returns: + The filtered parameters with large fields removed. + """ + excluded_keys = {"tools", "functions", "messages", "response_format"} + return {k: v for k, v in params.items() if k not in excluded_keys} + + +def is_openai_data_block( + block: dict[str, Any], filter_: Literal["image", "audio", "file"] | None = None +) -> bool: + """Check whether a block contains multimodal data in OpenAI Chat Completions format. + + Supports both data and ID-style blocks (e.g. `'file_data'` and `'file_id'`) + + If additional keys are present, they are ignored / will not affect outcome as long + as the required keys are present and valid. + + Args: + block: The content block to check. + filter_: If provided, only return True for blocks matching this specific type. + - "image": Only match image_url blocks + - "audio": Only match input_audio blocks + - "file": Only match file blocks + If `None`, match any valid OpenAI data block type. Note that this means that + if the block has a valid OpenAI data type but the filter_ is set to a + different type, this function will return False. + + Returns: + `True` if the block is a valid OpenAI data block and matches the filter_ + (if provided). + + """ + if block.get("type") == "image_url": + if filter_ is not None and filter_ != "image": + return False + if ( + (set(block.keys()) <= {"type", "image_url", "detail"}) + and (image_url := block.get("image_url")) + and isinstance(image_url, dict) + ): + url = image_url.get("url") + if isinstance(url, str): + # Required per OpenAI spec + return True + # Ignore `'detail'` since it's optional and specific to OpenAI + + elif block.get("type") == "input_audio": + if filter_ is not None and filter_ != "audio": + return False + if (audio := block.get("input_audio")) and isinstance(audio, dict): + audio_data = audio.get("data") + audio_format = audio.get("format") + # Both required per OpenAI spec + if isinstance(audio_data, str) and isinstance(audio_format, str): + return True + + elif block.get("type") == "file": + if filter_ is not None and filter_ != "file": + return False + if (file := block.get("file")) and isinstance(file, dict): + file_data = file.get("file_data") + file_id = file.get("file_id") + # Files can be either base64-encoded or pre-uploaded with an ID + if isinstance(file_data, str) or isinstance(file_id, str): + return True + + else: + return False + + # Has no `'type'` key + return False + + +class ParsedDataUri(TypedDict): + source_type: Literal["base64"] + data: str + mime_type: str + + +def _parse_data_uri(uri: str) -> ParsedDataUri | None: + """Parse a data URI into its components. + + If parsing fails, return `None`. If either MIME type or data is missing, return + `None`. + + Example: + ```python + data_uri = "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + parsed = _parse_data_uri(data_uri) + + assert parsed == { + "source_type": "base64", + "mime_type": "image/jpeg", + "data": "/9j/4AAQSkZJRg...", + } + ``` + """ + regex = r"^data:(?P[^;]+);base64,(?P.+)$" + match = re.match(regex, uri) + if match is None: + return None + + mime_type = match.group("mime_type") + data = match.group("data") + if not mime_type or not data: + return None + + return { + "source_type": "base64", + "data": data, + "mime_type": mime_type, + } + + +def _normalize_messages( + messages: Sequence["BaseMessage"], +) -> list["BaseMessage"]: + """Normalize message formats to LangChain v1 standard content blocks. + + Chat models already implement support for: + - Images in OpenAI Chat Completions format + These will be passed through unchanged + - LangChain v1 standard content blocks + + This function extends support to: + - `[Audio](https://platform.openai.com/docs/api-reference/chat/create) and + `[file](https://platform.openai.com/docs/api-reference/files) data in OpenAI + Chat Completions format + - Images are technically supported but we expect chat models to handle them + directly; this may change in the future + - LangChain v0 standard content blocks for backward compatibility + + !!! warning "Behavior changed in `langchain-core` 1.0.0" + + In previous versions, this function returned messages in LangChain v0 format. + Now, it returns messages in LangChain v1 format, which upgraded chat models now + expect to receive when passing back in message history. For backward + compatibility, this function will convert v0 message content to v1 format. + + ??? note "v0 Content Block Schemas" + + `URLContentBlock`: + + ```python + { + mime_type: NotRequired[str] + type: Literal['image', 'audio', 'file'], + source_type: Literal['url'], + url: str, + } + ``` + + `Base64ContentBlock`: + + ```python + { + mime_type: NotRequired[str] + type: Literal['image', 'audio', 'file'], + source_type: Literal['base64'], + data: str, + } + ``` + + `IDContentBlock`: + + (In practice, this was never used) + + ```python + { + type: Literal["image", "audio", "file"], + source_type: Literal["id"], + id: str, + } + ``` + + `PlainTextContentBlock`: + + ```python + { + mime_type: NotRequired[str] + type: Literal['file'], + source_type: Literal['text'], + url: str, + } + ``` + + If a v1 message is passed in, it will be returned as-is, meaning it is safe to + always pass in v1 messages to this function for assurance. + + For posterity, here are the OpenAI Chat Completions schemas we expect: + + Chat Completions image. Can be URL-based or base64-encoded. Supports MIME types + png, jpeg/jpg, webp, static gif: + { + "type": Literal['image_url'], + "image_url": { + "url": Union["data:$MIME_TYPE;base64,$BASE64_ENCODED_IMAGE", "$IMAGE_URL"], + "detail": Literal['low', 'high', 'auto'] = 'auto', # Supported by OpenAI + } + } + + Chat Completions audio: + { + "type": Literal['input_audio'], + "input_audio": { + "format": Literal['wav', 'mp3'], + "data": str = "$BASE64_ENCODED_AUDIO", + }, + } + + Chat Completions files: either base64 or pre-uploaded file ID + { + "type": Literal['file'], + "file": Union[ + { + "filename": str | None = "$FILENAME", + "file_data": str = "$BASE64_ENCODED_FILE", + }, + { + "file_id": str = "$FILE_ID", # For pre-uploaded files to OpenAI + }, + ], + } + + """ + from langchain_core.messages.block_translators.langchain_v0 import ( # noqa: PLC0415 + _convert_legacy_v0_content_block_to_v1, + ) + from langchain_core.messages.block_translators.openai import ( # noqa: PLC0415 + _convert_openai_format_to_data_block, + ) + + formatted_messages = [] + for message in messages: + # We preserve input messages - the caller may reuse them elsewhere and expects + # them to remain unchanged. We only create a copy if we need to translate. + formatted_message = message + + if isinstance(message.content, list): + for idx, block in enumerate(message.content): + # OpenAI Chat Completions multimodal data blocks to v1 standard + if ( + isinstance(block, dict) + and block.get("type") in {"input_audio", "file"} + # Discriminate between OpenAI/LC format since they share `'type'` + and is_openai_data_block(block) + ): + formatted_message = _ensure_message_copy(message, formatted_message) + + converted_block = _convert_openai_format_to_data_block(block) + _update_content_block(formatted_message, idx, converted_block) + + # Convert multimodal LangChain v0 to v1 standard content blocks + elif ( + isinstance(block, dict) + and block.get("type") + in { + "image", + "audio", + "file", + } + and block.get("source_type") # v1 doesn't have `source_type` + in { + "url", + "base64", + "id", + "text", + } + ): + formatted_message = _ensure_message_copy(message, formatted_message) + + converted_block = _convert_legacy_v0_content_block_to_v1(block) + _update_content_block(formatted_message, idx, converted_block) + continue + + # else, pass through blocks that look like they have v1 format unchanged + + formatted_messages.append(formatted_message) + + return formatted_messages + + +T = TypeVar("T", bound="BaseMessage") + + +def _ensure_message_copy(message: T, formatted_message: T) -> T: + """Create a copy of the message if it hasn't been copied yet.""" + if formatted_message is message: + formatted_message = message.model_copy() + # Shallow-copy content list to allow modifications + formatted_message.content = list(formatted_message.content) + return formatted_message + + +def _update_content_block( + formatted_message: "BaseMessage", idx: int, new_block: ContentBlock | dict[str, Any] +) -> None: + """Update a content block at the given index, handling type issues.""" + # Type ignore needed because: + # - `BaseMessage.content` is typed as `Union[str, list[Union[str, dict]]]` + # - When content is str, indexing fails (index error) + # - When content is list, the items are `Union[str, dict]` but we're assigning + # `Union[ContentBlock, dict]` where ContentBlock is richer than dict + # - This is safe because we only call this when we've verified content is a list and + # we're doing content block conversions + formatted_message.content[idx] = new_block # type: ignore[index, assignment] + + +def _update_message_content_to_blocks(message: T, output_version: str) -> T: + return message.model_copy( + update={ + "content": message.content_blocks, + "response_metadata": { + **message.response_metadata, + "output_version": output_version, + }, + } + ) diff --git a/libs/core/langchain_core/language_models/base.py b/libs/core/langchain_core/language_models/base.py new file mode 100644 index 0000000..44eb7ae --- /dev/null +++ b/libs/core/langchain_core/language_models/base.py @@ -0,0 +1,485 @@ +"""Base language models class.""" + +from __future__ import annotations + +import builtins # noqa: TC003 # runtime-evaluated; subclass `dict()` shadows the builtin +import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping, Sequence +from functools import cache +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeAlias, + TypeVar, + cast, +) + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing_extensions import TypedDict, override + +from langchain_core.caches import BaseCache # noqa: TC001 +from langchain_core.callbacks import Callbacks # noqa: TC001 +from langchain_core.globals import get_verbose +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + MessageLikeRepresentation, + get_buffer_string, +) +from langchain_core.prompt_values import ( + ChatPromptValueConcrete, + PromptValue, + StringPromptValue, +) +from langchain_core.runnables import Runnable, RunnableSerializable + +if TYPE_CHECKING: + from langchain_core.outputs import LLMResult + +try: + from transformers import GPT2TokenizerFast # type: ignore[import-not-found] + + _HAS_TRANSFORMERS = True +except ImportError: + _HAS_TRANSFORMERS = False + + +class LangSmithParams(TypedDict, total=False): + """LangSmith parameters for tracing.""" + + ls_provider: str + """Provider of the model.""" + + ls_model_name: str + """Name of the model.""" + + ls_model_type: Literal["chat", "llm"] + """Type of the model. + + Should be `'chat'` or `'llm'`. + """ + + ls_temperature: float | None + """Temperature for generation.""" + + ls_max_tokens: int | None + """Max tokens for generation.""" + + ls_stop: list[str] | None + """Stop words for generation.""" + ls_integration: str + """Integration that created the trace.""" + + +@cache # Cache the tokenizer +def get_tokenizer() -> Any: + """Get a GPT-2 tokenizer instance. + + This function is cached to avoid re-loading the tokenizer every time it is called. + + Raises: + ImportError: If the transformers package is not installed. + + Returns: + The GPT-2 tokenizer instance. + + """ + if not _HAS_TRANSFORMERS: + msg = ( + "Could not import transformers python package. " + "This is needed in order to calculate get_token_ids. " + "Please install it with `pip install transformers`." + ) + raise ImportError(msg) + # create a GPT-2 tokenizer instance + return GPT2TokenizerFast.from_pretrained("gpt2") + + +_GPT2_TOKENIZER_WARNED = False + + +def _get_token_ids_default_method(text: str) -> list[int]: + """Encode the text into token IDs using the fallback GPT-2 tokenizer.""" + global _GPT2_TOKENIZER_WARNED # noqa: PLW0603 + if not _GPT2_TOKENIZER_WARNED: + warnings.warn( + "Using fallback GPT-2 tokenizer for token counting. " + "Token counts may be inaccurate for non-GPT-2 models. " + "For accurate counts, use a model-specific method if available.", + stacklevel=3, + ) + _GPT2_TOKENIZER_WARNED = True + + tokenizer = get_tokenizer() + + # Pass verbose=False to suppress the "Token indices sequence length is longer than + # the specified maximum sequence length" warning from HuggingFace. This warning is + # about GPT-2's 1024 token context limit, but we're only using the tokenizer for + # counting, not for model input. + return cast("list[int]", tokenizer.encode(text, verbose=False)) + + +LanguageModelInput = PromptValue | str | Sequence[MessageLikeRepresentation] +"""Input to a language model.""" + +LanguageModelOutput = BaseMessage | str +"""Output from a language model.""" + +LanguageModelLike = Runnable[LanguageModelInput, LanguageModelOutput] +"""Input/output interface for a language model.""" + +LanguageModelOutputVar = TypeVar("LanguageModelOutputVar", AIMessage, str) +"""Type variable for the output of a language model.""" + + +def _get_verbosity() -> bool: + return get_verbose() + + +@cache +def _get_langchain_version() -> str | None: + """Return the installed `langchain` version, or `None` if not installed. + + Cached because `importlib.metadata.version` performs a filesystem lookup and + `model_post_init` runs on every `BaseLanguageModel` instantiation. `langchain` + is an optional sibling package, so its absence is expected and not an error. + """ + from importlib.metadata import PackageNotFoundError # noqa: PLC0415 + from importlib.metadata import version as pkg_version # noqa: PLC0415 + + try: + return pkg_version("langchain") + except PackageNotFoundError: + return None + + +# Warm the cache at import time, while we're guaranteed to be on the synchronous +# import path and outside any event loop. Otherwise the first model constructed +# inside async code would run the blocking `os.stat` (via `importlib.metadata`) +# on the event loop, tripping blocking-I/O detectors like blockbuster. +_get_langchain_version() + + +class BaseLanguageModel( + RunnableSerializable[LanguageModelInput, LanguageModelOutputVar], ABC +): + """Abstract base class for interfacing with language models. + + All language model wrappers inherited from `BaseLanguageModel`. + + """ + + cache: BaseCache | bool | None = Field(default=None, exclude=True) + """Whether to cache the response. + + * If `True`, will use the global cache. + * If `False`, will not use a cache + * If `None`, will use the global cache if it's set, otherwise no cache. + * If instance of `BaseCache`, will use the provided cache. + + Caching is not currently supported for streaming methods of models. + """ + + verbose: bool = Field(default_factory=_get_verbosity, exclude=True, repr=False) + """Whether to print out response text.""" + + callbacks: Callbacks = Field(default=None, exclude=True) + """Callbacks to add to the run trace.""" + + tags: list[str] | None = Field(default=None, exclude=True) + """Tags to add to the run trace.""" + + metadata: builtins.dict[str, Any] | None = Field(default=None, exclude=True) + """Metadata to add to the run trace.""" + + custom_get_token_ids: Callable[[str], list[int]] | None = Field( + default=None, exclude=True + ) + """Optional encoder to use for counting tokens.""" + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + def model_post_init(self, _context: Any, /) -> None: + """Pydantic V2 lifecycle hook called automatically after `__init__`. + + Seeds `metadata["lc_versions"]` with the installed `langchain-core` + (and `langchain`, if installed) versions so that every LLM trace + carries the package versions that produced it. + + Partner packages should **not** override this method. Instead, they + should define a `@model_validator(mode="after")` that calls + `_add_version` to append their own version to the same dict. + + !!! warning "Validator naming" + + Each subclass's validator **must** have a unique name. Pydantic + replaces — rather than chains — same-named `model_validator` methods + in child classes. For example, a `BaseChatOpenAI` subclass should + use `_set__version`, not `_set_version`, to avoid silently + dropping the parent's entry. + + Args: + _context: Pydantic validation context (typically `None`). + """ + super().model_post_init(_context) + from langchain_core.version import VERSION # noqa: PLC0415 + + self._add_version("langchain-core", VERSION) + + langchain_version = _get_langchain_version() + if langchain_version is not None: + self._add_version("langchain", langchain_version) + + def _add_version(self, pkg: str, version: str) -> None: + """Record a package version in `metadata.lc_versions` for tracing. + + Each layer in the class hierarchy (core -> langchain -> partner) + calls this so that the resulting metadata dict accumulates *all* + package versions involved in an invocation. + + Example resulting metadata: + + ```python + { + "lc_versions": { + "langchain-core": "1.x.x", + "langchain": "1.x.x", + "langchain-openai": "1.x.x", + } + } + ``` + + Args: + pkg: Package name (e.g., `'langchain-openai'`). + version: Installed version string. + """ + if self.metadata is None: + self.metadata = {} + existing = self.metadata.get("lc_versions") + if existing is not None and not isinstance(existing, Mapping): + warnings.warn( + f"metadata['lc_versions'] expected a dict, got " + f"{type(existing).__name__}; overwriting with package version dict", + stacklevel=2, + ) + existing = None + self.metadata["lc_versions"] = { + **(existing if isinstance(existing, Mapping) else {}), + pkg: version, + } + + @field_validator("verbose", mode="before") + def set_verbose(cls, verbose: bool | None) -> bool: # noqa: FBT001 + """If verbose is `None`, set it. + + This allows users to pass in `None` as verbose to access the global setting. + + Args: + verbose: The verbosity setting to use. + + Returns: + The verbosity setting to use. + + """ + if verbose is None: + return _get_verbosity() + return verbose + + @property + @override + def InputType(self) -> TypeAlias: + """Get the input type for this `Runnable`.""" + # This is a version of LanguageModelInput which replaces the abstract + # base class BaseMessage with a union of its subclasses, which makes + # for a much better schema. + return str | StringPromptValue | ChatPromptValueConcrete | list[AnyMessage] + + @abstractmethod + def generate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks = None, + **kwargs: Any, + ) -> LLMResult: + """Pass a sequence of prompts to the model and return model generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + prompts: List of `PromptValue` objects. + + A `PromptValue` is an object that can be converted to match the format + of any language model (string for pure text generation models and + `BaseMessage` objects for chat models). + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + An `LLMResult`, which contains a list of candidate `Generation` objects for + each input prompt and additional model provider-specific output. + + """ + + @abstractmethod + async def agenerate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks = None, + **kwargs: Any, + ) -> LLMResult: + """Asynchronously pass a sequence of prompts and return model generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + prompts: List of `PromptValue` objects. + + A `PromptValue` is an object that can be converted to match the format + of any language model (string for pure text generation models and + `BaseMessage` objects for chat models). + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + An `LLMResult`, which contains a list of candidate `Generation` objects for + each input prompt and additional model provider-specific output. + + """ + + def with_structured_output( + self, schema: dict[str, Any] | type, **kwargs: Any + ) -> Runnable[LanguageModelInput, dict[str, Any] | BaseModel]: + """Not implemented on this class.""" + # Implement this on child class if there is a way of steering the model to + # generate responses that match a given schema. + raise NotImplementedError + + def _get_ls_params( + self, + stop: list[str] | None = None, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> LangSmithParams: + """Get standard params for tracing.""" + return LangSmithParams() + + def _get_ls_params_with_defaults( + self, + stop: list[str] | None = None, + **kwargs: Any, + ) -> LangSmithParams: + """Wrap _get_ls_params to include any additional default parameters.""" + return self._get_ls_params(stop=stop, **kwargs) + + @property + def _identifying_params(self) -> Mapping[str, Any]: + """Get the identifying parameters.""" + return self.lc_attributes + + def get_token_ids(self, text: str) -> list[int]: + """Return the ordered IDs of the tokens in a text. + + Args: + text: The string input to tokenize. + + Returns: + A list of IDs corresponding to the tokens in the text, in order they occur + in the text. + """ + if self.custom_get_token_ids is not None: + return self.custom_get_token_ids(text) + return _get_token_ids_default_method(text) + + def get_num_tokens(self, text: str) -> int: + """Get the number of tokens present in the text. + + Useful for checking if an input fits in a model's context window. + + This should be overridden by model-specific implementations to provide accurate + token counts via model-specific tokenizers. + + Args: + text: The string input to tokenize. + + Returns: + The integer number of tokens in the text. + + """ + return len(self.get_token_ids(text)) + + def get_num_tokens_from_messages( + self, + messages: list[BaseMessage], + tools: Sequence[Any] | None = None, + ) -> int: + """Get the number of tokens in the messages. + + Useful for checking if an input fits in a model's context window. + + This should be overridden by model-specific implementations to provide accurate + token counts via model-specific tokenizers. + + !!! note + + * The base implementation of `get_num_tokens_from_messages` ignores tool + schemas. + * The base implementation of `get_num_tokens_from_messages` adds additional + prefixes to messages in represent user roles, which will add to the + overall token count. Model-specific implementations may choose to + handle this differently. + + Args: + messages: The message inputs to tokenize. + tools: If provided, sequence of dict, `BaseModel`, function, or + `BaseTool` objects to be converted to tool schemas. + + Returns: + The sum of the number of tokens across the messages. + + """ + if tools is not None: + warnings.warn( + "Counting tokens in tool schemas is not yet supported. Ignoring tools.", + stacklevel=2, + ) + return sum(self.get_num_tokens(get_buffer_string([m])) for m in messages) diff --git a/libs/core/langchain_core/language_models/chat_model_stream.py b/libs/core/langchain_core/language_models/chat_model_stream.py new file mode 100644 index 0000000..045e9a0 --- /dev/null +++ b/libs/core/langchain_core/language_models/chat_model_stream.py @@ -0,0 +1,1441 @@ +"""Per-message streaming objects for content-block protocol events. + +`ChatModelStream` is the synchronous variant returned by +`BaseChatModel.stream_events(version="v3")`. `AsyncChatModelStream` is the +asynchronous variant returned by `BaseChatModel.astream_events(version="v3")`. + +Both expose typed projection properties (`.text`, `.reasoning`, +`.tool_calls`, `.usage`, `.output`) that accumulate protocol +events as they arrive. Projections can be iterated for deltas or +drained for the final accumulated value. + +Raw protocol events are also available via direct iteration on the +stream object (replay-buffer semantics — multiple independent +consumers supported). +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING, Any, cast + +from langchain_core.language_models._compat_bridge import finalize_tool_call_chunk +from langchain_core.messages import AIMessage + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Generator, Iterator, Mapping + + from langchain_protocol.protocol import ( + ContentBlockDeltaData, + ContentBlockFinishData, + FinalizedContentBlock, + InvalidToolCall, + MessageFinishData, + MessageMetadata, + MessagesData, + MessageStartData, + ReasoningContentBlock, + ServerToolCallChunk, + TextContentBlock, + ToolCall, + ToolCallChunk, + UsageInfo, + ) + from typing_extensions import Self + + +# --------------------------------------------------------------------------- +# Tool-call chunk helpers (shared by tool_call_chunk and server_tool_call_chunk) +# --------------------------------------------------------------------------- + + +def _merge_chunk_into_store( + store: dict[int, dict[str, Any]], + idx: int, + block: dict[str, Any], +) -> None: + """Merge a tool-call-chunk delta: sticky id/name, concat args.""" + existing = store.get(idx, {}) + if block.get("id") and "id" not in existing: + existing["id"] = block["id"] + if block.get("name") and "name" not in existing: + existing["name"] = block["name"] + existing["args"] = existing.get("args", "") + (block.get("args") or "") + store[idx] = existing + + +def _merge_block_delta_into_store( + store: dict[int, dict[str, Any]], + idx: int, + fields: dict[str, Any], +) -> None: + """Shallow-merge a block-delta snapshot into an indexed chunk store.""" + existing = store.get(idx, {}) + for key, value in fields.items(): + if value is not None: + existing[key] = value + store[idx] = existing + + +def _event_content_block(data: Mapping[str, Any]) -> dict[str, Any] | None: + """Return start/finish content, tolerating the pre-delta field name.""" + block = data.get("content") or data.get("content_block") + return block if isinstance(block, dict) else None + + +def _legacy_block_to_delta(block: Mapping[str, Any]) -> dict[str, Any]: + """Convert the old content-block delta shape to an explicit delta.""" + btype = block.get("type") + if btype == "text": + return {"type": "text-delta", "text": block.get("text", "")} + if btype == "reasoning": + return { + "type": "reasoning-delta", + "reasoning": block.get("reasoning", ""), + } + if "data" in block: + delta = {"type": "data-delta", "data": block.get("data", "")} + if block.get("encoding") == "base64": + delta["encoding"] = "base64" + return delta + return {"type": "legacy-block-delta", "fields": block} + + +def _event_delta(data: Mapping[str, Any]) -> dict[str, Any] | None: + """Return an explicit delta, converting legacy content-block deltas.""" + delta = data.get("delta") + if isinstance(delta, dict): + return delta + block = data.get("content_block") + if isinstance(block, dict): + return _legacy_block_to_delta(block) + return None + + +def _sweep_chunk_store( + store: dict[int, dict[str, Any]], + *, + finalized_type: str, + finalized_blocks: dict[int, FinalizedContentBlock], + tool_calls_acc: list[ToolCall] | None, + invalid_acc: list[InvalidToolCall], +) -> None: + """Parse each unswept chunk's `args`; record as `finalized_type` or invalid. + + `tool_calls_acc` is only populated when `finalized_type == "tool_call"` + (server-side calls don't surface through `.tool_calls`). + + Deliberately does not backfill `index` onto finalized tool-call blocks: + matches v1 (`AIMessage.init_tool_calls` drops `index` when substituting + `tool_call_chunk` → `tool_call`) and prevents `merge_lists` from + re-merging further chunks into an already-parsed args dict. + """ + for idx in sorted(store): + chunk = store[idx] + # Carry over any non-finalize-rewritten fields the chunk collected + # (e.g., `extras`). `_merge_chunk_into_store` only populates + # `id` / `name` / `args`, so this is empty in practice today; + # future provider-specific fields would flow through here. + extras = { + k: v + for k, v in chunk.items() + if k not in {"type", "id", "name", "args"} and v is not None + } + final_block = finalize_tool_call_chunk( + raw_args=chunk.get("args"), + id_=chunk.get("id"), + name=chunk.get("name"), + extras=extras, + finalized_type=finalized_type, + ) + if final_block["type"] == "invalid_tool_call": + invalid_acc.append(final_block) + elif tool_calls_acc is not None and finalized_type == "tool_call": + tool_calls_acc.append(cast("ToolCall", final_block)) + finalized_blocks[idx] = final_block + store.clear() + + +# --------------------------------------------------------------------------- +# Projection base — shared producer API +# --------------------------------------------------------------------------- + + +class _ProjectionBase: + """Shared state and producer API for sync and async projections. + + The `push` / `complete` / `fail` methods are the producer-side + API — called by the stream as events arrive. Subclasses add the + consumer protocol (sync iteration or async iteration + await). + + `done` and `error` are safe read-only views of the terminal state + for iterators and other siblings that need to observe lifecycle + without reaching into the underlying fields. + """ + + __slots__ = ("_deltas", "_done", "_error", "_final_set", "_final_value") + + def __init__(self) -> None: + """Initialize empty projection state.""" + self._deltas: list[Any] = [] + self._final_value: Any = None + self._final_set: bool = False + self._done: bool = False + self._error: BaseException | None = None + + @property + def done(self) -> bool: + """Whether the projection has finished (successfully or via error).""" + return self._done + + @property + def error(self) -> BaseException | None: + """The terminal error, if any.""" + return self._error + + def push(self, delta: Any) -> None: + """Append a delta value. Producer-side API.""" + self._deltas.append(delta) + + def complete(self, final_value: Any) -> None: + """Set the final accumulated value and mark as done. Producer-side API.""" + self._final_value = final_value + self._final_set = True + self._done = True + + def fail(self, error: BaseException) -> None: + """Mark as errored. Producer-side API.""" + self._error = error + self._done = True + + +# --------------------------------------------------------------------------- +# Sync projections +# --------------------------------------------------------------------------- + + +class SyncProjection(_ProjectionBase): + """Sync iterable of deltas with pull-based backpressure. + + Follows the same `_request_more` convention as langgraph's + `EventLog`: when the cursor catches up to the buffer and the + projection is not done, it calls `_request_more()` to pull more + events from the producer. + + Each call to `__iter__` creates a new cursor at position 0. + Multiple iterators replay all deltas from the start. + """ + + __slots__ = ("_ensure_started", "_request_more") + + def __init__(self) -> None: + """Initialize with no pull callback.""" + super().__init__() + self._ensure_started: Callable[[], None] | None = None + self._request_more: Callable[[], bool] | None = None + + def set_start(self, cb: Callable[[], None] | None) -> None: + """Install a lazy-start callback invoked on first consumption.""" + self._ensure_started = cb + + def set_request_more(self, cb: Callable[[], bool] | None) -> None: + """Install the pull callback the iterator uses to drain the source.""" + self._request_more = cb + + def __iter__(self) -> Iterator[Any]: + """Yield deltas, pulling via `_request_more` when caught up.""" + if self._ensure_started is not None: + self._ensure_started() + cursor = 0 + while True: + if cursor < len(self._deltas): + yield self._deltas[cursor] + cursor += 1 + elif self._error is not None: + raise self._error + elif self._done: + return + elif self._request_more is not None: + while cursor >= len(self._deltas) and not self._done: + if not self._request_more(): + break + if cursor >= len(self._deltas): + if self._error is not None: + raise self._error + return + else: + return + + def get(self) -> Any: + """Drain via `_request_more` and return the final value.""" + if self._ensure_started is not None: + self._ensure_started() + if not self._done and self._request_more is not None: + while not self._done: + if not self._request_more(): + break + if self._error is not None: + raise self._error + return self._final_value + + +class SyncTextProjection(SyncProjection): + """String-specialized sync projection. + + Adds `__str__`, `__bool__`, `__repr__` for ergonomic use with + `.text` and `.reasoning` projections. + """ + + __slots__ = () + + def __str__(self) -> str: + """Drain and return the full accumulated string.""" + val = self.get() + return val if val is not None else "" + + def __bool__(self) -> bool: + """Return whether any deltas have been pushed.""" + return len(self._deltas) > 0 + + def __repr__(self) -> str: + """Return repr of the accumulated text so far.""" + if self._final_set: + return repr(self._final_value) + return repr("".join(self._deltas)) + + +# --------------------------------------------------------------------------- +# Async projection +# --------------------------------------------------------------------------- + + +class AsyncProjection(_ProjectionBase): + """Async iterable of deltas that is also awaitable for the final value. + + Uses an `asyncio.Event` to notify consumers of state changes. Each + waiter — the awaitable (`__await__`) and each async iterator cursor + — shares the event and re-checks its own condition on wake. The event + is cleared before a waiter awaits, so stale "something happened" + signals don't cause spin loops. + + This is single-loop only — producers and consumers must share an + event loop. If cross-thread wake is ever required, revert to a + list-of-futures pattern with `call_soon_threadsafe`. + """ + + __slots__ = ("_arequest_more", "_ensure_started", "_event") + + def __init__(self) -> None: + """Initialize with an un-set event and no pump callback.""" + super().__init__() + self._event = asyncio.Event() + self._arequest_more: Callable[[], Awaitable[bool]] | None = None + self._ensure_started: Callable[[], Awaitable[None]] | None = None + + def set_start(self, cb: Callable[[], Awaitable[None]] | None) -> None: + """Install a lazy-start callback invoked on first consumption.""" + self._ensure_started = cb + + def set_arequest_more(self, cb: Callable[[], Awaitable[bool]] | None) -> None: + """Wire the async pull callback iterators use to drive the source. + + Mirrors `SyncProjection.set_request_more`. Under caller-driven + streaming, consumers call this callback when their buffer is + empty so that the owning graph advances one step. + + Args: + cb: Async no-arg callable returning `True` when a new event + was produced, `False` when the source is exhausted. Pass + `None` to unwire. + """ + self._arequest_more = cb + + def push(self, delta: Any) -> None: + """Append a delta and notify waiters.""" + super().push(delta) + self._event.set() + + def complete(self, final_value: Any) -> None: + """Set the final value, mark done, and notify waiters.""" + super().complete(final_value) + self._event.set() + + def fail(self, error: BaseException) -> None: + """Mark errored and notify waiters.""" + super().fail(error) + self._event.set() + + # -- Async iterable (yields deltas) ------------------------------------ + + def __aiter__(self) -> _AsyncProjectionIterator: + """Return an async iterator over deltas.""" + return _AsyncProjectionIterator(self) + + # -- Awaitable (returns final value) ----------------------------------- + + def __await__(self) -> Generator[Any, None, Any]: + """Await the final accumulated value.""" + return self._await_impl().__await__() + + async def _await_impl(self) -> Any: + """Wait until the final value is set and return it. + + When a caller-driven pump is wired via `set_arequest_more`, drive + it instead of blocking on `self._event`; otherwise fall back to + the event (used by tests that dispatch manually). + """ + if self._ensure_started is not None: + await self._ensure_started() + while not self._final_set: + if self._error is not None: + raise self._error + if self._arequest_more is not None: + if not await self._arequest_more() and not self._final_set: + # Pump exhausted without completing this projection — + # nothing more will arrive. Return current state and + # let callers observe the missing final via the + # returned None / unset error. + break + else: + self._event.clear() + await self._event.wait() + if self._error is not None: + raise self._error + return self._final_value + + +class _AsyncProjectionIterator: + """Async iterator over an `AsyncProjection`'s deltas.""" + + __slots__ = ("_offset", "_proj") + + def __init__(self, proj: AsyncProjection) -> None: + """Initialize cursor at position 0.""" + self._proj = proj + self._offset = 0 + + def __aiter__(self) -> _AsyncProjectionIterator: + """Return self for the async iteration protocol.""" + return self + + async def __anext__(self) -> Any: + """Return the next delta, awaiting if necessary. + + When the projection has an `_arequest_more` pump wired, drain it + in an inner loop (mirrors `SyncProjection.__iter__`) until this + cursor advances or the pump reports exhaustion. Without a pump, + fall back to waiting on the shared event. + """ + proj = self._proj + if proj._ensure_started is not None: # noqa: SLF001 + await proj._ensure_started() # noqa: SLF001 + while True: + # Direct access to the projection's internal list/event is + # intentional — the iterator is the projection's sidekick and + # depends on reading the shared buffer by cursor. + if self._offset < len(proj._deltas): # noqa: SLF001 + item = proj._deltas[self._offset] # noqa: SLF001 + self._offset += 1 + return item + if proj.error is not None: + raise proj.error + if proj.done: + raise StopAsyncIteration + if proj._arequest_more is not None: # noqa: SLF001 + # Caller-driven: drive the producer. Pump may land new + # deltas for a sibling projection — loop until our cursor + # advances, the projection terminates, or the pump is + # exhausted. + while ( + self._offset >= len(proj._deltas) # noqa: SLF001 + and not proj.done + ): + if not await proj._arequest_more(): # noqa: SLF001 + break + if ( + self._offset >= len(proj._deltas) # noqa: SLF001 + and not proj.done + ): + if proj.error is not None: + raise proj.error + raise StopAsyncIteration + else: + proj._event.clear() # noqa: SLF001 + await proj._event.wait() # noqa: SLF001 + + +# --------------------------------------------------------------------------- +# Sync stream +# --------------------------------------------------------------------------- + + +class _ChatModelStreamBase: + """Shared state and event dispatch for chat-model streams. + + Holds accumulated protocol state (text, reasoning, tool calls, + usage, metadata) and the event-dispatch machinery that drives the + typed projections. `ChatModelStream` (sync) and + `AsyncChatModelStream` (async) inherit from this base and add the + projection types and consumer APIs for their flavor. + """ + + # Projection instances — concrete subclasses create them as sync or + # async variants in their own __init__ after calling super(). + _text_proj: _ProjectionBase + _reasoning_proj: _ProjectionBase + _tool_calls_proj: _ProjectionBase + + def __init__( + self, + *, + namespace: list[str] | None = None, + node: str | None = None, + message_id: str | None = None, + ) -> None: + self._namespace = namespace or [] + self._node = node + self._message_id = message_id + + # Accumulated state + self._text_acc: str = "" + self._reasoning_acc: str = "" + # Per-block text / reasoning storage keyed by wire index. Used to + # populate the finalized block payload without cross-contaminating + # other blocks of the same type in the same message. Without + # per-block storage the message-wide accumulator would bleed + # earlier block text into later finalized blocks. + self._text_per_block: dict[int, str] = {} + self._reasoning_per_block: dict[int, str] = {} + self._tool_call_chunks: dict[int, dict[str, Any]] = {} + self._tool_calls_acc: list[ToolCall] = [] + self._invalid_tool_calls_acc: list[InvalidToolCall] = [] + self._server_tool_call_chunks: dict[int, dict[str, Any]] = {} + # Ordered snapshot of every finalized block, keyed by event index. + # Single source of truth for .output.content. Typed accumulators + # (text/reasoning/tool_calls/invalid_tool_calls) continue to serve + # the public projections. + self._blocks: dict[int, FinalizedContentBlock] = {} + self._usage_value: UsageInfo | None = None + self._start_metadata: MessageMetadata | None = None + self._finish_metadata: dict[str, Any] | None = None + self._additional_kwargs: dict[str, Any] | None = None + self._done: bool = False + self._error: BaseException | None = None + self._output_message: AIMessage | None = None + + # Raw event replay buffer + self._events: list[MessagesData] = [] + + # -- Common properties ------------------------------------------------ + + @property + def namespace(self) -> list[str]: + """Graph namespace path for this message.""" + return self._namespace + + @property + def node(self) -> str | None: + """Graph node that produced this message.""" + return self._node + + @property + def message_id(self) -> str | None: + """Stable message identifier.""" + return self._message_id + + def set_message_id(self, message_id: str) -> None: + """Assign the stable message identifier once the run starts. + + Called by the stream driver (`stream_events(version="v3")` / + `astream_events(version="v3")`) after `on_chat_model_start` produces a run + id. Not intended for end-user code. + """ + self._message_id = message_id + + @property + def done(self) -> bool: + """Whether the stream has finished.""" + return self._done + + @property + def has_events(self) -> bool: + """Whether any protocol events have been recorded.""" + return bool(self._events) + + @property + def output_message(self) -> AIMessage | None: + """The assembled message if the stream has finished, else `None`. + + Unlike `ChatModelStream.output` (which blocks until the stream + finishes), this never pumps, blocks, or raises. Intended for the + stream driver (`stream_events(version="v3")` and its async + equivalent) to check whether the stream produced a message before + firing `on_llm_end` callbacks. + """ + return self._output_message + + # -- Event ingestion (public) ------------------------------------------ + + def dispatch(self, event: Mapping[str, Any]) -> None: + """Route a protocol event to the appropriate internal handler. + + Public entry point for feeding events into the stream. Called by + the stream driver (the `stream_events(version="v3")` pump and its + async equivalent) and by any observer or test that needs to + inject protocol events. + """ + self._record_event(event) + event_type = event.get("event") + if event_type == "message-start": + self._push_message_start(cast("MessageStartData", event)) + elif event_type == "content-block-delta": + self._push_content_block_delta(cast("ContentBlockDeltaData", event)) + elif event_type == "content-block-finish": + self._push_content_block_finish(cast("ContentBlockFinishData", event)) + elif event_type == "message-finish": + self._finish(cast("MessageFinishData", event)) + elif event_type == "error": + self.fail(RuntimeError(event.get("message", "Unknown error"))) + # content-block-start is informational — no accumulation needed + + # -- Internal push API (called by dispatch) ---------------------------- + + def _record_event(self, event: Mapping[str, Any]) -> None: + """Append a raw event to the replay buffer.""" + self._events.append(cast("MessagesData", event)) + + def _push_message_start(self, data: MessageStartData) -> None: + """Process a `message-start` event.""" + self._start_metadata = data.get("metadata") + message_id = data.get("id") + if message_id: + self._message_id = message_id + + def _push_content_block_delta(self, data: ContentBlockDeltaData) -> None: + """Process a `content-block-delta` event.""" + delta = _event_delta(data) + if delta is None: + return + event_idx = data.get("index") + dtype = delta.get("type", "") + + if dtype == "text-delta": + delta_text = delta.get("text", "") + if delta_text: + self._text_acc += delta_text + if event_idx is not None: + self._text_per_block[event_idx] = ( + self._text_per_block.get(event_idx, "") + delta_text + ) + self._text_proj.push(delta_text) + elif dtype == "reasoning-delta": + delta_r = delta.get("reasoning", "") + if delta_r: + self._reasoning_acc += delta_r + if event_idx is not None: + self._reasoning_per_block[event_idx] = ( + self._reasoning_per_block.get(event_idx, "") + delta_r + ) + self._reasoning_proj.push(delta_r) + elif dtype == "block-delta": + fields = delta.get("fields") + if not isinstance(fields, dict): + return + btype = fields.get("type", "") + if btype == "tool_call_chunk": + tcc = cast("ToolCallChunk", fields) + idx = data.get("index") + if idx is None: + idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable] + _merge_block_delta_into_store(self._tool_call_chunks, idx, dict(tcc)) + chunk_block: ToolCallChunk = { + "type": "tool_call_chunk", + "id": tcc.get("id"), + "name": tcc.get("name"), + "args": tcc.get("args"), + } + if "index" in tcc: + chunk_block["index"] = tcc["index"] + self._tool_calls_proj.push(chunk_block) + elif btype == "server_tool_call_chunk": + stcc = cast("ServerToolCallChunk", fields) + idx = data.get("index") + if idx is None: + idx = len(self._server_tool_call_chunks) # type: ignore[unreachable] + _merge_block_delta_into_store( + self._server_tool_call_chunks, + idx, + dict(stcc), + ) + elif dtype == "legacy-block-delta": + fields = delta.get("fields") + if not isinstance(fields, dict): + return + btype = fields.get("type", "") + if btype == "tool_call_chunk": + tcc = cast("ToolCallChunk", fields) + idx = data.get("index") + if idx is None: + idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable] + _merge_chunk_into_store(self._tool_call_chunks, idx, dict(tcc)) + legacy_chunk_block: ToolCallChunk = { + "type": "tool_call_chunk", + "id": tcc.get("id"), + "name": tcc.get("name"), + "args": tcc.get("args"), + } + if "index" in tcc: + legacy_chunk_block["index"] = tcc["index"] + self._tool_calls_proj.push(legacy_chunk_block) + elif btype == "server_tool_call_chunk": + stcc = cast("ServerToolCallChunk", fields) + idx = data.get("index") + if idx is None: + idx = len(self._server_tool_call_chunks) # type: ignore[unreachable] + _merge_chunk_into_store( + self._server_tool_call_chunks, + idx, + dict(stcc), + ) + elif dtype == "data-delta": + # Binary/modal payload deltas are reflected in the final + # content-block finish event; there is no dedicated projection. + return + else: + # Transitional legacy path for old `content_block` deltas that + # should not be reachable after `_event_delta` conversion, kept + # here for custom in-tree test fixtures or third-party emitters. + block = data.get("content_block") + if not isinstance(block, dict): + return + btype = block.get("type", "") + if btype != "tool_call_chunk": + return + tcc = cast("ToolCallChunk", block) + idx = data.get("index") + if idx is None: + idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable] + _merge_chunk_into_store(self._tool_call_chunks, idx, dict(tcc)) + fallback_chunk_block: ToolCallChunk = { + "type": "tool_call_chunk", + "id": tcc.get("id"), + "name": tcc.get("name"), + "args": tcc.get("args"), + } + if "index" in tcc: + fallback_chunk_block["index"] = tcc["index"] + self._tool_calls_proj.push(fallback_chunk_block) + + def _resolve_block_text(self, idx: int | None, full_text: str) -> str: + """Return authoritative text for a single text block at `idx`. + + Prefers per-block delta accumulation; reconciles with the finish + event's `full_text` when the provider emits authoritative text + that differs from what the deltas built up. + + Does not mutate `self._text_acc` (the delta-sum accumulator) — + the message-wide projection value is derived from per-block + storage at `_finish` time, so reconciliation remains correct + regardless of finish ordering across blocks. + """ + if idx is None: + # No wire index — legacy behavior: use the message-wide + # accumulator. Preserved for pre-index semantics; not + # exercised by the compat bridge or any in-tree provider. + if full_text and full_text != self._text_acc: + self._text_acc = full_text + return self._text_acc + existing = self._text_per_block.get(idx, "") + if full_text and full_text != existing: + if not existing: + # No deltas arrived for this block — surface the full + # text as a single delta so the stream projection + # reflects it. + self._text_acc += full_text + self._text_proj.push(full_text) + elif full_text.startswith(existing): + # Authoritative text extends the partial deltas — emit + # the tail so delta consumers see the completion. + tail = full_text[len(existing) :] + self._text_acc += tail + self._text_proj.push(tail) + # else: authoritative text replaces the partial deltas + # entirely. No corrective delta is emitted (semantics + # would be ambiguous mid-stream). `_text_acc` is not + # spliced — the final value is computed from per-block + # storage at `_finish`, so this remains correct even when + # other blocks have added to `_text_acc` in between. + self._text_per_block[idx] = full_text + return self._text_per_block.get(idx, "") + + def _resolve_block_reasoning(self, idx: int | None, full_r: str) -> str: + """Return authoritative reasoning text for a single block at `idx`. + + Mirrors `_resolve_block_text` for the reasoning projection. + """ + if idx is None: + if full_r and full_r != self._reasoning_acc: + self._reasoning_acc = full_r + return self._reasoning_acc + existing = self._reasoning_per_block.get(idx, "") + if full_r and full_r != existing: + if not existing: + self._reasoning_acc += full_r + self._reasoning_proj.push(full_r) + elif full_r.startswith(existing): + tail = full_r[len(existing) :] + self._reasoning_acc += tail + self._reasoning_proj.push(tail) + self._reasoning_per_block[idx] = full_r + return self._reasoning_per_block.get(idx, "") + + def _push_content_block_finish(self, data: ContentBlockFinishData) -> None: + """Process a `content-block-finish` event.""" + block = _event_content_block(data) + if block is None: + return + btype = block.get("type", "") + idx = data.get("index") + finalized: FinalizedContentBlock | None = None + + if btype == "text": + text_block = cast("TextContentBlock", block) + full_text = text_block.get("text", "") + block_text = self._resolve_block_text(idx, full_text) + finalized = cast( + "FinalizedContentBlock", + { + **text_block, + "type": "text", + "text": block_text, + }, + ) + elif btype == "reasoning": + reasoning_block = cast("ReasoningContentBlock", block) + full_r = reasoning_block.get("reasoning", "") + block_reasoning = self._resolve_block_reasoning(idx, full_r) + # Keep provider-specific fields alongside the accumulated + # reasoning text. Anthropic's `signature` arrives under + # `extras` and is required on follow-up turns. Only overwrite + # `reasoning` when we have accumulated content; OpenAI can + # emit a reasoning block with no text deltas, and writing an + # empty string there makes downstream serializers synthesize + # an empty summary entry. + finalized_dict: dict[str, Any] = {**reasoning_block, "type": "reasoning"} + if block_reasoning: + finalized_dict["reasoning"] = block_reasoning + finalized = cast("FinalizedContentBlock", finalized_dict) + elif btype == "tool_call": + tcb = cast("ToolCall", block) + # Preserve provider-specific fields (extras, etc.) on the + # content block. `_assemble_message` separately projects the + # minimal {id, name, args, type} shape onto + # `AIMessage.tool_calls`. Strip `index` to match v1 + # (`AIMessage.init_tool_calls` rebuilds the block without + # `index`); see `_finalize_block` in `_compat_bridge.py`. + tc = cast( + "ToolCall", + { + **{k: v for k, v in tcb.items() if k != "index"}, + "type": "tool_call", + "id": tcb.get("id", ""), + "name": tcb.get("name", ""), + "args": tcb.get("args", {}), + }, + ) + self._tool_calls_acc.append(tc) + if idx is not None and idx in self._tool_call_chunks: + del self._tool_call_chunks[idx] + finalized = tc + elif btype == "invalid_tool_call": + itc = cast("InvalidToolCall", block) + # Strip `index` on the stored block to stay symmetric with + # the `tool_call` path. + itc = cast( + "InvalidToolCall", + {k: v for k, v in itc.items() if k != "index"}, + ) + self._invalid_tool_calls_acc.append(itc) + # Critical: drop the stale chunk so _finish's sweep doesn't revive + # it as an empty-args ToolCall. + if idx is not None and idx in self._tool_call_chunks: + del self._tool_call_chunks[idx] + if idx is not None and idx in self._server_tool_call_chunks: + del self._server_tool_call_chunks[idx] + finalized = itc + elif btype in { + "server_tool_call", + "server_tool_result", + "image", + "audio", + "video", + "file", + "non_standard", + }: + if btype == "server_tool_call" and idx is not None: + self._server_tool_call_chunks.pop(idx, None) + finalized = cast("FinalizedContentBlock", block) + + if finalized is not None and idx is not None: + # Backfill the wire index onto the finalized block when the + # source didn't supply one. `langchain_core.utils._merge`'s + # block-merger (used by `AIMessageChunk.__add__` / + # `add_ai_message_chunks`) keys on `block["index"]` to group + # deltas into the same output block — without it, a v2- + # assembled `AIMessage` that later re-enters the chunk + # aggregation path won't merge cleanly. Client-side + # `tool_call` / `invalid_tool_call` blocks are excluded: v1 + # finalization drops `index` on them so further deltas + # cannot clobber already-parsed args, and v2 mirrors that. + if btype not in {"tool_call", "invalid_tool_call"}: + finalized.setdefault("index", idx) + self._blocks[idx] = finalized + + def _finish(self, data: MessageFinishData) -> None: + """Process a `message-finish` event.""" + self._done = True + self._usage_value = data.get("usage") + self._finish_metadata = cast("dict[str, Any] | None", data.get("metadata")) + # Off-spec extension carrying provider-side `additional_kwargs` + # that don't map onto a typed protocol field (e.g. Gemini's + # `__gemini_function_call_thought_signatures__`). The compat + # bridge emits this on `message-finish` so the assembled message + # carries the same data `ainvoke` would have preserved. + self._additional_kwargs = cast( + "dict[str, Any] | None", + cast("dict[str, Any]", data).get("additional_kwargs"), + ) + + # Finalize any unswept chunks — both client- and server-side. + _sweep_chunk_store( + self._tool_call_chunks, + finalized_type="tool_call", + finalized_blocks=self._blocks, + tool_calls_acc=self._tool_calls_acc, + invalid_acc=self._invalid_tool_calls_acc, + ) + _sweep_chunk_store( + self._server_tool_call_chunks, + finalized_type="server_tool_call", + finalized_blocks=self._blocks, + tool_calls_acc=None, + invalid_acc=self._invalid_tool_calls_acc, + ) + + # Prefer the per-block sum when any indexed text / reasoning + # arrived — it stays correct regardless of finish ordering and + # of whether finish events carried authoritative text that + # differed from the deltas. Fall back to the delta-sum + # accumulator only for the legacy no-index path. + if self._text_per_block: + text_final = "".join( + self._text_per_block[i] for i in sorted(self._text_per_block) + ) + else: + text_final = self._text_acc + if self._reasoning_per_block: + reasoning_final = "".join( + self._reasoning_per_block[i] for i in sorted(self._reasoning_per_block) + ) + else: + reasoning_final = self._reasoning_acc + + self._text_proj.complete(text_final) + self._reasoning_proj.complete(reasoning_final) + self._tool_calls_proj.complete(self._tool_calls_acc) + self._output_message = self._assemble_message() + + def fail(self, error: BaseException) -> None: + """Mark the stream as errored and propagate to all projections. + + Public API — called by the stream driver (`stream_events(version="v3")` / + `astream_events(version="v3")`) when the underlying producer raises, by + `dispatch` when an `error` protocol event arrives, and by + cancellation paths. + """ + self._done = True + self._error = error + self._text_proj.fail(error) + self._reasoning_proj.fail(error) + self._tool_calls_proj.fail(error) + + def _assemble_message(self) -> AIMessage: + """Build an `AIMessage` from accumulated state. + + Content is built from `self._blocks`, an index-ordered snapshot of + finalized protocol blocks. The bare-string fast path is used when + the message has exactly one `text` block (the common chat case); + otherwise content is a list of protocol-shape block dicts. + """ + content: Any + if not self._blocks: + # No protocol blocks ever arrived. Fall back to the accumulated + # text (possibly empty) as bare-string content. + content = self._text_acc + else: + # `ChatModelStream` is the v1 content-block surface: content + # is always a list of protocol blocks when any block arrived. + # Do not collapse a single text block down to a bare string — + # that would drop block-level fields (`id`, `index`, + # annotations, extras) that downstream serializers need to + # round-trip the message on a follow-up turn. + ordered_blocks = [self._blocks[idx] for idx in sorted(self._blocks)] + content = [dict(b) for b in ordered_blocks] + + response_metadata: dict[str, Any] = {} + if self._start_metadata: + if "provider" in self._start_metadata: + response_metadata["model_provider"] = self._start_metadata["provider"] + if "model" in self._start_metadata: + response_metadata["model_name"] = self._start_metadata["model"] + if self._finish_metadata: + response_metadata.update(self._finish_metadata) + # Pin `output_version` last: `stream_events(version="v3")` always + # assembles content as v1 protocol blocks, regardless of the + # provider's configured output format. + # A provider-supplied `output_version` in finish metadata (e.g. + # `"responses/v1"` from `ChatOpenAI(use_responses_api=True, ...)`) would + # otherwise cause `AIMessage.content_blocks` to re-run the wrong + # translator on already-v1 content. + response_metadata["output_version"] = "v1" + + tool_calls = [ + { + "id": tc.get("id", ""), + "name": tc.get("name", ""), + "args": tc.get("args", {}), + "type": "tool_call", + } + for tc in self._tool_calls_acc + ] + + invalid_tool_calls = [ + { + "type": "invalid_tool_call", + "id": itc.get("id") or None, + "name": itc.get("name") or None, + "args": itc.get("args") or None, + "error": itc.get("error"), + } + for itc in self._invalid_tool_calls_acc + ] + + message_kwargs: dict[str, Any] = { + "content": content, + "id": self._message_id, + "tool_calls": tool_calls, + "invalid_tool_calls": invalid_tool_calls, + "usage_metadata": self._usage_value, + "response_metadata": response_metadata, + } + if self._additional_kwargs: + message_kwargs["additional_kwargs"] = dict(self._additional_kwargs) + return AIMessage(**message_kwargs) + + +# --------------------------------------------------------------------------- +# Sync stream +# --------------------------------------------------------------------------- + + +class ChatModelStream(_ChatModelStreamBase): + """Synchronous per-message streaming object for a single LLM response. + + Returned by `BaseChatModel.stream_events(version="v3")`. Content-block protocol + events are fed into this object and accumulated into typed projections. + + Projections (always return the same cached object): + + - `.text` — iterable of `str` deltas; `str()` for full text + - `.reasoning` — same as `.text` for reasoning content + - `.tool_calls` — iterable of `ToolCallChunk` deltas; + `.get()` returns `list[ToolCall]` + - `.output` — blocking property, returns assembled `AIMessage` + + Usage info is available on `.output.usage_metadata` once the stream + has finished. + + !!! note "Output shape is always v1 content blocks" + + `.output.content` is always a list of v1 protocol blocks + (text, reasoning, tool_call, image, …), regardless of the + underlying model's `output_version` setting. That attribute + only controls the legacy `stream()` / `astream()` / `invoke()` + paths; `ChatModelStream` is built on the content-block + protocol and emits v1 shapes by construction. + + Raw event iteration:: + + for event in stream: + print(event) # MessagesData dicts + """ + + _text_proj: SyncTextProjection + _reasoning_proj: SyncTextProjection + _tool_calls_proj: SyncProjection + + def __init__( # noqa: D107 + self, + *, + namespace: list[str] | None = None, + node: str | None = None, + message_id: str | None = None, + ) -> None: + super().__init__(namespace=namespace, node=node, message_id=message_id) + # Projections — created eagerly + self._text_proj = SyncTextProjection() + self._reasoning_proj = SyncTextProjection() + self._tool_calls_proj = SyncProjection() + # Pull callback (set by bind_pump or set_request_more) + self._ensure_started: Callable[[], None] | None = None + self._request_more: Callable[[], bool] | None = None + + # -- Pump/pull wiring -------------------------------------------------- + + def bind_pump(self, pump_one: Callable[[], bool]) -> None: + """Bind a pump for standalone streaming. + + Delegates to `set_request_more`. Used by + `BaseChatModel.stream_events(version="v3")`. + """ + self.set_request_more(pump_one) + + def set_start(self, cb: Callable[[], None] | None) -> None: + """Install a lazy-start callback on this stream and its projections.""" + self._ensure_started = cb + self._text_proj.set_start(cb) + self._reasoning_proj.set_start(cb) + self._tool_calls_proj.set_start(cb) + + def set_request_more(self, cb: Callable[[], bool]) -> None: + """Set the pull callback on this stream and all its projections. + + Used by langgraph's `GraphRunStream._wire_request_more` to + connect the shared graph pump. + """ + self._request_more = cb + self._text_proj.set_request_more(cb) + self._reasoning_proj.set_request_more(cb) + self._tool_calls_proj.set_request_more(cb) + + # -- Public projections ------------------------------------------------ + + @property + def text(self) -> SyncTextProjection: + """Text content — iterable of `str` deltas, `str()` for full.""" + return self._text_proj + + @property + def reasoning(self) -> SyncTextProjection: + """Reasoning content — same interface as :attr:`text`.""" + return self._reasoning_proj + + @property + def tool_calls(self) -> SyncProjection: + """Tool calls — iterable of `ToolCallChunk` deltas. + + `.get()` returns finalized `list[ToolCall]`. + """ + return self._tool_calls_proj + + @property + def output(self) -> AIMessage: + """Assembled `AIMessage` — blocks until the stream finishes.""" + self._drain() + if self._error is not None: + raise self._error + if self._output_message is None: + msg = "Stream finished without producing a message" + raise RuntimeError(msg) + return self._output_message + + # -- Raw event iteration (replay buffer) ------------------------------- + + def __iter__(self) -> Iterator[MessagesData]: + """Iterate raw protocol events with replay-buffer semantics.""" + if self._ensure_started is not None: + self._ensure_started() + cursor = 0 + while True: + if cursor < len(self._events): + yield self._events[cursor] + cursor += 1 + elif self._error is not None: + raise self._error + elif self._done: + return + elif self._request_more is not None: + while cursor >= len(self._events) and not self._done: + if not self._request_more(): + break + if cursor >= len(self._events): + if self._error is not None: + raise self._error + return + else: + return + + # -- Internal helpers -------------------------------------------------- + + def _drain(self) -> None: + """Pull all remaining events until done.""" + if self._done: + return + if self._ensure_started is not None: + self._ensure_started() + if self._request_more is not None: + while not self._done: + if not self._request_more(): + break + + +# --------------------------------------------------------------------------- +# Async stream +# --------------------------------------------------------------------------- + + +class AsyncChatModelStream(_ChatModelStreamBase): + """Asynchronous per-message streaming object for a single LLM response. + + Returned by `BaseChatModel.astream_events(version="v3")`. Content-block events + are fed into this object by a background producer task. + + Projections: + + - `.text` — async iterable of text deltas; awaitable for full text + - `.reasoning` — async iterable of reasoning deltas; awaitable + - `.tool_calls` — async iterable of `ToolCallChunk` deltas; + awaitable for `list[ToolCall]` + - `.output` — awaitable for assembled `AIMessage` + + Usage info is available on `.output.usage_metadata` once the stream + has finished. + + !!! note "Output shape is always v1 content blocks" + + The assembled message's content is always a list of v1 + protocol blocks, regardless of the model's `output_version` + setting — see `ChatModelStream` for the full rationale. + + The stream itself is awaitable (`msg = await stream`) and + async-iterable (`async for event in stream`). + """ + + _text_proj: AsyncProjection + _reasoning_proj: AsyncProjection + _tool_calls_proj: AsyncProjection + + def __init__( # noqa: D107 + self, + *, + namespace: list[str] | None = None, + node: str | None = None, + message_id: str | None = None, + ) -> None: + super().__init__(namespace=namespace, node=node, message_id=message_id) + self._text_proj = AsyncProjection() + self._reasoning_proj = AsyncProjection() + self._tool_calls_proj = AsyncProjection() + self._output_proj = AsyncProjection() + self._events_proj = AsyncProjection() + self._ensure_started: Callable[[], Awaitable[None]] | None = None + self._producer_task: asyncio.Task[None] | None = None + # Teardown callback invoked by `aclose()` only when the producer + # task was cancelled before its body ran (so the normal + # `_produce` CancelledError handler — which fires + # `on_llm_error` — never executed). Set by `astream_events(version="v3")`. + self._on_aclose_fail: Callable[[BaseException], Awaitable[None]] | None = None + + # -- Pump/pull wiring (async) ------------------------------------------ + + def set_arequest_more(self, cb: Callable[[], Awaitable[bool]] | None) -> None: + """Fan the async pump callback out to every projection. + + Used by langgraph's `AsyncGraphRunStream._wire_arequest_more` so + cursors on `stream.text`, `stream.reasoning`, etc. can drive the + shared graph pump when their buffer is empty. + + Args: + cb: Async no-arg callable returning `True` when a new event + was produced, `False` when the source is exhausted. Pass + `None` to unwire. + """ + for proj in ( + self._text_proj, + self._reasoning_proj, + self._tool_calls_proj, + self._output_proj, + self._events_proj, + ): + proj.set_arequest_more(cb) + + def set_start(self, cb: Callable[[], Awaitable[None]] | None) -> None: + """Install a lazy-start callback on this stream and its projections.""" + self._ensure_started = cb + for proj in ( + self._text_proj, + self._reasoning_proj, + self._tool_calls_proj, + self._output_proj, + self._events_proj, + ): + proj.set_start(cb) + + # -- Public projections ------------------------------------------------ + + @property + def text(self) -> AsyncProjection: + """Text content — async iterable of deltas, awaitable for full.""" + return self._text_proj + + @property + def reasoning(self) -> AsyncProjection: + """Reasoning content — same interface as :attr:`text`.""" + return self._reasoning_proj + + @property + def tool_calls(self) -> AsyncProjection: + """Tool calls — async iterable, awaitable for finalized list.""" + return self._tool_calls_proj + + @property + def output(self) -> AsyncProjection: + """Assembled `AIMessage` — awaitable.""" + return self._output_proj + + def __await__(self) -> Generator[Any, None, AIMessage]: + """Await the assembled `AIMessage` and full producer lifecycle. + + The producer task is awaited after the output projection resolves so + that post-stream work (notably `on_llm_end` callbacks) has run by + the time the caller's `await` returns. + """ + return self._await_full().__await__() + + async def _await_full(self) -> AIMessage: + if self._ensure_started is not None: + await self._ensure_started() + message: AIMessage = await self._output_proj + if self._producer_task is not None: + await self._producer_task + return message + + def __aiter__(self) -> _AsyncProjectionIterator: + """Iterate raw protocol events asynchronously.""" + return _AsyncProjectionIterator(self._events_proj) + + # -- Cleanup ----------------------------------------------------------- + + async def aclose(self) -> None: + """Cancel the background producer task and release resources. + + If a consumer cancels mid-stream or decides to stop iterating + early, the producer task keeps pumping the provider HTTP call to + completion because `asyncio.Task` has no implicit link to its + awaiter. Call this method to cancel the producer explicitly; the + stream transitions to an errored state with `CancelledError`. + + If the stream has already produced a message successfully (for + example, after `await stream.output`), the producer may still be + running post-stream work such as `on_llm_end` callbacks. In that + case `aclose()` awaits the task rather than cancelling it — + turning a successful run into a cancelled one would drop the + end callback and corrupt tracing. + + Idempotent: safe to call multiple times, including after the + stream has finished normally. Also invoked by the async context + manager protocol on `__aexit__`. + """ + if self._ensure_started is not None and self._producer_task is None: + await self._ensure_started() + + task = self._producer_task + if task is None: + return + if task.done() and self._done: + return + + we_cancelled = not (self._output_message is not None and self._error is None) + if we_cancelled and not task.done(): + task.cancel() + + # Wait for the task via a linked `Future`, not by awaiting the + # task directly. Awaiting the task would raise `CancelledError` + # in two indistinguishable cases: (1) the task we just cancelled + # completed, (2) our caller cancelled us. `asyncio.Task.cancelling()` + # disambiguates on 3.11+ but doesn't exist on 3.10. + # + # The `done_future` resolves with `None` whenever the task + # finishes (any reason). It is not a `Task` itself, so its + # `await` only raises when our caller is cancelled — giving us + # a portable, unambiguous signal to propagate. + if not task.done(): + loop = asyncio.get_running_loop() + done_future: asyncio.Future[None] = loop.create_future() + + def _link(_: asyncio.Task[None]) -> None: + if not done_future.done(): + done_future.set_result(None) + + task.add_done_callback(_link) + try: + await done_future + finally: + task.remove_done_callback(_link) + + # If the task was cancelled before `_produce` ran (e.g. + # `astream_events(version="v3")` immediately followed by `aclose()`), the stream + # never reached `_produce`'s CancelledError handler — its + # projections are still pending and no end-of-lifecycle callback + # has fired. Resolve both here so callers of `await stream.output` + # don't hang and tracing sees a matching end event. + if we_cancelled and not self._done: + cancel_exc = asyncio.CancelledError() + self.fail(cancel_exc) + teardown = self._on_aclose_fail + if teardown is not None: + with contextlib.suppress(Exception): + await teardown(cancel_exc) + + async def __aenter__(self) -> Self: + """Enter the async context — returns self.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + """Exit the async context — cancels the producer via `aclose()`.""" + del exc_type, exc, tb + await self.aclose() + + # -- Internal API (extend base to drive async projections) ------------- + + def _record_event(self, event: Mapping[str, Any]) -> None: + """Record event and push to async event replay projection.""" + super()._record_event(event) + self._events_proj.push(cast("MessagesData", event)) + + def _finish(self, data: MessageFinishData) -> None: + """Finish base projections and async-only projections.""" + super()._finish(data) + self._output_proj.complete(self._output_message) + self._events_proj.complete(self._events) + + def fail(self, error: BaseException) -> None: + """Fail base projections and async-only projections.""" + super().fail(error) + self._output_proj.fail(error) + self._events_proj.fail(error) + + +__all__ = [ + "AsyncChatModelStream", + "AsyncProjection", + "ChatModelStream", + "SyncProjection", + "SyncTextProjection", +] diff --git a/libs/core/langchain_core/language_models/chat_models.py b/libs/core/langchain_core/language_models/chat_models.py new file mode 100644 index 0000000..3ed4b94 --- /dev/null +++ b/libs/core/langchain_core/language_models/chat_models.py @@ -0,0 +1,2711 @@ +"""Chat models for conversational AI.""" + +from __future__ import annotations + +import asyncio +import builtins # noqa: TC003 # runtime-evaluated; subclass `dict()` shadows the builtin +import contextlib +import inspect +import json +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator, Callable, Iterator, Sequence +from functools import cached_property +from operator import itemgetter +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from langchain_protocol.protocol import MessageFinishData +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self, override + +from langchain_core._api import beta, deprecated, suppress_langchain_deprecation_warning +from langchain_core.caches import BaseCache +from langchain_core.callbacks import ( + AsyncCallbackManager, + AsyncCallbackManagerForLLMRun, + CallbackManager, + CallbackManagerForLLMRun, + Callbacks, +) +from langchain_core.globals import get_llm_cache +from langchain_core.language_models._compat_bridge import ( + achunks_to_events, + amessage_to_events, + chunks_to_events, + message_to_events, +) +from langchain_core.language_models._utils import ( + _filter_invocation_params_for_tracing, + _normalize_messages, + _update_message_content_to_blocks, +) +from langchain_core.language_models.base import ( + BaseLanguageModel, + LangSmithParams, + LanguageModelInput, +) +from langchain_core.language_models.chat_model_stream import ( + AsyncChatModelStream, + ChatModelStream, +) +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, + AIMessageChunk, + AnyMessage, + BaseMessage, + convert_to_messages, + is_data_content_block, + message_chunk_to_message, +) +from langchain_core.messages import content as types +from langchain_core.messages.block_translators.openai import ( + convert_to_openai_image_block, +) +from langchain_core.output_parsers.openai_tools import ( + JsonOutputKeyToolsParser, + JsonOutputToolsParser, + PydanticToolsParser, +) +from langchain_core.outputs import ( + ChatGeneration, + ChatGenerationChunk, + ChatResult, + Generation, + LLMResult, + RunInfo, +) +from langchain_core.outputs.chat_generation import merge_chat_generation_chunks +from langchain_core.prompt_values import ChatPromptValue, PromptValue, StringPromptValue +from langchain_core.rate_limiters import BaseRateLimiter +from langchain_core.runnables import RunnableBinding, RunnableMap, RunnablePassthrough +from langchain_core.runnables.config import ensure_config, run_in_executor +from langchain_core.tracers._streaming import ( + _StreamingCallbackHandler, + _V2StreamingCallbackHandler, +) +from langchain_core.utils.function_calling import ( + convert_to_json_schema, + convert_to_openai_tool, +) +from langchain_core.utils.pydantic import is_basemodel_subclass +from langchain_core.utils.utils import LC_ID_PREFIX, from_env + +if TYPE_CHECKING: + import uuid + from collections.abc import Awaitable + + from langchain_protocol.protocol import MessagesData + + from langchain_core.runnables import Runnable, RunnableConfig + from langchain_core.runnables.schema import StreamEvent + from langchain_core.tools import BaseTool + + +def _generate_response_from_error(error: BaseException) -> list[ChatGeneration]: + if hasattr(error, "response"): + response = error.response + metadata: dict[str, Any] = {} + if hasattr(response, "json"): + try: + metadata["body"] = response.json() + except Exception: + try: + metadata["body"] = getattr(response, "text", None) + except Exception: + metadata["body"] = None + if hasattr(response, "headers"): + try: + metadata["headers"] = dict(response.headers) + except Exception: + metadata["headers"] = None + if hasattr(response, "status_code"): + metadata["status_code"] = response.status_code + if hasattr(error, "request_id"): + metadata["request_id"] = error.request_id + generations = [ + ChatGeneration(message=AIMessage(content="", response_metadata=metadata)) + ] + else: + generations = [] + + return generations + + +def _format_for_tracing(messages: list[BaseMessage]) -> list[BaseMessage]: + """Format messages for tracing in `on_chat_model_start`. + + - Update image content blocks to OpenAI Chat Completions format (backward + compatibility). + - Add `type` key to content blocks that have a single key. + + Args: + messages: List of messages to format. + + Returns: + List of messages formatted for tracing. + + """ + messages_to_trace = [] + for message in messages: + message_to_trace = message + if isinstance(message.content, list): + for idx, block in enumerate(message.content): + if isinstance(block, dict): + # Update image content blocks to OpenAI # Chat Completions format. + if ( + block.get("type") == "image" + and is_data_content_block(block) + and not ("file_id" in block or block.get("source_type") == "id") + ): + if message_to_trace is message: + # Shallow copy + message_to_trace = message.model_copy() + message_to_trace.content = list(message_to_trace.content) + + message_to_trace.content[idx] = ( # type: ignore[index] # mypy confused by .model_copy + convert_to_openai_image_block(block) + ) + elif ( + block.get("type") == "file" + and is_data_content_block(block) # v0 (image/audio/file) or v1 + and "base64" in block + # Backward compat: convert v1 base64 blocks to v0 + ): + if message_to_trace is message: + # Shallow copy + message_to_trace = message.model_copy() + message_to_trace.content = list(message_to_trace.content) + + message_to_trace.content[idx] = { # type: ignore[index] + **{k: v for k, v in block.items() if k != "base64"}, + "data": block["base64"], + "source_type": "base64", + } + elif len(block) == 1 and "type" not in block: + # Tracing assumes all content blocks have a "type" key. Here + # we add this key if it is missing, and there's an obvious + # choice for the type (e.g., a single key in the block). + if message_to_trace is message: + # Shallow copy + message_to_trace = message.model_copy() + message_to_trace.content = list(message_to_trace.content) + key = next(iter(block)) + message_to_trace.content[idx] = { # type: ignore[index] + "type": key, + key: block[key], + } + messages_to_trace.append(message_to_trace) + + return messages_to_trace + + +def generate_from_stream(stream: Iterator[ChatGenerationChunk]) -> ChatResult: + """Generate from a stream. + + Args: + stream: Iterator of `ChatGenerationChunk`. + + Raises: + ValueError: If no generations are found in the stream. + + Returns: + Chat result. + + """ + generation = next(stream, None) + if generation: + generation += list(stream) + if generation is None: + msg = "No generations found in stream." + raise ValueError(msg) + return ChatResult( + generations=[ + ChatGeneration( + message=message_chunk_to_message(generation.message), + generation_info=generation.generation_info, + ) + ] + ) + + +async def agenerate_from_stream( + stream: AsyncIterator[ChatGenerationChunk], +) -> ChatResult: + """Async generate from a stream. + + Args: + stream: AsyncIterator of `ChatGenerationChunk`. + + Returns: + Chat result. + + """ + chunks = [chunk async for chunk in stream] + return await run_in_executor(None, generate_from_stream, iter(chunks)) + + +def _format_ls_structured_output( + ls_structured_output_format: dict[str, Any] | None, +) -> dict[str, Any]: + if ls_structured_output_format: + try: + ls_structured_output_format_dict = { + "ls_structured_output_format": { + "kwargs": ls_structured_output_format.get("kwargs", {}), + "schema": convert_to_json_schema( + ls_structured_output_format["schema"] + ), + } + } + except ValueError: + ls_structured_output_format_dict = {} + else: + ls_structured_output_format_dict = {} + + return ls_structured_output_format_dict + + +class BaseChatModel(BaseLanguageModel[AIMessage], ABC): + r"""Base class for chat models. + + Key imperative methods: + Methods that actually call the underlying model. + + This table provides a brief overview of the main imperative methods. Please see the base `Runnable` reference for full documentation. + + | Method | Input | Output | Description | + | ---------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------- | + | `invoke` | `str` \| `list[dict | tuple | BaseMessage]` \| `PromptValue` | `BaseMessage` | A single chat model call. | + | `ainvoke` | `'''` | `BaseMessage` | Defaults to running `invoke` in an async executor. | + | `stream` | `'''` | `Iterator[BaseMessageChunk]` | Defaults to yielding output of `invoke`. | + | `astream` | `'''` | `AsyncIterator[BaseMessageChunk]` | Defaults to yielding output of `ainvoke`. | + | `astream_events` | `'''` | `AsyncIterator[StreamEvent]` | Event types: `on_chat_model_start`, `on_chat_model_stream`, `on_chat_model_end`. | + | `batch` | `list[''']` | `list[BaseMessage]` | Defaults to running `invoke` in concurrent threads. | + | `abatch` | `list[''']` | `list[BaseMessage]` | Defaults to running `ainvoke` in concurrent threads. | + | `batch_as_completed` | `list[''']` | `Iterator[tuple[int, Union[BaseMessage, Exception]]]` | Defaults to running `invoke` in concurrent threads. | + | `abatch_as_completed` | `list[''']` | `AsyncIterator[tuple[int, Union[BaseMessage, Exception]]]` | Defaults to running `ainvoke` in concurrent threads. | + + Key declarative methods: + Methods for creating another `Runnable` using the chat model. + + This table provides a brief overview of the main declarative methods. Please see the reference for each method for full documentation. + + | Method | Description | + | ---------------------------- | ------------------------------------------------------------------------------------------ | + | `bind_tools` | Create chat model that can call tools. | + | `with_structured_output` | Create wrapper that structures model output using schema. | + | `with_retry` | Create wrapper that retries model calls on failure. | + | `with_fallbacks` | Create wrapper that falls back to other models on failure. | + | `configurable_fields` | Specify init args of the model that can be configured at runtime via the `RunnableConfig`. | + | `configurable_alternatives` | Specify alternative models which can be swapped in at runtime via the `RunnableConfig`. | + + Creating custom chat model: + Custom chat model implementations should inherit from this class. + Please reference the table below for information about which + methods and properties are required or optional for implementations. + + | Method/Property | Description | Required | + | -------------------------------- | ------------------------------------------------------------------ | ----------------- | + | `_generate` | Use to generate a chat result from a prompt | Required | + | `_llm_type` (property) | Used to uniquely identify the type of the model. Used for logging. | Required | + | `_identifying_params` (property) | Represent model parameterization for tracing purposes. | Optional | + | `_stream` | Use to implement streaming | Optional | + | `_agenerate` | Use to implement a native async method | Optional | + | `_astream` | Use to implement async version of `_stream` | Optional | + + """ # noqa: E501 + + rate_limiter: BaseRateLimiter | None = Field(default=None, exclude=True) + "An optional rate limiter to use for limiting the number of requests." + + disable_streaming: bool | Literal["tool_calling"] = False + """Whether to disable streaming for this model. + + If streaming is bypassed, then `stream`/`astream`/`astream_events` will + defer to `invoke`/`ainvoke`. + + - If `True`, will always bypass streaming case. + - If `'tool_calling'`, will bypass streaming case only when the model is called + with a `tools` keyword argument. In other words, LangChain will automatically + switch to non-streaming behavior (`invoke`) only when the tools argument is + provided. This offers the best of both worlds. + - If `False` (Default), will always use streaming case if available. + + The main reason for this flag is that code might be written using `stream` and + a user may want to swap out a given model for another model whose implementation + does not properly support streaming. + """ + + output_version: str | None = Field( + default_factory=from_env("LC_OUTPUT_VERSION", default=None) + ) + """Version of `AIMessage` output format to store in message content. + + `AIMessage.content_blocks` will lazily parse the contents of `content` into a + standard format. This flag can be used to additionally store the standard format + in message content, e.g., for serialization purposes. + + Supported values: + + - `'v0'`: provider-specific format in content (can lazily-parse with + `content_blocks`) + - `'v1'`: standardized format in content (consistent with `content_blocks`) + + Partner packages (e.g., + [`langchain-openai`](https://pypi.org/project/langchain-openai)) can also use this + field to roll out new content formats in a backward-compatible way. + + !!! version-added "Added in `langchain-core` 1.0.0" + + """ + + profile: ModelProfile | None = Field(default=None, exclude=True) + """Profile detailing model capabilities. + + !!! warning "Beta feature" + + This is a beta feature. The format of model profiles is subject to change. + + If not specified, automatically loaded from the provider package on initialization + if data is available. + + Example profile data includes context window sizes, supported modalities, or support + for tool calling, structured output, and other features. + + !!! version-added "Added in `langchain-core` 1.1.0" + """ + + model_config = ConfigDict( + 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) -> builtins.dict[str, Any]: + # self is always a Serializable object in this case, thus the result is + # guaranteed to be a dict since dumpd uses the default callback, which uses + # obj.to_json which always returns TypedDict subclasses + return cast("builtins.dict[str, Any]", dumpd(self)) + + # --- Runnable methods --- + + @property + @override + def OutputType(self) -> Any: + """Get the output type for this `Runnable`.""" + return AnyMessage + + def _convert_input(self, model_input: LanguageModelInput) -> PromptValue: + if isinstance(model_input, PromptValue): + return model_input + if isinstance(model_input, str): + return StringPromptValue(text=model_input) + if isinstance(model_input, Sequence): + return ChatPromptValue(messages=convert_to_messages(model_input)) + msg = ( # type: ignore[unreachable] + f"Invalid input type {type(model_input)}. " + "Must be a PromptValue, str, or list of BaseMessages." + ) + raise ValueError(msg) + + @override + def invoke( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AIMessage: + config = ensure_config(config) + return cast( + "AIMessage", + cast( + "ChatGeneration", + self.generate_prompt( + [self._convert_input(input)], + stop=stop, + callbacks=config.get("callbacks"), + tags=config.get("tags"), + metadata=config.get("metadata"), + run_name=config.get("run_name"), + run_id=config.pop("run_id", None), + **kwargs, + ).generations[0][0], + ).message, + ) + + @override + async def ainvoke( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AIMessage: + config = ensure_config(config) + llm_result = await self.agenerate_prompt( + [self._convert_input(input)], + stop=stop, + callbacks=config.get("callbacks"), + tags=config.get("tags"), + metadata=config.get("metadata"), + run_name=config.get("run_name"), + run_id=config.pop("run_id", None), + **kwargs, + ) + return cast( + "AIMessage", cast("ChatGeneration", llm_result.generations[0][0]).message + ) + + def _streaming_disabled(self, **kwargs: Any) -> bool: + """Return whether streaming is hard-disabled for this call. + + Shared opt-outs honored by both `_should_stream` and + `_should_use_protocol_streaming` — these override any affirmative trigger + (attached handler, `stream=True`, etc.): + + - `self.disable_streaming is True` + - `self.disable_streaming == "tool_calling"` with `tools` passed + - `stream=` in call kwargs + - `self.streaming is False` on the instance + """ + if self.disable_streaming is True: + return True + # We assume tools are passed in via "tools" kwarg in all models. + if self.disable_streaming == "tool_calling" and kwargs.get("tools"): + return True + if "stream" in kwargs and not kwargs["stream"]: + return True + return ( + "streaming" in self.model_fields_set + and getattr(self, "streaming", None) is False + ) + + def _should_stream( + self, + *, + async_api: bool, + run_manager: CallbackManagerForLLMRun + | AsyncCallbackManagerForLLMRun + | None = None, + **kwargs: Any, + ) -> bool: + """Determine if a given model call should hit the streaming API.""" + sync_not_implemented = type(self)._stream == BaseChatModel._stream # noqa: SLF001 + async_not_implemented = type(self)._astream == BaseChatModel._astream # noqa: SLF001 + + # Check if streaming is implemented. + if (not async_api) and sync_not_implemented: + return False + # Note, since async falls back to sync we check both here. + if async_api and async_not_implemented and sync_not_implemented: + return False + + if self._streaming_disabled(**kwargs): + return False + + # Affirmative: explicit `stream=` kwarg. + if kwargs.get("stream"): + return True + + # Affirmative: instance-level `streaming=True` attribute. + if ( + "streaming" in self.model_fields_set + and getattr(self, "streaming", None) is True + ): + return True + + # Affirmative: a v1 streaming callback handler is attached. + handlers = run_manager.handlers if run_manager else [] + return any(isinstance(h, _StreamingCallbackHandler) for h in handlers) + + def _should_use_protocol_streaming( + self, + *, + async_api: bool, + run_manager: CallbackManagerForLLMRun + | AsyncCallbackManagerForLLMRun + | None = None, + **kwargs: Any, + ) -> bool: + """Determine whether an invoke should route through the v2 event path. + + Runs alongside `_should_stream` inside `_generate_with_cache` / + `_agenerate_with_cache` — after the run manager is open — and + wins over the v1 streaming branch when a handler has declared + itself a `_V2StreamingCallbackHandler`. Parallel to + `_should_stream` rather than a delegation — v1 and v2 have + disjoint affirmative triggers. + + Args: + async_api: Whether the caller is on the async path. + run_manager: The active LLM run manager. + **kwargs: Call kwargs; inspected for `disable_streaming` + semantics and an explicit `stream=False` override. + + Returns: + `True` if any attached handler inherits + `_V2StreamingCallbackHandler` and the model can drive the v2 + event generator (natively or via the `_stream` compat + bridge). + """ + # Opt-in: only route through v2 when a v2 handler is attached. + handlers = run_manager.handlers if run_manager else [] + if not any(isinstance(h, _V2StreamingCallbackHandler) for h in handlers): + return False + + # Need a source of v2 events on the requested flavor. A native + # `_(a)stream_chat_model_events` hook bypasses the bridge; + # otherwise the bridge wraps `_stream` / `_astream`. Async can + # fall back to sync. + # + # `cls._stream is not BaseChatModel._stream` is an identity + # check for "subclass overrode `_stream`" — same pattern as + # `_should_stream`. + cls = type(self) + has_native_sync = getattr(cls, "_stream_chat_model_events", None) is not None + has_native_async = getattr(cls, "_astream_chat_model_events", None) is not None + overrides_sync = cls._stream is not BaseChatModel._stream + overrides_async = cls._astream is not BaseChatModel._astream + has_sync_source = has_native_sync or overrides_sync + has_async_source = has_native_async or overrides_async + has_source = ( + (has_sync_source or has_async_source) if async_api else has_sync_source + ) + if not has_source: + return False + + return not self._streaming_disabled(**kwargs) + + def _iter_v2_events( + self, + messages: list[BaseMessage], + *, + run_manager: CallbackManagerForLLMRun, + stream: ChatModelStream, + stop: list[str] | None = None, + **kwargs: Any, + ) -> Iterator[MessagesData]: + """Drive the v2 event generator with per-event dispatch. + + Shared between the `stream_events(version="v3")` pump and the + invoke-time v2 branch in `_generate_with_cache`. Picks the native + `_stream_chat_model_events` hook when the subclass provides one, + else bridges `_stream` chunks via `chunks_to_events`. Each event + is dispatched into `stream` and fired as `on_stream_event` on + the run manager. Run-lifecycle callbacks + (`on_chat_model_start` / `on_llm_end` / `on_llm_error`) and + rate-limiter acquisition are the caller's responsibility. + + Args: + messages: Normalized input messages. + run_manager: Active LLM run manager; receives + `on_stream_event` per event. + stream: Accumulator owned by the caller; receives each + event via `stream.dispatch`. + stop: Optional stop sequences. + **kwargs: Forwarded to the event producer. + + Yields: + Each protocol event produced by the model. + """ + native = cast( + "Callable[..., Iterator[MessagesData]] | None", + getattr(self, "_stream_chat_model_events", None), + ) + if native is not None: + event_iter: Iterator[MessagesData] = native( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + else: + event_iter = chunks_to_events( + self._stream(messages, stop=stop, run_manager=run_manager, **kwargs), + message_id=stream.message_id, + ) + for event in event_iter: + stream.dispatch(event) + run_manager.on_stream_event(event) + yield event + + async def _aiter_v2_events( + self, + messages: list[BaseMessage], + *, + run_manager: AsyncCallbackManagerForLLMRun, + stream: AsyncChatModelStream, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncIterator[MessagesData]: + """Async counterpart to `_iter_v2_events`. + + See `_iter_v2_events` for the shared contract. + """ + native = cast( + "Callable[..., AsyncIterator[MessagesData]] | None", + getattr(self, "_astream_chat_model_events", None), + ) + if native is not None: + event_iter: AsyncIterator[MessagesData] = native( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + else: + event_iter = achunks_to_events( + self._astream(messages, stop=stop, run_manager=run_manager, **kwargs), + message_id=stream.message_id, + ) + async for event in event_iter: + stream.dispatch(event) + await run_manager.on_stream_event(event) + yield event + + @override + def stream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> Iterator[AIMessageChunk]: + if not self._should_stream(async_api=False, **{**kwargs, "stream": True}): + # Model doesn't implement streaming, so use default implementation + yield cast( + "AIMessageChunk", + self.invoke(input, config=config, stop=stop, **kwargs), + ) + else: + config = ensure_config(config) + messages = self._convert_input(input).to_messages() + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **kwargs, **ls_structured_output_format_dict} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = CallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + (run_manager,) = callback_manager.on_chat_model_start( + self._serialized, + [_format_for_tracing(messages)], + invocation_params=params, + options=options, + name=config.get("run_name"), + run_id=config.pop("run_id", None), + batch_size=1, + ) + + chunks: list[ChatGenerationChunk] = [] + + if self.rate_limiter: + self.rate_limiter.acquire(blocking=True) + + try: + input_messages = _normalize_messages(messages) + run_id = "-".join((LC_ID_PREFIX, str(run_manager.run_id))) + yielded = False + index = -1 + index_type = "" + for chunk in self._stream(input_messages, stop=stop, **kwargs): + if chunk.message.id is None: + chunk.message.id = run_id + chunk.message.response_metadata = _gen_info_and_msg_metadata(chunk) + if self.output_version == "v1": + # Overwrite .content with .content_blocks + chunk.message = _update_message_content_to_blocks( + chunk.message, "v1" + ) + for block in cast( + "list[types.ContentBlock]", chunk.message.content + ): + if block["type"] != index_type: + index_type = block["type"] + index += 1 + if "index" not in block: + block["index"] = index + run_manager.on_llm_new_token(chunk.message.content, chunk=chunk) + chunks.append(chunk) + yield cast("AIMessageChunk", chunk.message) + yielded = True + + # Yield a final empty chunk with chunk_position="last" if not yet + # yielded + if ( + yielded + and isinstance(chunk.message, AIMessageChunk) + and not chunk.message.chunk_position + ): + empty_content: str | list[str | dict[str, Any]] = ( + "" if isinstance(chunk.message.content, str) else [] + ) + msg_chunk = AIMessageChunk( + content=empty_content, chunk_position="last", id=run_id + ) + run_manager.on_llm_new_token( + "", chunk=ChatGenerationChunk(message=msg_chunk) + ) + yield msg_chunk + except BaseException as e: + generations_with_error_metadata = _generate_response_from_error(e) + chat_generation_chunk = merge_chat_generation_chunks(chunks) + if chat_generation_chunk: + generations = [ + [chat_generation_chunk], + generations_with_error_metadata, + ] + else: + generations = [generations_with_error_metadata] + run_manager.on_llm_error( + e, + response=LLMResult(generations=generations), + ) + raise + + generation = merge_chat_generation_chunks(chunks) + if generation is None: + err = ValueError("No generation chunks were returned") + run_manager.on_llm_error(err, response=LLMResult(generations=[])) + raise err + + run_manager.on_llm_end(LLMResult(generations=[[generation]])) + + @override + async def astream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncIterator[AIMessageChunk]: + if not self._should_stream(async_api=True, **{**kwargs, "stream": True}): + # No async or sync stream is implemented, so fall back to ainvoke + yield cast( + "AIMessageChunk", + await self.ainvoke(input, config=config, stop=stop, **kwargs), + ) + return + + config = ensure_config(config) + messages = self._convert_input(input).to_messages() + + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **kwargs, **ls_structured_output_format_dict} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = AsyncCallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + (run_manager,) = await callback_manager.on_chat_model_start( + self._serialized, + [_format_for_tracing(messages)], + invocation_params=params, + options=options, + name=config.get("run_name"), + run_id=config.pop("run_id", None), + batch_size=1, + ) + + if self.rate_limiter: + await self.rate_limiter.aacquire(blocking=True) + + chunks: list[ChatGenerationChunk] = [] + + try: + input_messages = _normalize_messages(messages) + run_id = "-".join((LC_ID_PREFIX, str(run_manager.run_id))) + yielded = False + index = -1 + index_type = "" + async for chunk in self._astream( + input_messages, + stop=stop, + **kwargs, + ): + if chunk.message.id is None: + chunk.message.id = run_id + chunk.message.response_metadata = _gen_info_and_msg_metadata(chunk) + if self.output_version == "v1": + # Overwrite .content with .content_blocks + chunk.message = _update_message_content_to_blocks( + chunk.message, "v1" + ) + for block in cast( + "list[types.ContentBlock]", chunk.message.content + ): + if block["type"] != index_type: + index_type = block["type"] + index += 1 + if "index" not in block: + block["index"] = index + await run_manager.on_llm_new_token(chunk.message.content, chunk=chunk) + chunks.append(chunk) + yield cast("AIMessageChunk", chunk.message) + yielded = True + + # Yield a final empty chunk with chunk_position="last" if not yet yielded + if ( + yielded + and isinstance(chunk.message, AIMessageChunk) + and not chunk.message.chunk_position + ): + empty_content: str | list[str | dict[str, Any]] = ( + "" if isinstance(chunk.message.content, str) else [] + ) + msg_chunk = AIMessageChunk( + content=empty_content, chunk_position="last", id=run_id + ) + await run_manager.on_llm_new_token( + "", chunk=ChatGenerationChunk(message=msg_chunk) + ) + yield msg_chunk + except BaseException as e: + generations_with_error_metadata = _generate_response_from_error(e) + chat_generation_chunk = merge_chat_generation_chunks(chunks) + if chat_generation_chunk: + generations = [[chat_generation_chunk], generations_with_error_metadata] + else: + generations = [generations_with_error_metadata] + await run_manager.on_llm_error( + e, + response=LLMResult(generations=generations), + ) + raise + + generation = merge_chat_generation_chunks(chunks) + if not generation: + err = ValueError("No generation chunks were returned") + await run_manager.on_llm_error(err, response=LLMResult(generations=[])) + raise err + + await run_manager.on_llm_end( + LLMResult(generations=[[generation]]), + ) + + # --- stream_events v3 --- + + @beta() + def _chat_model_stream_v3( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> ChatModelStream: + """Internal v3 sync streaming implementation. + + Public entry point: `stream_events(version='v3')`. + """ + config = ensure_config(config) + messages = self._convert_input(input).to_messages() + input_messages = _normalize_messages(messages) + + # Strip tracing-only kwargs before forwarding to `_stream` — matches + # `stream()` / `astream()`. Provider clients reject unknown kwargs, + # so `.with_structured_output().stream_events(version="v3", ...)` + # and any other binding that carries `ls_structured_output_format` + # / `structured_output_format` would raise without this pop. + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **kwargs, **ls_structured_output_format_dict} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = CallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + stream = ChatModelStream() + run_manager: CallbackManagerForLLMRun | None = None + event_iter_ref: Iterator[MessagesData] | None = None + rate_limiter_acquired = self.rate_limiter is None + run_name = config.get("run_name") + run_id = config.pop("run_id", None) + + def ensure_started() -> None: + nonlocal event_iter_ref, run_manager + if event_iter_ref is not None: + return + + (run_manager,) = callback_manager.on_chat_model_start( + self._serialized, + [_format_for_tracing(messages)], + invocation_params=params, + options=options, + name=run_name, + run_id=run_id, + batch_size=1, + ) + stream.set_message_id("-".join((LC_ID_PREFIX, str(run_manager.run_id)))) + event_iter_ref = iter( + self._iter_v2_events( + input_messages, + run_manager=run_manager, + stream=stream, + stop=stop, + **kwargs, + ) + ) + + def pump_one() -> bool: + nonlocal rate_limiter_acquired + ensure_started() + if not rate_limiter_acquired: + assert self.rate_limiter is not None # noqa: S101 + self.rate_limiter.acquire(blocking=True) + rate_limiter_acquired = True + assert event_iter_ref is not None # noqa: S101 + assert run_manager is not None # noqa: S101 + try: + next(event_iter_ref) + except StopIteration: + if not stream.done: + if stream.has_events: + # Native event producers may omit the terminal + # `message-finish`. Close the lifecycle here so + # `on_llm_end` still observes the assembled + # message. A truly empty stream remains an error + # for parity with `stream()`. + stream.dispatch(MessageFinishData(event="message-finish")) + else: + err = ValueError("No generation chunks were returned") + stream.fail(err) + run_manager.on_llm_error( + err, + response=LLMResult(generations=[]), + ) + return False + if stream.done and stream.output_message is not None: + run_manager.on_llm_end( + LLMResult( + generations=[ + [ChatGeneration(message=stream.output_message)], + ], + ), + ) + return False + except BaseException as exc: + stream.fail(exc) + run_manager.on_llm_error( + exc, + response=LLMResult(generations=[]), + ) + return False + if stream.done and stream.output_message is not None: + run_manager.on_llm_end( + LLMResult( + generations=[ + [ChatGeneration(message=stream.output_message)], + ], + ), + ) + return True + + stream.set_start(ensure_started) + stream.bind_pump(pump_one) + return stream + + @beta() + async def _achat_model_stream_v3( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncChatModelStream: + """Internal v3 async streaming implementation. + + Public entry point: `astream_events(version='v3')`. + """ + config = ensure_config(config) + messages = self._convert_input(input).to_messages() + input_messages = _normalize_messages(messages) + + # Strip tracing-only kwargs before forwarding — see the sync v3 + # implementation for the full rationale. + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **kwargs, **ls_structured_output_format_dict} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = AsyncCallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + stream = AsyncChatModelStream() + run_manager: AsyncCallbackManagerForLLMRun | None = None + run_name = config.get("run_name") + run_id = config.pop("run_id", None) + start_lock = asyncio.Lock() + + async def _produce() -> None: + assert run_manager is not None # noqa: S101 + try: + if self.rate_limiter: + await self.rate_limiter.aacquire(blocking=True) + + async for _event in self._aiter_v2_events( + input_messages, + run_manager=run_manager, + stream=stream, + stop=stop, + **kwargs, + ): + pass + if not stream.done: + if stream.has_events: + # Native event producers may omit the terminal + # `message-finish`. Close the lifecycle here so + # `on_llm_end` sees the finalized message. A + # truly empty stream remains an error for parity + # with `astream()`. + stream.dispatch(MessageFinishData(event="message-finish")) + else: + err = ValueError("No generation chunks were returned") + stream.fail(err) + await run_manager.on_llm_error( + err, + response=LLMResult(generations=[]), + ) + return + if stream.done and stream.output_message is not None: + await run_manager.on_llm_end( + LLMResult( + generations=[ + [ChatGeneration(message=stream.output_message)], + ], + ), + ) + except asyncio.CancelledError as exc: + stream.fail(exc) + # Close the callback lifecycle so tracing observes a + # matching end event for the earlier `on_chat_model_start`. + # `on_llm_error` is `@shielded`, so the callback runs to + # completion in the background even though the `await` + # here re-raises our cancellation. + with contextlib.suppress(Exception): + await run_manager.on_llm_error( + exc, + response=LLMResult(generations=[]), + ) + raise + except BaseException as exc: + stream.fail(exc) + await run_manager.on_llm_error( + exc, + response=LLMResult(generations=[]), + ) + + async def ensure_started() -> None: + nonlocal run_manager + if stream._producer_task is not None: # noqa: SLF001 + return + + async with start_lock: + if stream._producer_task is not None: # noqa: SLF001 + return # type: ignore[unreachable] + + (run_manager,) = await callback_manager.on_chat_model_start( + self._serialized, + [_format_for_tracing(messages)], + invocation_params=params, + options=options, + name=run_name, + run_id=run_id, + batch_size=1, + ) + stream.set_message_id("-".join((LC_ID_PREFIX, str(run_manager.run_id)))) + stream._producer_task = asyncio.get_running_loop().create_task( # noqa: SLF001 + _produce() + ) + + async def _on_aclose_fail(exc: BaseException) -> None: + assert run_manager is not None # noqa: S101 + # Invoked by `stream.aclose()` only when the producer was + # cancelled before `_produce` ran — so `on_llm_error` from + # the CancelledError handler never fired. Shielded by the + # callback manager; runs to completion even if our caller + # is being cancelled. + await run_manager.on_llm_error( + exc, + response=LLMResult(generations=[]), + ) + + stream.set_start(ensure_started) + stream._on_aclose_fail = _on_aclose_fail # noqa: SLF001 + return stream + + @overload # type: ignore[override] + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2"] = "v2", + **kwargs: Any, + ) -> Iterator[StreamEvent]: ... + + @overload + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v3"], + stop: list[str] | None = None, + **kwargs: Any, + ) -> ChatModelStream: ... + + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2", "v3"] = "v2", + stop: list[str] | None = None, + **kwargs: Any, + ) -> Iterator[StreamEvent] | ChatModelStream: + """Stream events from this chat model. + + For `version="v1"` / `"v2"`, yields `StreamEvent` dicts (see + `Runnable.stream_events`). For `version="v3"`, returns a + `ChatModelStream` exposing typed projections (`.text`, + `.reasoning`, `.tool_calls`, `.output`). + + !!! warning "Beta" + + `version="v3"` is in beta. The protocol shape, return type, + and surface area may change in future releases. Calling it + emits a `LangChainBetaWarning` at runtime. + + !!! note "v3 always produces v1-shaped content" + + `ChatModelStream.output.content` is always a list of v1 + content blocks (text / reasoning / tool_call / image / …), + regardless of the model's `output_version` attribute. The + setting only affects the legacy `stream()` / `astream()` / + `invoke()` paths. If you're mixing + `stream_events(version="v3")` with those paths in the same + pipeline and need a consistent output shape across them, + set `output_version="v1"` on the model. + + Args: + input: The model input. + config: Optional runnable config. + version: Streaming-event schema version. `"v3"` selects the + content-block-centric streaming protocol. + stop: Optional stop sequences. Only used for `version="v3"`; + ignored otherwise. + **kwargs: Additional keyword arguments. For `version="v3"`, + forwarded to the model. + + Returns: + For `version="v3"`, a `ChatModelStream` with typed + projections. Otherwise an `Iterator[StreamEvent]`. + """ + if version == "v3": + return self._chat_model_stream_v3(input, config, stop=stop, **kwargs) + return super().stream_events( + input, config, version=version, stop=stop, **kwargs + ) + + @overload + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2"] = "v2", + **kwargs: Any, + ) -> AsyncIterator[StreamEvent]: ... + + @overload + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v3"], + stop: list[str] | None = None, + **kwargs: Any, + ) -> Awaitable[AsyncChatModelStream]: ... + + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2", "v3"] = "v2", + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncIterator[StreamEvent] | Awaitable[AsyncChatModelStream]: + """Async variant of `stream_events`. See `stream_events` for full docs.""" + if version == "v3": + return self._achat_model_stream_v3(input, config, stop=stop, **kwargs) + # v1/v2: forward to Runnable.astream_events (async generator). + return super().astream_events( + input, config, version=version, stop=stop, **kwargs + ) + + # --- Custom methods --- + + def _combine_llm_outputs( + self, _llm_outputs: list[builtins.dict[str, Any] | None], / + ) -> builtins.dict[str, Any]: + return {} + + def _convert_cached_generations( + self, cache_val: list[Generation] + ) -> list[ChatGeneration]: + """Convert cached Generation objects to ChatGeneration objects. + + Handle case where cache contains Generation objects instead of + ChatGeneration objects. This can happen due to serialization/deserialization + issues or legacy cache data (see #22389). + + Args: + cache_val: List of cached generation objects. + + Returns: + List of ChatGeneration objects. + + """ + converted_generations = [] + for gen in cache_val: + if isinstance(gen, Generation) and not isinstance(gen, ChatGeneration): + # Convert Generation to ChatGeneration by creating AIMessage + # from the text content + chat_gen = ChatGeneration( + message=AIMessage(content=gen.text), + generation_info=gen.generation_info, + ) + converted_generations.append(chat_gen) + else: + # Already a ChatGeneration or other expected type + if hasattr(gen, "message") and isinstance(gen.message, AIMessage): + # We zero out cost on cache hits + gen.message = gen.message.model_copy( + update={ + "usage_metadata": { + **(gen.message.usage_metadata or {}), + "total_cost": 0, + } + } + ) + converted_generations.append(gen) + return converted_generations + + def _replay_v2_events_for_cache_hit( + self, + generations: list[ChatGeneration], + *, + run_manager: CallbackManagerForLLMRun | None, + **kwargs: Any, + ) -> None: + """Replay cached messages as v2 events when a v2 handler is attached. + + A warm cache must produce the same `on_stream_event` stream as a + cold call so LangGraph-style consumers do not observe behavior + that depends on cache state. Gated by + `_should_use_protocol_streaming` so a `disable_streaming` config + that suppresses v2 on cold calls also suppresses it here. + """ + if run_manager is None or not self._should_use_protocol_streaming( + async_api=False, run_manager=run_manager, **kwargs + ): + return + message_id = f"{LC_ID_PREFIX}-{run_manager.run_id}" + for gen in generations: + msg = getattr(gen, "message", None) + if not isinstance(msg, AIMessage): + continue + for event in message_to_events(msg, message_id=message_id): + run_manager.on_stream_event(event) + + async def _areplay_v2_events_for_cache_hit( + self, + generations: list[ChatGeneration], + *, + run_manager: AsyncCallbackManagerForLLMRun | None, + **kwargs: Any, + ) -> None: + """Async counterpart to `_replay_v2_events_for_cache_hit`.""" + if run_manager is None or not self._should_use_protocol_streaming( + async_api=True, run_manager=run_manager, **kwargs + ): + return + message_id = f"{LC_ID_PREFIX}-{run_manager.run_id}" + for gen in generations: + msg = getattr(gen, "message", None) + if not isinstance(msg, AIMessage): + continue + async for event in amessage_to_events(msg, message_id=message_id): + await run_manager.on_stream_event(event) + + def _get_invocation_params( + self, + stop: list[str] | None = None, + **kwargs: Any, + ) -> builtins.dict[str, Any]: + params = self._dict_for_compat() + params["stop"] = stop + return {**params, **kwargs} + + def _get_ls_params( + self, + stop: list[str] | None = None, + **kwargs: Any, + ) -> LangSmithParams: + """Get standard params for LangSmith tracing. + + Subclasses **should override** this method to populate `ls_provider` + and `ls_model_name` from provider-specific attributes (e.g. `self.model`, + `self.model_name`, `self.model_id`) and to honor per-call overrides + passed via `kwargs["model"]` so that runtime `bind`/`invoke` model + changes are reflected in traces. + + The implementation here is a best-effort fallback for subclasses that + do not override it. It is not part of a stable contract and the + derivation rules may change: + + - `ls_provider` is derived from the class name by stripping a leading + or trailing `"Chat"` and lowercasing the remainder. This produces + ugly values for multi-word providers (e.g. `ChatGoogleGenerativeAI` + would become `"googlegenerativeai"`). + + Override to set a stable, conventional value + such as `"google_genai"`. + - `ls_model_name` is resolved from `kwargs["model"]`, then + `self.model`, then `self.model_name`. + + Subclasses whose model attribute has a different name + (`model_id`, `deployment_name`, ...) must override. + """ + # get default provider from class name + default_provider = self.__class__.__name__ + if default_provider.startswith("Chat"): + default_provider = default_provider[4:].lower() + elif default_provider.endswith("Chat"): + default_provider = default_provider[:-4] + default_provider = default_provider.lower() + + ls_params = LangSmithParams(ls_provider=default_provider, ls_model_type="chat") + if stop: + ls_params["ls_stop"] = stop + + # model + if "model" in kwargs and isinstance(kwargs["model"], str): + ls_params["ls_model_name"] = kwargs["model"] + elif hasattr(self, "model") and isinstance(self.model, str): + ls_params["ls_model_name"] = self.model + elif hasattr(self, "model_name") and isinstance(self.model_name, str): + ls_params["ls_model_name"] = self.model_name + + # temperature + if "temperature" in kwargs and isinstance(kwargs["temperature"], (int, float)): + ls_params["ls_temperature"] = kwargs["temperature"] + elif hasattr(self, "temperature") and isinstance( + self.temperature, (int, float) + ): + ls_params["ls_temperature"] = self.temperature + + # max_tokens + if "max_tokens" in kwargs and isinstance(kwargs["max_tokens"], int): + ls_params["ls_max_tokens"] = kwargs["max_tokens"] + elif hasattr(self, "max_tokens") and isinstance(self.max_tokens, int): + ls_params["ls_max_tokens"] = self.max_tokens + + return ls_params + + def _get_ls_params_with_defaults( + self, + stop: list[str] | None = None, + **kwargs: Any, + ) -> LangSmithParams: + """Wrap _get_ls_params to always include ls_integration.""" + ls_params = self._get_ls_params(stop=stop, **kwargs) + ls_params["ls_integration"] = "langchain_chat_model" + return ls_params + + def _get_llm_string(self, stop: list[str] | None = None, **kwargs: Any) -> str: + if self.is_lc_serializable(): + params = {**kwargs, "stop": stop} + param_string = str(sorted(params.items())) + # This code is not super efficient as it goes back and forth between + # json and dict. + serialized_repr = self._serialized + _cleanup_llm_representation(serialized_repr, 1) + llm_string = json.dumps(serialized_repr, sort_keys=True) + return llm_string + "---" + param_string + params = self._get_invocation_params(stop=stop, **kwargs) + params = {**params, **kwargs} + return str(sorted(params.items())) + + def generate( + self, + messages: list[list[BaseMessage]], + stop: list[str] | None = None, + callbacks: Callbacks = None, + *, + tags: list[str] | None = None, + metadata: builtins.dict[str, Any] | None = None, + run_name: str | None = None, + run_id: uuid.UUID | None = None, + **kwargs: Any, + ) -> LLMResult: + """Pass a sequence of prompts to the model and return model generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + messages: List of list of messages. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + tags: The tags to apply. + metadata: The metadata to apply. + run_name: The name of the run. + run_id: The ID of the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + An `LLMResult`, which contains a list of candidate `Generations` for each + input prompt and additional model provider-specific output. + + """ + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **ls_structured_output_format_dict} + inheritable_metadata = { + **(metadata or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + + callback_manager = CallbackManager.configure( + callbacks, + self.callbacks, + self.verbose, + tags, + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + messages_to_trace = [ + _format_for_tracing(message_list) for message_list in messages + ] + run_managers = callback_manager.on_chat_model_start( + self._serialized, + messages_to_trace, + invocation_params=params, + options=options, + name=run_name, + run_id=run_id, + batch_size=len(messages), + ) + results = [] + input_messages = [ + _normalize_messages(message_list) for message_list in messages + ] + for i, m in enumerate(input_messages): + try: + results.append( + self._generate_with_cache( + m, + stop=stop, + run_manager=run_managers[i] if run_managers else None, + **kwargs, + ) + ) + except BaseException as e: + if run_managers: + generations_with_error_metadata = _generate_response_from_error(e) + run_managers[i].on_llm_error( + e, + response=LLMResult( + generations=[generations_with_error_metadata] + ), + ) + raise + flattened_outputs = [ + LLMResult(generations=[res.generations], llm_output=res.llm_output) + for res in results + ] + llm_output = self._combine_llm_outputs([res.llm_output for res in results]) + generations = [res.generations for res in results] + output = LLMResult(generations=generations, llm_output=llm_output) + if run_managers: + run_infos = [] + for manager, flattened_output in zip( + run_managers, flattened_outputs, strict=False + ): + manager.on_llm_end(flattened_output) + run_infos.append(RunInfo(run_id=manager.run_id)) + output.run = run_infos + return output + + async def agenerate( + self, + messages: list[list[BaseMessage]], + stop: list[str] | None = None, + callbacks: Callbacks = None, + *, + tags: list[str] | None = None, + metadata: builtins.dict[str, Any] | None = None, + run_name: str | None = None, + run_id: uuid.UUID | None = None, + **kwargs: Any, + ) -> LLMResult: + """Asynchronously pass a sequence of prompts to a model and return generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + messages: List of list of messages. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + tags: The tags to apply. + metadata: The metadata to apply. + run_name: The name of the run. + run_id: The ID of the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + An `LLMResult`, which contains a list of candidate `Generations` for each + input prompt and additional model provider-specific output. + + """ + ls_structured_output_format = kwargs.pop( + "ls_structured_output_format", None + ) or kwargs.pop("structured_output_format", None) + ls_structured_output_format_dict = _format_ls_structured_output( + ls_structured_output_format + ) + + params = self._get_invocation_params(stop=stop, **kwargs) + options = {"stop": stop, **ls_structured_output_format_dict} + inheritable_metadata = { + **(metadata or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + + callback_manager = AsyncCallbackManager.configure( + callbacks, + self.callbacks, + self.verbose, + tags, + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + + messages_to_trace = [ + _format_for_tracing(message_list) for message_list in messages + ] + run_managers = await callback_manager.on_chat_model_start( + self._serialized, + messages_to_trace, + invocation_params=params, + options=options, + name=run_name, + batch_size=len(messages), + run_id=run_id, + ) + + input_messages = [ + _normalize_messages(message_list) for message_list in messages + ] + results = await asyncio.gather( + *[ + self._agenerate_with_cache( + m, + stop=stop, + run_manager=run_managers[i] if run_managers else None, + **kwargs, + ) + for i, m in enumerate(input_messages) + ], + return_exceptions=True, + ) + exceptions = [] + for i, res in enumerate(results): + if isinstance(res, BaseException): + if run_managers: + generations_with_error_metadata = _generate_response_from_error(res) + await run_managers[i].on_llm_error( + res, + response=LLMResult( + generations=[generations_with_error_metadata] + ), + ) + exceptions.append(res) + if exceptions: + if run_managers: + await asyncio.gather( + *[ + run_manager.on_llm_end( + LLMResult( + generations=[res.generations], # type: ignore[union-attr] + llm_output=res.llm_output, # type: ignore[union-attr] + ) + ) + for run_manager, res in zip(run_managers, results, strict=False) + if not isinstance(res, Exception) + ] + ) + raise exceptions[0] + flattened_outputs = [ + LLMResult(generations=[res.generations], llm_output=res.llm_output) # type: ignore[union-attr] + for res in results + ] + llm_output = self._combine_llm_outputs([res.llm_output for res in results]) # type: ignore[union-attr] + generations = [res.generations for res in results] # type: ignore[union-attr] + output = LLMResult(generations=generations, llm_output=llm_output) + await asyncio.gather( + *[ + run_manager.on_llm_end(flattened_output) + for run_manager, flattened_output in zip( + run_managers, flattened_outputs, strict=False + ) + ] + ) + if run_managers: + output.run = [ + RunInfo(run_id=run_manager.run_id) for run_manager in run_managers + ] + return output + + @override + def generate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks = None, + **kwargs: Any, + ) -> LLMResult: + prompt_messages = [p.to_messages() for p in prompts] + return self.generate(prompt_messages, stop=stop, callbacks=callbacks, **kwargs) + + @override + async def agenerate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks = None, + **kwargs: Any, + ) -> LLMResult: + prompt_messages = [p.to_messages() for p in prompts] + return await self.agenerate( + prompt_messages, stop=stop, callbacks=callbacks, **kwargs + ) + + def _generate_with_cache( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + llm_cache = self.cache if isinstance(self.cache, BaseCache) else get_llm_cache() + # We should check the cache unless it's explicitly set to False + # A None cache means we should use the default global cache + # if it's configured. + check_cache = self.cache or self.cache is None + if check_cache: + if llm_cache: + llm_string = self._get_llm_string(stop=stop, **kwargs) + normalized_messages = [ + ( + msg.model_copy(update={"id": None}) + if getattr(msg, "id", None) is not None + else msg + ) + for msg in messages + ] + prompt = dumps(normalized_messages) + cache_val = llm_cache.lookup(prompt, llm_string) + if isinstance(cache_val, list): + converted_generations = self._convert_cached_generations(cache_val) + self._replay_v2_events_for_cache_hit( + converted_generations, + run_manager=run_manager, + **kwargs, + ) + return ChatResult(generations=converted_generations) + elif self.cache is None: + pass + else: + msg = "Asked to cache, but no cache found at `langchain.cache`." + raise ValueError(msg) + + # Apply the rate limiter after checking the cache, since + # we usually don't want to rate limit cache lookups, but + # we do want to rate limit API requests. + if self.rate_limiter: + self.rate_limiter.acquire(blocking=True) + + # v2 streaming: preferred over v1 when any attached handler opts in via + # `_V2StreamingCallbackHandler`. Drives the protocol event generator + # (native or `_stream` compat bridge) through the shared helper so + # `on_stream_event` fires per event, then returns a normal `ChatResult` + # so caching / `on_llm_end` stay on the existing generate path. + if self._should_use_protocol_streaming( + async_api=False, + run_manager=run_manager, + **kwargs, + ): + stream_accum = ChatModelStream( + message_id=( + f"{LC_ID_PREFIX}-{run_manager.run_id}" if run_manager else None + ) + ) + assert run_manager is not None # noqa: S101 + for _event in self._iter_v2_events( + messages, + run_manager=run_manager, + stream=stream_accum, + stop=stop, + **kwargs, + ): + pass + if stream_accum.output_message is None: + msg = "v2 stream finished without producing a message" + raise RuntimeError(msg) + result = ChatResult( + generations=[ChatGeneration(message=stream_accum.output_message)] + ) + # If stream is not explicitly set, check if implicitly requested by + # astream_events() or astream_log(). Bail out if _stream not implemented + elif self._should_stream( + async_api=False, + run_manager=run_manager, + **kwargs, + ): + chunks: list[ChatGenerationChunk] = [] + run_id: str | None = ( + f"{LC_ID_PREFIX}-{run_manager.run_id}" if run_manager else None + ) + yielded = False + index = -1 + index_type = "" + for chunk in self._stream(messages, stop=stop, **kwargs): + chunk.message.response_metadata = _gen_info_and_msg_metadata(chunk) + if self.output_version == "v1": + # Overwrite .content with .content_blocks + chunk.message = _update_message_content_to_blocks( + chunk.message, "v1" + ) + for block in cast( + "list[types.ContentBlock]", chunk.message.content + ): + if block["type"] != index_type: + index_type = block["type"] + index += 1 + if "index" not in block: + block["index"] = index + if run_manager: + if chunk.message.id is None: + chunk.message.id = run_id + run_manager.on_llm_new_token(chunk.message.content, chunk=chunk) + chunks.append(chunk) + yielded = True + + # Yield a final empty chunk with chunk_position="last" if not yet yielded + if ( + yielded + and isinstance(chunk.message, AIMessageChunk) + and not chunk.message.chunk_position + ): + empty_content: str | list[str | dict[str, Any]] = ( + "" if isinstance(chunk.message.content, str) else [] + ) + chunk = ChatGenerationChunk( + message=AIMessageChunk( + content=empty_content, chunk_position="last", id=run_id + ) + ) + if run_manager: + run_manager.on_llm_new_token("", chunk=chunk) + chunks.append(chunk) + result = generate_from_stream(iter(chunks)) + elif inspect.signature(self._generate).parameters.get("run_manager"): + result = self._generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + else: + result = self._generate(messages, stop=stop, **kwargs) + + if self.output_version == "v1": + # Overwrite .content with .content_blocks + for generation in result.generations: + generation.message = _update_message_content_to_blocks( + generation.message, "v1" + ) + + # Add response metadata to each generation + for idx, generation in enumerate(result.generations): + if run_manager and generation.message.id is None: + generation.message.id = f"{LC_ID_PREFIX}-{run_manager.run_id}-{idx}" + generation.message.response_metadata = _gen_info_and_msg_metadata( + generation + ) + if len(result.generations) == 1 and result.llm_output is not None: + result.generations[0].message.response_metadata = { + **result.llm_output, + **result.generations[0].message.response_metadata, + } + if check_cache and llm_cache: + llm_cache.update(prompt, llm_string, result.generations) + return result + + async def _agenerate_with_cache( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + llm_cache = self.cache if isinstance(self.cache, BaseCache) else get_llm_cache() + # We should check the cache unless it's explicitly set to False + # A None cache means we should use the default global cache + # if it's configured. + check_cache = self.cache or self.cache is None + if check_cache: + if llm_cache: + llm_string = self._get_llm_string(stop=stop, **kwargs) + normalized_messages = [ + ( + msg.model_copy(update={"id": None}) + if getattr(msg, "id", None) is not None + else msg + ) + for msg in messages + ] + prompt = dumps(normalized_messages) + cache_val = await llm_cache.alookup(prompt, llm_string) + if isinstance(cache_val, list): + converted_generations = self._convert_cached_generations(cache_val) + await self._areplay_v2_events_for_cache_hit( + converted_generations, + run_manager=run_manager, + **kwargs, + ) + return ChatResult(generations=converted_generations) + elif self.cache is None: + pass + else: + msg = "Asked to cache, but no cache found at `langchain.cache`." + raise ValueError(msg) + + # Apply the rate limiter after checking the cache, since + # we usually don't want to rate limit cache lookups, but + # we do want to rate limit API requests. + if self.rate_limiter: + await self.rate_limiter.aacquire(blocking=True) + + # v2 streaming: see sync counterpart in `_generate_with_cache`. + if self._should_use_protocol_streaming( + async_api=True, + run_manager=run_manager, + **kwargs, + ): + stream_accum = AsyncChatModelStream( + message_id=( + f"{LC_ID_PREFIX}-{run_manager.run_id}" if run_manager else None + ) + ) + assert run_manager is not None # noqa: S101 + async for _event in self._aiter_v2_events( + messages, + run_manager=run_manager, + stream=stream_accum, + stop=stop, + **kwargs, + ): + pass + if stream_accum.output_message is None: + msg = "v2 stream finished without producing a message" + raise RuntimeError(msg) + result = ChatResult( + generations=[ChatGeneration(message=stream_accum.output_message)] + ) + # If stream is not explicitly set, check if implicitly requested by + # astream_events() or astream_log(). Bail out if _astream not implemented + elif self._should_stream( + async_api=True, + run_manager=run_manager, + **kwargs, + ): + chunks: list[ChatGenerationChunk] = [] + run_id: str | None = ( + f"{LC_ID_PREFIX}-{run_manager.run_id}" if run_manager else None + ) + yielded = False + index = -1 + index_type = "" + async for chunk in self._astream(messages, stop=stop, **kwargs): + chunk.message.response_metadata = _gen_info_and_msg_metadata(chunk) + if self.output_version == "v1": + # Overwrite .content with .content_blocks + chunk.message = _update_message_content_to_blocks( + chunk.message, "v1" + ) + for block in cast( + "list[types.ContentBlock]", chunk.message.content + ): + if block["type"] != index_type: + index_type = block["type"] + index += 1 + if "index" not in block: + block["index"] = index + if run_manager: + if chunk.message.id is None: + chunk.message.id = run_id + await run_manager.on_llm_new_token( + chunk.message.content, chunk=chunk + ) + chunks.append(chunk) + yielded = True + + # Yield a final empty chunk with chunk_position="last" if not yet yielded + if ( + yielded + and isinstance(chunk.message, AIMessageChunk) + and not chunk.message.chunk_position + ): + empty_content: str | list[str | dict[str, Any]] = ( + "" if isinstance(chunk.message.content, str) else [] + ) + chunk = ChatGenerationChunk( + message=AIMessageChunk( + content=empty_content, chunk_position="last", id=run_id + ) + ) + if run_manager: + await run_manager.on_llm_new_token("", chunk=chunk) + chunks.append(chunk) + result = generate_from_stream(iter(chunks)) + elif inspect.signature(self._agenerate).parameters.get("run_manager"): + result = await self._agenerate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + else: + result = await self._agenerate(messages, stop=stop, **kwargs) + + if self.output_version == "v1": + # Overwrite .content with .content_blocks + for generation in result.generations: + generation.message = _update_message_content_to_blocks( + generation.message, "v1" + ) + + # Add response metadata to each generation + for idx, generation in enumerate(result.generations): + if run_manager and generation.message.id is None: + generation.message.id = f"{LC_ID_PREFIX}-{run_manager.run_id}-{idx}" + generation.message.response_metadata = _gen_info_and_msg_metadata( + generation + ) + if len(result.generations) == 1 and result.llm_output is not None: + result.generations[0].message.response_metadata = { + **result.llm_output, + **result.generations[0].message.response_metadata, + } + if check_cache and llm_cache: + await llm_cache.aupdate(prompt, llm_string, result.generations) + return result + + @abstractmethod + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + """Generate the result. + + Args: + messages: The messages to generate from. + stop: Optional list of stop words to use when generating. + run_manager: Optional callback manager to use for this call. + **kwargs: Additional keyword arguments to pass to the model. + + Returns: + The chat result. + """ + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + """Generate the result. + + Args: + messages: The messages to generate from. + stop: Optional list of stop words to use when generating. + run_manager: Optional callback manager to use for this call. + **kwargs: Additional keyword arguments to pass to the model. + + Returns: + The chat result. + """ + return await run_in_executor( + None, + self._generate, + messages, + stop, + run_manager.get_sync() if run_manager else None, + **kwargs, + ) + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + """Stream the output of the model. + + Args: + messages: The messages to generate from. + stop: Optional list of stop words to use when generating. + run_manager: Optional callback manager to use for this call. + **kwargs: Additional keyword arguments to pass to the model. + + Yields: + The chat generation chunks. + """ + raise NotImplementedError + + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + """Stream the output of the model. + + Args: + messages: The messages to generate from. + stop: Optional list of stop words to use when generating. + run_manager: Optional callback manager to use for this call. + **kwargs: Additional keyword arguments to pass to the model. + + Yields: + The chat generation chunks. + """ + iterator = await run_in_executor( + None, + self._stream, + messages, + stop, + run_manager.get_sync() if run_manager else None, + **kwargs, + ) + done = object() + while True: + item = await run_in_executor( + None, + next, + iterator, + done, + ) + if item is done: + break + yield item # type: ignore[misc] + + async def _call_async( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + callbacks: Callbacks = None, + **kwargs: Any, + ) -> BaseMessage: + result = await self.agenerate( + [messages], stop=stop, callbacks=callbacks, **kwargs + ) + generation = result.generations[0][0] + if isinstance(generation, ChatGeneration): + return generation.message + msg = "Unexpected generation type" + raise ValueError(msg) + + @property + @abstractmethod + def _llm_type(self) -> str: + """Return type of chat model.""" + + @deprecated("1.4.2", alternative="asdict", removal="2.0.0") + @override + def dict(self, **_kwargs: Any) -> builtins.dict[str, Any]: + """DEPRECATED - use `asdict()` instead. + + Return a dictionary representation of the chat model. + """ + return self.asdict() + + def asdict(self) -> builtins.dict[str, Any]: + """Return a dictionary representation of the chat model.""" + starter_dict = dict(self._identifying_params) + starter_dict["_type"] = self._llm_type + return starter_dict + + def _dict_for_compat(self) -> builtins.dict[str, Any]: + """Return the chat model dictionary while preserving deprecated overrides.""" + with suppress_langchain_deprecation_warning(): + return self.dict() + + @override + def bind(self, **kwargs: Any) -> _ChatModelBinding: + """Bind kwargs to this chat model, returning a typed `_ChatModelBinding`. + + Overrides `Runnable.bind` so the result preserves chat-model-specific + `stream_events` / `astream_events` overloads. Without this override, + `model.bind(...).stream_events(version="v3")` would type as + `Iterator[Any]` and `await model.bind(...).astream_events(version="v3")` + as `Any`, forcing callers to `cast`. + """ + return _ChatModelBinding(bound=self, kwargs=kwargs, config={}) + + def bind_tools( + self, + tools: Sequence[builtins.dict[str, Any] | type | Callable[..., Any] | BaseTool], + *, + tool_choice: str | None = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, AIMessage]: + """Bind tools to the model. + + Args: + tools: Sequence of tools to bind to the model. + tool_choice: The tool to use. If "any" then any tool can be used. + + Returns: + A Runnable that returns a message. + + """ + raise NotImplementedError + + def with_structured_output( + self, + schema: builtins.dict[str, Any] | type, + *, + include_raw: bool = False, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, builtins.dict[str, Any] | BaseModel]: + """Model wrapper that returns outputs formatted to match the given schema. + + Args: + schema: The output schema. Can be passed in as: + + - An OpenAI function/tool schema, + - A JSON Schema, + - A `TypedDict` class, + - Or a Pydantic class. + + If `schema` is a Pydantic class then the model output will be a + Pydantic instance of that class, and the model-generated fields will be + validated by the Pydantic class. Otherwise the model output will be a + dict and will not be validated. + + See `langchain_core.utils.function_calling.convert_to_openai_tool` for + more on how to properly specify types and descriptions of schema fields + when specifying a Pydantic or `TypedDict` class. + + include_raw: + If `False` then only the parsed structured output is returned. + + If an error occurs during model output parsing it will be raised. + + If `True` then both the raw model response (a `BaseMessage`) and the + parsed model response will be returned. + + If an error occurs during output parsing it will be caught and returned + as well. + + The final output is always a `dict` with keys `'raw'`, `'parsed'`, and + `'parsing_error'`. + + Raises: + ValueError: If there are any unsupported `kwargs`. + NotImplementedError: If the model does not implement + `with_structured_output()`. + + Returns: + A `Runnable` that takes same inputs as a + `langchain_core.language_models.chat.BaseChatModel`. If `include_raw` is + `False` and `schema` is a Pydantic class, `Runnable` outputs an instance + of `schema` (i.e., a Pydantic object). Otherwise, if `include_raw` is + `False` then `Runnable` outputs a `dict`. + + If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys: + + - `'raw'`: `BaseMessage` + - `'parsed'`: `None` if there was a parsing error, otherwise the type + depends on the `schema` as described above. + - `'parsing_error'`: `BaseException | None` + + ???+ example "Pydantic schema (`include_raw=False`)" + + ```python + from pydantic import BaseModel + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: str + + + model = ChatModel(model="model-name", temperature=0) + structured_model = model.with_structured_output(AnswerWithJustification) + + structured_model.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + + # -> AnswerWithJustification( + # answer='They weigh the same', + # justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.' + # ) + ``` + + ??? example "Pydantic schema (`include_raw=True`)" + + ```python + from pydantic import BaseModel + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: str + + + model = ChatModel(model="model-name", temperature=0) + structured_model = model.with_structured_output( + AnswerWithJustification, include_raw=True + ) + + structured_model.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Ao02pnFYXD6GN1yzc0uXPsvF', 'function': {'arguments': '{"answer":"They weigh the same.","justification":"Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ."}', 'name': 'AnswerWithJustification'}, 'type': 'function'}]}), + # 'parsed': AnswerWithJustification(answer='They weigh the same.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'), + # 'parsing_error': None + # } + ``` + + ??? example "Dictionary schema (`include_raw=False`)" + + ```python + from pydantic import BaseModel + from langchain_core.utils.function_calling import convert_to_openai_tool + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: str + + + dict_schema = convert_to_openai_tool(AnswerWithJustification) + model = ChatModel(model="model-name", temperature=0) + structured_model = model.with_structured_output(dict_schema) + + structured_model.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'answer': 'They weigh the same', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' + # } + ``` + + !!! warning "Behavior changed in `langchain-core` 0.2.26" + + Added support for `TypedDict` class. + + """ # noqa: E501 + _ = kwargs.pop("method", None) + _ = kwargs.pop("strict", None) + if kwargs: + msg = f"Received unsupported arguments {kwargs}" + raise ValueError(msg) + + if type(self).bind_tools is BaseChatModel.bind_tools: + msg = "with_structured_output is not implemented for this model." + raise NotImplementedError(msg) + + llm = self.bind_tools( + [schema], + tool_choice="any", + ls_structured_output_format={ + "kwargs": {"method": "function_calling"}, + "schema": schema, + }, + ) + output_parser: JsonOutputToolsParser + if isinstance(schema, type) and is_basemodel_subclass(schema): + output_parser = PydanticToolsParser(tools=[schema], first_tool_only=True) + else: + key_name = convert_to_openai_tool(schema)["function"]["name"] + output_parser = JsonOutputKeyToolsParser( + key_name=key_name, first_tool_only=True + ) + if include_raw: + parser_assign = RunnablePassthrough.assign( + parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None + ) + parser_none = RunnablePassthrough.assign(parsed=lambda _: None) + parser_with_fallback = parser_assign.with_fallbacks( + [parser_none], exception_key="parsing_error" + ) + return RunnableMap(raw=llm) | parser_with_fallback + return llm | output_parser + + +class _ChatModelBinding(RunnableBinding[LanguageModelInput, AIMessage]): # type: ignore[no-redef] + """`RunnableBinding` that preserves chat-model-typed v3 overloads. + + Returned by `BaseChatModel.bind` so that callers of the bound runnable's + `stream_events(version="v3")` / `astream_events(version="v3")` get the + typed `ChatModelStream` / `AsyncChatModelStream` back without needing + `cast`. At runtime this is a plain `RunnableBinding`; the subclass + exists purely to give the type checker a more specific surface. + + The chat-model narrowing is preserved across further `bind` / + `with_config` calls because `RunnableBinding.bind` constructs its + result via `self.__class__(...)`. + """ + + @classmethod + @override + def lc_id(cls) -> list[str]: + """Serialize as `RunnableBinding`. + + At runtime this class is behaviorally identical to `RunnableBinding`; + keeping the serialized id stable means existing snapshots and the + load mapping continue to work without registering a new entry. + """ + return [*cls.get_lc_namespace(), "RunnableBinding"] + + @overload # type: ignore[override] + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2"] = "v2", + **kwargs: Any, + ) -> Iterator[StreamEvent]: ... + + @overload + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v3"], + stop: list[str] | None = None, + **kwargs: Any, + ) -> ChatModelStream: ... + + def stream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2", "v3"] = "v2", + **kwargs: Any, + ) -> Iterator[StreamEvent] | ChatModelStream: + return super().stream_events(input, config, version=version, **kwargs) + + @overload + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v1", "v2"] = "v2", + **kwargs: Any, + ) -> AsyncIterator[StreamEvent]: ... + + @overload + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + version: Literal["v3"], + stop: list[str] | None = None, + **kwargs: Any, + ) -> Awaitable[AsyncChatModelStream]: ... + + def astream_events( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + **kwargs: Any, + ) -> AsyncIterator[StreamEvent] | Awaitable[AsyncChatModelStream]: + return cast( + "AsyncIterator[StreamEvent] | Awaitable[AsyncChatModelStream]", + super().astream_events(input, config, **kwargs), + ) + + +class SimpleChatModel(BaseChatModel): + """Simplified implementation for a chat model to inherit from. + + !!! note + This implementation is primarily here for backwards compatibility. For new + implementations, please use `BaseChatModel` directly. + + """ + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + output_str = self._call(messages, stop=stop, run_manager=run_manager, **kwargs) + message = AIMessage(content=output_str) + generation = ChatGeneration(message=message) + return ChatResult(generations=[generation]) + + @abstractmethod + def _call( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + """Simpler interface.""" + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + return await run_in_executor( + None, + self._generate, + messages, + stop=stop, + run_manager=run_manager.get_sync() if run_manager else None, + **kwargs, + ) + + +def _gen_info_and_msg_metadata( + generation: ChatGeneration | ChatGenerationChunk, +) -> dict[str, Any]: + return { + **(generation.generation_info or {}), + **generation.message.response_metadata, + } + + +_MAX_CLEANUP_DEPTH = 100 + + +def _cleanup_llm_representation(serialized: Any, depth: int) -> None: + """Remove non-serializable objects from a serialized object.""" + if depth > _MAX_CLEANUP_DEPTH: # Don't cooperate for pathological cases + return + + if not isinstance(serialized, dict): + return + + if ( + "type" in serialized + and serialized["type"] == "not_implemented" + and "repr" in serialized + ): + del serialized["repr"] + + if "graph" in serialized: + del serialized["graph"] + + if "kwargs" in serialized: + kwargs = serialized["kwargs"] + + for value in kwargs.values(): + _cleanup_llm_representation(value, depth + 1) diff --git a/libs/core/langchain_core/language_models/fake.py b/libs/core/langchain_core/language_models/fake.py new file mode 100644 index 0000000..77b7cdd --- /dev/null +++ b/libs/core/langchain_core/language_models/fake.py @@ -0,0 +1,137 @@ +"""Fake LLMs for testing purposes.""" + +import asyncio +import time +from collections.abc import AsyncIterator, Iterator, Mapping +from typing import Any + +from typing_extensions import override + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models import LanguageModelInput +from langchain_core.language_models.llms import LLM +from langchain_core.runnables import RunnableConfig + + +class FakeListLLM(LLM): + """Fake LLM for testing purposes.""" + + responses: list[str] + """List of responses to return in order.""" + # This parameter should be removed from FakeListLLM since + # it's only used by sub-classes. + sleep: float | None = None + """Sleep time in seconds between responses. + + Ignored by FakeListLLM, but used by sub-classes. + """ + i: int = 0 + """Internally incremented after every model invocation. + + Useful primarily for testing purposes. + """ + + @property + @override + def _llm_type(self) -> str: + """Return type of llm.""" + return "fake-list" + + @override + def _call( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + """Return next response.""" + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + return response + + @override + async def _acall( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + """Return next response.""" + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + return response + + @property + @override + def _identifying_params(self) -> Mapping[str, Any]: + return {"responses": self.responses} + + +class FakeListLLMError(Exception): + """Fake error for testing purposes.""" + + +class FakeStreamingListLLM(FakeListLLM): + """Fake streaming list LLM for testing purposes. + + An LLM that will return responses from a list in order. + + This model also supports optionally sleeping between successive + chunks in a streaming implementation. + """ + + error_on_chunk_number: int | None = None + """If set, will raise an exception on the specified chunk number.""" + + @override + def stream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> Iterator[str]: + result = self.invoke(input, config) + for i_c, c in enumerate(result): + if self.sleep is not None: + time.sleep(self.sleep) + + if ( + self.error_on_chunk_number is not None + and i_c == self.error_on_chunk_number + ): + raise FakeListLLMError + yield c + + @override + async def astream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncIterator[str]: + result = await self.ainvoke(input, config) + for i_c, c in enumerate(result): + if self.sleep is not None: + await asyncio.sleep(self.sleep) + + if ( + self.error_on_chunk_number is not None + and i_c == self.error_on_chunk_number + ): + raise FakeListLLMError + yield c diff --git a/libs/core/langchain_core/language_models/fake_chat_models.py b/libs/core/langchain_core/language_models/fake_chat_models.py new file mode 100644 index 0000000..1898f22 --- /dev/null +++ b/libs/core/langchain_core/language_models/fake_chat_models.py @@ -0,0 +1,396 @@ +"""Fake chat models for testing purposes.""" + +import asyncio +import re +import time +from collections.abc import AsyncIterator, Iterator +from typing import Any, Literal, cast + +from typing_extensions import override + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel, SimpleChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.runnables import RunnableConfig + + +class FakeMessagesListChatModel(BaseChatModel): + """Fake chat model for testing purposes.""" + + responses: list[BaseMessage] + """List of responses to **cycle** through in order.""" + sleep: float | None = None + """Sleep time in seconds between responses.""" + i: int = 0 + """Internally incremented after every model invocation.""" + + @override + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + if self.sleep is not None: + time.sleep(self.sleep) + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + generation = ChatGeneration(message=response) + return ChatResult(generations=[generation]) + + @property + @override + def _llm_type(self) -> str: + return "fake-messages-list-chat-model" + + +class FakeListChatModelError(Exception): + """Fake error for testing purposes.""" + + +class FakeListChatModel(SimpleChatModel): + """Fake chat model for testing purposes.""" + + responses: list[str] + """List of responses to **cycle** through in order.""" + sleep: float | None = None + i: int = 0 + """Internally incremented after every model invocation.""" + error_on_chunk_number: int | None = None + """If set, raise an error on the specified chunk number during streaming.""" + + @property + @override + def _llm_type(self) -> str: + return "fake-list-chat-model" + + @override + def _call( + self, + *args: Any, + **kwargs: Any, + ) -> str: + """Return the next response in the list. + + Cycle back to the start if at the end. + """ + if self.sleep is not None: + time.sleep(self.sleep) + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + return response + + @override + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + for i_c, c in enumerate(response): + if self.sleep is not None: + time.sleep(self.sleep) + if ( + self.error_on_chunk_number is not None + and i_c == self.error_on_chunk_number + ): + raise FakeListChatModelError + + chunk_position: Literal["last"] | None = ( + "last" if i_c == len(response) - 1 else None + ) + yield ChatGenerationChunk( + message=AIMessageChunk(content=c, chunk_position=chunk_position) + ) + + @override + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + response = self.responses[self.i] + if self.i < len(self.responses) - 1: + self.i += 1 + else: + self.i = 0 + for i_c, c in enumerate(response): + if self.sleep is not None: + await asyncio.sleep(self.sleep) + if ( + self.error_on_chunk_number is not None + and i_c == self.error_on_chunk_number + ): + raise FakeListChatModelError + chunk_position: Literal["last"] | None = ( + "last" if i_c == len(response) - 1 else None + ) + yield ChatGenerationChunk( + message=AIMessageChunk(content=c, chunk_position=chunk_position) + ) + + @property + @override + def _identifying_params(self) -> dict[str, Any]: + return {"responses": self.responses} + + @override + # manually override batch to preserve batch ordering with no concurrency + def batch( + self, + inputs: list[Any], + config: RunnableConfig | list[RunnableConfig] | None = None, + *, + return_exceptions: bool = False, + **kwargs: Any, + ) -> list[AIMessage]: + if isinstance(config, list): + return [ + self.invoke(m, c, **kwargs) + for m, c in zip(inputs, config, strict=False) + ] + return [self.invoke(m, config, **kwargs) for m in inputs] + + @override + async def abatch( + self, + inputs: list[Any], + config: RunnableConfig | list[RunnableConfig] | None = None, + *, + return_exceptions: bool = False, + **kwargs: Any, + ) -> list[AIMessage]: + if isinstance(config, list): + # do Not use an async iterator here because need explicit ordering + return [ + await self.ainvoke(m, c, **kwargs) + for m, c in zip(inputs, config, strict=False) + ] + # do Not use an async iterator here because need explicit ordering + return [await self.ainvoke(m, config, **kwargs) for m in inputs] + + +class FakeChatModel(SimpleChatModel): + """Fake Chat Model wrapper for testing purposes.""" + + @override + def _call( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + return "fake response" + + @override + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + output_str = "fake response" + message = AIMessage(content=output_str) + generation = ChatGeneration(message=message) + return ChatResult(generations=[generation]) + + @property + def _llm_type(self) -> str: + return "fake-chat-model" + + @property + def _identifying_params(self) -> dict[str, Any]: + return {"key": "fake"} + + +class GenericFakeChatModel(BaseChatModel): + """Generic fake chat model that can be used to test the chat model interface. + + * Chat model should be usable in both sync and async tests + * Invokes `on_llm_new_token` to allow for testing of callback related code for new + tokens. + * Includes logic to break messages into message chunk to facilitate testing of + streaming. + + """ + + messages: Iterator[AIMessage | str] + """Get an iterator over messages. + + This can be expanded to accept other types like Callables / dicts / strings + to make the interface more generic if needed. + + !!! note + if you want to pass a list, you can use `iter` to convert it to an iterator. + + !!! warning + Streaming is not implemented yet. We should try to implement it in the future by + delegating to invoke and then breaking the resulting output into message chunks. + + """ + + @override + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + message = next(self.messages) + message_ = AIMessage(content=message) if isinstance(message, str) else message + generation = ChatGeneration(message=message_) + return ChatResult(generations=[generation]) + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + chat_result = self._generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + if not isinstance(chat_result, ChatResult): + msg = ( # type: ignore[unreachable] + f"Expected generate to return a ChatResult, " + f"but got {type(chat_result)} instead." + ) + raise ValueError(msg) # noqa: TRY004 + + message = chat_result.generations[0].message + + if not isinstance(message, AIMessage): + msg = ( + f"Expected invoke to return an AIMessage, " + f"but got {type(message)} instead." + ) + raise ValueError(msg) # noqa: TRY004 + + content = message.content + + if content: + # Use a regular expression to split on whitespace with a capture group + # so that we can preserve the whitespace in the output. + if not isinstance(content, str): + msg = "Expected content to be a string." + raise ValueError(msg) + + content_chunks = cast("list[str]", re.split(r"(\s)", content)) + + for idx, token in enumerate(content_chunks): + chunk = ChatGenerationChunk( + message=AIMessageChunk(content=token, id=message.id) + ) + if ( + idx == len(content_chunks) - 1 + and isinstance(chunk.message, AIMessageChunk) + and not message.additional_kwargs + ): + chunk.message.chunk_position = "last" + if run_manager: + run_manager.on_llm_new_token(token, chunk=chunk) + yield chunk + + if message.additional_kwargs: + for key, value in message.additional_kwargs.items(): + # We should further break down the additional kwargs into chunks + # Special case for function call + if key == "function_call": + for fkey, fvalue in value.items(): + if isinstance(fvalue, str): + # Break function call by `,` + fvalue_chunks = cast("list[str]", re.split(r"(,)", fvalue)) + for fvalue_chunk in fvalue_chunks: + chunk = ChatGenerationChunk( + message=AIMessageChunk( + id=message.id, + content="", + additional_kwargs={ + "function_call": {fkey: fvalue_chunk} + }, + ) + ) + if run_manager: + run_manager.on_llm_new_token( + "", + chunk=chunk, # No token for function call + ) + yield chunk + else: + chunk = ChatGenerationChunk( + message=AIMessageChunk( + id=message.id, + content="", + additional_kwargs={"function_call": {fkey: fvalue}}, + ) + ) + if run_manager: + run_manager.on_llm_new_token( + "", + chunk=chunk, # No token for function call + ) + yield chunk + else: + chunk = ChatGenerationChunk( + message=AIMessageChunk( + id=message.id, content="", additional_kwargs={key: value} + ) + ) + if run_manager: + run_manager.on_llm_new_token( + "", + chunk=chunk, # No token for function call + ) + yield chunk + + @property + def _llm_type(self) -> str: + return "generic-fake-chat-model" + + +class ParrotFakeChatModel(BaseChatModel): + """Generic fake chat model that can be used to test the chat model interface. + + * Chat model should be usable in both sync and async tests + + """ + + @override + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + if not messages: + msg = "messages list cannot be empty." + raise ValueError(msg) + return ChatResult(generations=[ChatGeneration(message=messages[-1])]) + + @property + def _llm_type(self) -> str: + return "parrot-fake-chat-model" diff --git a/libs/core/langchain_core/language_models/llms.py b/libs/core/langchain_core/language_models/llms.py new file mode 100644 index 0000000..14b09ac --- /dev/null +++ b/libs/core/langchain_core/language_models/llms.py @@ -0,0 +1,1569 @@ +"""Base interface for traditional large language models (LLMs) to expose. + +These are traditionally older models (newer models generally are chat models). +""" + +from __future__ import annotations + +import asyncio +import builtins +import functools +import inspect +import json +import logging +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator, Callable, Iterator, Sequence +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +import yaml +from pydantic import ConfigDict +from tenacity import ( + RetryCallState, + before_sleep_log, + retry, + retry_base, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) +from typing_extensions import override + +from langchain_core._api import deprecated, suppress_langchain_deprecation_warning +from langchain_core.caches import BaseCache +from langchain_core.callbacks import ( + AsyncCallbackManager, + AsyncCallbackManagerForLLMRun, + BaseCallbackManager, + CallbackManager, + CallbackManagerForLLMRun, + Callbacks, +) +from langchain_core.globals import get_llm_cache +from langchain_core.language_models._utils import _filter_invocation_params_for_tracing +from langchain_core.language_models.base import ( + BaseLanguageModel, + LangSmithParams, + LanguageModelInput, +) +from langchain_core.load import dumpd +from langchain_core.messages import ( + convert_to_messages, +) +from langchain_core.outputs import Generation, GenerationChunk, LLMResult, RunInfo +from langchain_core.prompt_values import ChatPromptValue, PromptValue, StringPromptValue +from langchain_core.runnables import RunnableConfig, ensure_config, get_config_list +from langchain_core.runnables.config import run_in_executor + +if TYPE_CHECKING: + import builtins + import uuid + +logger = logging.getLogger(__name__) + +_background_tasks: set[asyncio.Task[None]] = set() + + +@functools.lru_cache +def _log_error_once(msg: str) -> None: + """Log an error once.""" + logger.error(msg) + + +def create_base_retry_decorator( + error_types: list[type[BaseException]], + max_retries: int = 1, + run_manager: AsyncCallbackManagerForLLMRun | CallbackManagerForLLMRun | None = None, +) -> Callable[[Any], Any]: + """Create a retry decorator for a given LLM and provided a list of error types. + + Args: + error_types: List of error types to retry on. + max_retries: Number of retries. + run_manager: Callback manager for the run. + + Returns: + A retry decorator. + + Raises: + ValueError: If the cache is not set and cache is True. + """ + logging_ = before_sleep_log(logger, logging.WARNING) + + def _before_sleep(retry_state: RetryCallState) -> None: + logging_(retry_state) + if run_manager: + if isinstance(run_manager, AsyncCallbackManagerForLLMRun): + coro = run_manager.on_retry(retry_state) + try: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + asyncio.run(coro) + else: + if loop.is_running(): + task = loop.create_task(coro) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + else: + asyncio.run(coro) + except Exception as e: + _log_error_once(f"Error in on_retry: {e}") + else: + run_manager.on_retry(retry_state) + + min_seconds = 4 + max_seconds = 10 + # Wait 2^x * 1 second between each retry starting with + # 4 seconds, then up to 10 seconds, then 10 seconds afterwards + retry_instance: retry_base = retry_if_exception_type(error_types[0]) + for error in error_types[1:]: + retry_instance |= retry_if_exception_type(error) + return retry( + reraise=True, + stop=stop_after_attempt(max_retries), + wait=wait_exponential(multiplier=1, min=min_seconds, max=max_seconds), + retry=retry_instance, + before_sleep=_before_sleep, + ) + + +def _resolve_cache(*, cache: BaseCache | bool | None) -> BaseCache | None: + """Resolve the cache.""" + llm_cache: BaseCache | None + if isinstance(cache, BaseCache): + llm_cache = cache + elif cache is None: + llm_cache = get_llm_cache() + elif cache is True: + llm_cache = get_llm_cache() + if llm_cache is None: + msg = ( + "No global cache was configured. Use `set_llm_cache`." + "to set a global cache if you want to use a global cache." + "Otherwise either pass a cache object or set cache to False/None" + ) + raise ValueError(msg) + elif cache is False: + llm_cache = None + else: + msg = f"Unsupported cache value {cache}" # type: ignore[unreachable] + raise ValueError(msg) + return llm_cache + + +def get_prompts( + params: dict[str, Any], + prompts: list[str], + cache: BaseCache | bool | None = None, # noqa: FBT001 +) -> tuple[dict[int, list[Generation]], str, list[int], list[str]]: + """Get prompts that are already cached. + + Args: + params: Dictionary of parameters. + prompts: List of prompts. + cache: Cache object. + + Returns: + A tuple of existing prompts, llm_string, missing prompt indexes, + and missing prompts. + + Raises: + ValueError: If the cache is not set and cache is True. + """ + llm_string = str(sorted(params.items())) + missing_prompts = [] + missing_prompt_idxs = [] + existing_prompts = {} + + llm_cache = _resolve_cache(cache=cache) + for i, prompt in enumerate(prompts): + if llm_cache: + cache_val = llm_cache.lookup(prompt, llm_string) + if isinstance(cache_val, list): + existing_prompts[i] = cache_val + else: + missing_prompts.append(prompt) + missing_prompt_idxs.append(i) + return existing_prompts, llm_string, missing_prompt_idxs, missing_prompts + + +async def aget_prompts( + params: dict[str, Any], + prompts: list[str], + cache: BaseCache | bool | None = None, # noqa: FBT001 +) -> tuple[dict[int, list[Generation]], str, list[int], list[str]]: + """Get prompts that are already cached. Async version. + + Args: + params: Dictionary of parameters. + prompts: List of prompts. + cache: Cache object. + + Returns: + A tuple of existing prompts, llm_string, missing prompt indexes, + and missing prompts. + + Raises: + ValueError: If the cache is not set and cache is True. + """ + llm_string = str(sorted(params.items())) + missing_prompts = [] + missing_prompt_idxs = [] + existing_prompts = {} + llm_cache = _resolve_cache(cache=cache) + for i, prompt in enumerate(prompts): + if llm_cache: + cache_val = await llm_cache.alookup(prompt, llm_string) + if isinstance(cache_val, list): + existing_prompts[i] = cache_val + else: + missing_prompts.append(prompt) + missing_prompt_idxs.append(i) + return existing_prompts, llm_string, missing_prompt_idxs, missing_prompts + + +def update_cache( + cache: BaseCache | bool | None, # noqa: FBT001 + existing_prompts: dict[int, list[Generation]], + llm_string: str, + missing_prompt_idxs: list[int], + new_results: LLMResult, + prompts: list[str], +) -> dict[str, Any] | None: + """Update the cache and get the LLM output. + + Args: + cache: Cache object. + existing_prompts: Dictionary of existing prompts. + llm_string: LLM string. + missing_prompt_idxs: List of missing prompt indexes. + new_results: LLMResult object. + prompts: List of prompts. + + Returns: + LLM output. + + Raises: + ValueError: If the cache is not set and cache is True. + """ + llm_cache = _resolve_cache(cache=cache) + for i, result in enumerate(new_results.generations): + existing_prompts[missing_prompt_idxs[i]] = result + prompt = prompts[missing_prompt_idxs[i]] + if llm_cache is not None: + llm_cache.update(prompt, llm_string, result) + return new_results.llm_output + + +async def aupdate_cache( + cache: BaseCache | bool | None, # noqa: FBT001 + existing_prompts: dict[int, list[Generation]], + llm_string: str, + missing_prompt_idxs: list[int], + new_results: LLMResult, + prompts: list[str], +) -> dict[str, Any] | None: + """Update the cache and get the LLM output. Async version. + + Args: + cache: Cache object. + existing_prompts: Dictionary of existing prompts. + llm_string: LLM string. + missing_prompt_idxs: List of missing prompt indexes. + new_results: LLMResult object. + prompts: List of prompts. + + Returns: + LLM output. + + Raises: + ValueError: If the cache is not set and cache is True. + """ + llm_cache = _resolve_cache(cache=cache) + for i, result in enumerate(new_results.generations): + existing_prompts[missing_prompt_idxs[i]] = result + prompt = prompts[missing_prompt_idxs[i]] + if llm_cache: + await llm_cache.aupdate(prompt, llm_string, result) + return new_results.llm_output + + +class BaseLLM(BaseLanguageModel[str], ABC): + """Base LLM abstract interface. + + It should take in a prompt and return a string. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + @functools.cached_property + def _serialized(self) -> builtins.dict[str, Any]: + # self is always a Serializable object in this case, thus the result is + # guaranteed to be a dict since dumpd uses the default callback, which uses + # obj.to_json which always returns TypedDict subclasses + return cast("builtins.dict[str, Any]", dumpd(self)) + + # --- Runnable methods --- + + @property + @override + def OutputType(self) -> type[str]: + """Get the output type for this `Runnable`.""" + return str + + def _convert_input(self, model_input: LanguageModelInput) -> PromptValue: + if isinstance(model_input, PromptValue): + return model_input + if isinstance(model_input, str): + return StringPromptValue(text=model_input) + if isinstance(model_input, Sequence): + return ChatPromptValue(messages=convert_to_messages(model_input)) + msg = ( # type: ignore[unreachable] + f"Invalid input type {type(model_input)}. " + "Must be a PromptValue, str, or list of BaseMessages." + ) + raise ValueError(msg) + + def _get_ls_params( + self, + stop: list[str] | None = None, + **kwargs: Any, + ) -> LangSmithParams: + """Get standard params for tracing.""" + # get default provider from class name + default_provider = self.__class__.__name__ + default_provider = default_provider.removesuffix("LLM") + default_provider = default_provider.lower() + + ls_params = LangSmithParams(ls_provider=default_provider, ls_model_type="llm") + if stop: + ls_params["ls_stop"] = stop + + # model + if "model" in kwargs and isinstance(kwargs["model"], str): + ls_params["ls_model_name"] = kwargs["model"] + elif hasattr(self, "model") and isinstance(self.model, str): + ls_params["ls_model_name"] = self.model + elif hasattr(self, "model_name") and isinstance(self.model_name, str): + ls_params["ls_model_name"] = self.model_name + + # temperature + if "temperature" in kwargs and isinstance(kwargs["temperature"], (int, float)): + ls_params["ls_temperature"] = kwargs["temperature"] + elif hasattr(self, "temperature") and isinstance( + self.temperature, (int, float) + ): + ls_params["ls_temperature"] = self.temperature + + # max_tokens + if "max_tokens" in kwargs and isinstance(kwargs["max_tokens"], int): + ls_params["ls_max_tokens"] = kwargs["max_tokens"] + elif hasattr(self, "max_tokens") and isinstance(self.max_tokens, int): + ls_params["ls_max_tokens"] = self.max_tokens + + return ls_params + + @override + def invoke( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> str: + config = ensure_config(config) + return ( + self.generate_prompt( + [self._convert_input(input)], + stop=stop, + callbacks=config.get("callbacks"), + tags=config.get("tags"), + metadata=config.get("metadata"), + run_name=config.get("run_name"), + run_id=config.pop("run_id", None), + **kwargs, + ) + .generations[0][0] + .text + ) + + @override + async def ainvoke( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> str: + config = ensure_config(config) + llm_result = await self.agenerate_prompt( + [self._convert_input(input)], + stop=stop, + callbacks=config.get("callbacks"), + tags=config.get("tags"), + metadata=config.get("metadata"), + run_name=config.get("run_name"), + run_id=config.pop("run_id", None), + **kwargs, + ) + return llm_result.generations[0][0].text + + @override + def batch( + self, + inputs: list[LanguageModelInput], + config: RunnableConfig | list[RunnableConfig] | None = None, + *, + return_exceptions: bool = False, + **kwargs: Any, + ) -> list[str]: + if not inputs: + return [] + + config = get_config_list(config, len(inputs)) + max_concurrency = config[0].get("max_concurrency") + + if max_concurrency is None: + try: + llm_result = self.generate_prompt( + [self._convert_input(input_) for input_ in inputs], + callbacks=[c.get("callbacks") for c in config], + tags=[c.get("tags") for c in config], + metadata=[c.get("metadata") for c in config], + run_name=[c.get("run_name") for c in config], + **kwargs, + ) + return [g[0].text for g in llm_result.generations] + except Exception as e: + if return_exceptions: + return cast("list[str]", [e for _ in inputs]) + raise + else: + batches = [ + inputs[i : i + max_concurrency] + for i in range(0, len(inputs), max_concurrency) + ] + config = [{**c, "max_concurrency": None} for c in config] + return [ + output + for i, batch in enumerate(batches) + for output in self.batch( + batch, + config=config[i * max_concurrency : (i + 1) * max_concurrency], + return_exceptions=return_exceptions, + **kwargs, + ) + ] + + @override + async def abatch( + self, + inputs: list[LanguageModelInput], + config: RunnableConfig | list[RunnableConfig] | None = None, + *, + return_exceptions: bool = False, + **kwargs: Any, + ) -> list[str]: + if not inputs: + return [] + config = get_config_list(config, len(inputs)) + max_concurrency = config[0].get("max_concurrency") + + if max_concurrency is None: + try: + llm_result = await self.agenerate_prompt( + [self._convert_input(input_) for input_ in inputs], + callbacks=[c.get("callbacks") for c in config], + tags=[c.get("tags") for c in config], + metadata=[c.get("metadata") for c in config], + run_name=[c.get("run_name") for c in config], + **kwargs, + ) + return [g[0].text for g in llm_result.generations] + except Exception as e: + if return_exceptions: + return cast("list[str]", [e for _ in inputs]) + raise + else: + batches = [ + inputs[i : i + max_concurrency] + for i in range(0, len(inputs), max_concurrency) + ] + config = [{**c, "max_concurrency": None} for c in config] + return [ + output + for i, batch in enumerate(batches) + for output in await self.abatch( + batch, + config=config[i * max_concurrency : (i + 1) * max_concurrency], + return_exceptions=return_exceptions, + **kwargs, + ) + ] + + @override + def stream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> Iterator[str]: + if type(self)._stream == BaseLLM._stream: # noqa: SLF001 + # model doesn't implement streaming, so use default implementation + yield self.invoke(input, config=config, stop=stop, **kwargs) + else: + prompt = self._convert_input(input).to_string() + config = ensure_config(config) + params = self._dict_for_compat() + params["stop"] = stop + params = {**params, **kwargs} + options = {"stop": stop} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = CallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + (run_manager,) = callback_manager.on_llm_start( + self._serialized, + [prompt], + invocation_params=params, + options=options, + name=config.get("run_name"), + run_id=config.pop("run_id", None), + batch_size=1, + ) + generation: GenerationChunk | None = None + try: + for chunk in self._stream( + prompt, stop=stop, run_manager=run_manager, **kwargs + ): + yield chunk.text + if generation is None: + generation = chunk + else: + generation += chunk + except BaseException as e: + run_manager.on_llm_error( + e, + response=LLMResult( + generations=[[generation]] if generation else [] + ), + ) + raise + + if generation is None: + err = ValueError("No generation chunks were returned") + run_manager.on_llm_error(err, response=LLMResult(generations=[])) + raise err + + run_manager.on_llm_end(LLMResult(generations=[[generation]])) + + @override + async def astream( + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> AsyncIterator[str]: + if ( + type(self)._astream is BaseLLM._astream # noqa: SLF001 + and type(self)._stream is BaseLLM._stream # noqa: SLF001 + ): + yield await self.ainvoke(input, config=config, stop=stop, **kwargs) + return + + prompt = self._convert_input(input).to_string() + config = ensure_config(config) + params = self._dict_for_compat() + params["stop"] = stop + params = {**params, **kwargs} + options = {"stop": stop} + inheritable_metadata = { + **(config.get("metadata") or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + callback_manager = AsyncCallbackManager.configure( + config.get("callbacks"), + self.callbacks, + self.verbose, + config.get("tags"), + self.tags, + inheritable_metadata, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + (run_manager,) = await callback_manager.on_llm_start( + self._serialized, + [prompt], + invocation_params=params, + options=options, + name=config.get("run_name"), + run_id=config.pop("run_id", None), + batch_size=1, + ) + generation: GenerationChunk | None = None + try: + async for chunk in self._astream( + prompt, + stop=stop, + run_manager=run_manager, + **kwargs, + ): + yield chunk.text + if generation is None: + generation = chunk + else: + generation += chunk + except BaseException as e: + await run_manager.on_llm_error( + e, + response=LLMResult(generations=[[generation]] if generation else []), + ) + raise + + if generation is None: + err = ValueError("No generation chunks were returned") + await run_manager.on_llm_error(err, response=LLMResult(generations=[])) + raise err + + await run_manager.on_llm_end(LLMResult(generations=[[generation]])) + + # --- Custom methods --- + + @abstractmethod + def _generate( + self, + prompts: list[str], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> LLMResult: + """Run the LLM on the given prompts. + + Args: + prompts: The prompts to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + + If stop tokens are not supported consider raising `NotImplementedError`. + run_manager: Callback manager for the run. + + Returns: + The LLM result. + """ + + async def _agenerate( + self, + prompts: list[str], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> LLMResult: + """Run the LLM on the given prompts. + + Args: + prompts: The prompts to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + + If stop tokens are not supported consider raising `NotImplementedError`. + run_manager: Callback manager for the run. + + Returns: + The LLM result. + """ + return await run_in_executor( + None, + self._generate, + prompts, + stop, + run_manager.get_sync() if run_manager else None, + **kwargs, + ) + + def _stream( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[GenerationChunk]: + """Stream the LLM on the given prompt. + + This method should be overridden by subclasses that support streaming. + + If not implemented, the default behavior of calls to stream will be to + fallback to the non-streaming version of the model and return + the output as a single chunk. + + Args: + prompt: The prompt to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + run_manager: Callback manager for the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Yields: + Generation chunks. + """ + raise NotImplementedError + + async def _astream( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[GenerationChunk]: + """An async version of the _stream method. + + The default implementation uses the synchronous _stream method and wraps it in + an async iterator. Subclasses that need to provide a true async implementation + should override this method. + + Args: + prompt: The prompt to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + run_manager: Callback manager for the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Yields: + Generation chunks. + """ + iterator = await run_in_executor( + None, + self._stream, + prompt, + stop, + run_manager.get_sync() if run_manager else None, + **kwargs, + ) + done = object() + while True: + item = await run_in_executor( + None, + next, + iterator, + done, + ) + if item is done: + break + yield item # type: ignore[misc] + + @override + def generate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks | list[Callbacks] | None = None, + **kwargs: Any, + ) -> LLMResult: + prompt_strings = [p.to_string() for p in prompts] + return self.generate(prompt_strings, stop=stop, callbacks=callbacks, **kwargs) + + @override + async def agenerate_prompt( + self, + prompts: list[PromptValue], + stop: list[str] | None = None, + callbacks: Callbacks | list[Callbacks] | None = None, + **kwargs: Any, + ) -> LLMResult: + prompt_strings = [p.to_string() for p in prompts] + return await self.agenerate( + prompt_strings, stop=stop, callbacks=callbacks, **kwargs + ) + + def _generate_helper( + self, + prompts: list[str], + stop: list[str] | None, + run_managers: list[CallbackManagerForLLMRun], + *, + new_arg_supported: bool, + **kwargs: Any, + ) -> LLMResult: + try: + output = ( + self._generate( + prompts, + stop=stop, + # TODO: support multiple run managers + run_manager=run_managers[0] if run_managers else None, + **kwargs, + ) + if new_arg_supported + else self._generate(prompts, stop=stop) + ) + except BaseException as e: + for run_manager in run_managers: + run_manager.on_llm_error(e, response=LLMResult(generations=[])) + raise + flattened_outputs = output.flatten() + for manager, flattened_output in zip( + run_managers, flattened_outputs, strict=False + ): + manager.on_llm_end(flattened_output) + if run_managers: + output.run = [ + RunInfo(run_id=run_manager.run_id) for run_manager in run_managers + ] + return output + + def generate( + self, + prompts: list[str], + stop: list[str] | None = None, + callbacks: Callbacks | list[Callbacks] | None = None, + *, + tags: list[str] | list[list[str]] | None = None, + metadata: builtins.dict[str, Any] | list[builtins.dict[str, Any]] | None = None, + run_name: str | list[str] | None = None, + run_id: uuid.UUID | list[uuid.UUID | None] | None = None, + **kwargs: Any, + ) -> LLMResult: + """Pass a sequence of prompts to a model and return generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + prompts: List of string prompts. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + tags: List of tags to associate with each prompt. If provided, the length + of the list must match the length of the prompts list. + metadata: List of metadata dictionaries to associate with each prompt. If + provided, the length of the list must match the length of the prompts + list. + run_name: List of run names to associate with each prompt. If provided, the + length of the list must match the length of the prompts list. + run_id: List of run IDs to associate with each prompt. If provided, the + length of the list must match the length of the prompts list. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Raises: + ValueError: If prompts is not a list. + ValueError: If the length of `callbacks`, `tags`, `metadata`, or + `run_name` (if provided) does not match the length of prompts. + + Returns: + An `LLMResult`, which contains a list of candidate `Generations` for each + input prompt and additional model provider-specific output. + """ + if not isinstance(prompts, list): + msg = ( # type: ignore[unreachable] + "Argument 'prompts' is expected to be of type list[str], received" + f" argument of type {type(prompts)}." + ) + raise ValueError(msg) # noqa: TRY004 + # Create callback managers + if isinstance(metadata, list): + metadata = [ + { + **(meta or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + for meta in metadata + ] + elif isinstance(metadata, dict): + metadata = { + **(metadata or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + if ( + isinstance(callbacks, list) + and callbacks + and ( + isinstance(callbacks[0], (list, BaseCallbackManager)) + or callbacks[0] is None + ) + ): + # We've received a list of callbacks args to apply to each input + if len(callbacks) != len(prompts): + msg = "callbacks must be the same length as prompts" + raise ValueError(msg) + if tags is not None and not ( + isinstance(tags, list) and len(tags) == len(prompts) + ): + msg = "tags must be a list of the same length as prompts" + raise ValueError(msg) + if metadata is not None and not ( + isinstance(metadata, list) and len(metadata) == len(prompts) + ): + msg = "metadata must be a list of the same length as prompts" + raise ValueError(msg) + if run_name is not None and not ( + isinstance(run_name, list) and len(run_name) == len(prompts) + ): + msg = "run_name must be a list of the same length as prompts" + raise ValueError(msg) + tags_list = cast("list[list[str] | None]", tags or ([None] * len(prompts))) + metadata_list = cast( + "list[builtins.dict[str, Any] | None]", + metadata or ([{}] * len(prompts)), + ) + run_name_list = run_name or cast( + "list[str | None]", ([None] * len(prompts)) + ) + params = self._dict_for_compat() + params["stop"] = stop + callback_managers = [ + CallbackManager.configure( + callback, + self.callbacks, + self.verbose, + tag, + self.tags, + meta, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + for callback, tag, meta in zip( + callbacks, tags_list, metadata_list, strict=False + ) + ] + else: + # We've received a single callbacks arg to apply to all inputs + params = self._dict_for_compat() + params["stop"] = stop + callback_managers = [ + CallbackManager.configure( + cast("Callbacks", callbacks), + self.callbacks, + self.verbose, + cast("list[str]", tags), + self.tags, + cast("builtins.dict[str, Any]", metadata), + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + ] * len(prompts) + run_name_list = [cast("str | None", run_name)] * len(prompts) + run_ids_list = self._get_run_ids_list(run_id, prompts) + options = {"stop": stop} + ( + existing_prompts, + llm_string, + missing_prompt_idxs, + missing_prompts, + ) = get_prompts(params, prompts, self.cache) + new_arg_supported = inspect.signature(self._generate).parameters.get( + "run_manager" + ) + if (self.cache is None and get_llm_cache() is None) or self.cache is False: + run_managers = [ + callback_manager.on_llm_start( + self._serialized, + [prompt], + invocation_params=params, + options=options, + name=run_name, + batch_size=len(prompts), + run_id=run_id_, + )[0] + for callback_manager, prompt, run_name, run_id_ in zip( + callback_managers, + prompts, + run_name_list, + run_ids_list, + strict=False, + ) + ] + return self._generate_helper( + prompts, + stop, + run_managers, + new_arg_supported=bool(new_arg_supported), + **kwargs, + ) + if len(missing_prompts) > 0: + run_managers = [ + callback_managers[idx].on_llm_start( + self._serialized, + [prompts[idx]], + invocation_params=params, + options=options, + name=run_name_list[idx], + batch_size=len(missing_prompts), + )[0] + for idx in missing_prompt_idxs + ] + new_results = self._generate_helper( + missing_prompts, + stop, + run_managers, + new_arg_supported=bool(new_arg_supported), + **kwargs, + ) + llm_output = update_cache( + self.cache, + existing_prompts, + llm_string, + missing_prompt_idxs, + new_results, + prompts, + ) + run_info = ( + [RunInfo(run_id=run_manager.run_id) for run_manager in run_managers] + if run_managers + else None + ) + else: + llm_output = {} + run_info = None + generations = [existing_prompts[i] for i in range(len(prompts))] + return LLMResult(generations=generations, llm_output=llm_output, run=run_info) + + @staticmethod + def _get_run_ids_list( + run_id: uuid.UUID | list[uuid.UUID | None] | None, prompts: list[str] + ) -> list[uuid.UUID | None]: + if run_id is None: + return [None] * len(prompts) + if isinstance(run_id, list): + if len(run_id) != len(prompts): + msg = ( + "Number of manually provided run_id's does not match batch length." + f" {len(run_id)} != {len(prompts)}" + ) + raise ValueError(msg) + return run_id + return [run_id] + [None] * (len(prompts) - 1) + + async def _agenerate_helper( + self, + prompts: list[str], + stop: list[str] | None, + run_managers: list[AsyncCallbackManagerForLLMRun], + *, + new_arg_supported: bool, + **kwargs: Any, + ) -> LLMResult: + try: + output = ( + await self._agenerate( + prompts, + stop=stop, + run_manager=run_managers[0] if run_managers else None, + **kwargs, + ) + if new_arg_supported + else await self._agenerate(prompts, stop=stop) + ) + except BaseException as e: + await asyncio.gather( + *[ + run_manager.on_llm_error(e, response=LLMResult(generations=[])) + for run_manager in run_managers + ] + ) + raise + flattened_outputs = output.flatten() + await asyncio.gather( + *[ + run_manager.on_llm_end(flattened_output) + for run_manager, flattened_output in zip( + run_managers, flattened_outputs, strict=False + ) + ] + ) + if run_managers: + output.run = [ + RunInfo(run_id=run_manager.run_id) for run_manager in run_managers + ] + return output + + async def agenerate( + self, + prompts: list[str], + stop: list[str] | None = None, + callbacks: Callbacks | list[Callbacks] | None = None, + *, + tags: list[str] | list[list[str]] | None = None, + metadata: builtins.dict[str, Any] | list[builtins.dict[str, Any]] | None = None, + run_name: str | list[str] | None = None, + run_id: uuid.UUID | list[uuid.UUID | None] | None = None, + **kwargs: Any, + ) -> LLMResult: + """Asynchronously pass a sequence of prompts to a model and return generations. + + This method should make use of batched calls for models that expose a batched + API. + + Use this method when you want to: + + 1. Take advantage of batched calls, + 2. Need more output from the model than just the top generated value, + 3. Are building chains that are agnostic to the underlying language model + type (e.g., pure text completion models vs chat models). + + Args: + prompts: List of string prompts. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + callbacks: `Callbacks` to pass through. + + Used for executing additional functionality, such as logging or + streaming, throughout generation. + tags: List of tags to associate with each prompt. If provided, the length + of the list must match the length of the prompts list. + metadata: List of metadata dictionaries to associate with each prompt. If + provided, the length of the list must match the length of the prompts + list. + run_name: List of run names to associate with each prompt. If provided, the + length of the list must match the length of the prompts list. + run_id: List of run IDs to associate with each prompt. If provided, the + length of the list must match the length of the prompts list. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Raises: + ValueError: If the length of `callbacks`, `tags`, `metadata`, or + `run_name` (if provided) does not match the length of prompts. + + Returns: + An `LLMResult`, which contains a list of candidate `Generations` for each + input prompt and additional model provider-specific output. + """ + if isinstance(metadata, list): + metadata = [ + { + **(meta or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + for meta in metadata + ] + elif isinstance(metadata, dict): + metadata = { + **(metadata or {}), + **self._get_ls_params_with_defaults(stop=stop, **kwargs), + } + # Create callback managers + if isinstance(callbacks, list) and ( + isinstance(callbacks[0], (list, BaseCallbackManager)) + or callbacks[0] is None + ): + # We've received a list of callbacks args to apply to each input + if len(callbacks) != len(prompts): + msg = "callbacks must be the same length as prompts" + raise ValueError(msg) + if tags is not None and not ( + isinstance(tags, list) and len(tags) == len(prompts) + ): + msg = "tags must be a list of the same length as prompts" + raise ValueError(msg) + if metadata is not None and not ( + isinstance(metadata, list) and len(metadata) == len(prompts) + ): + msg = "metadata must be a list of the same length as prompts" + raise ValueError(msg) + if run_name is not None and not ( + isinstance(run_name, list) and len(run_name) == len(prompts) + ): + msg = "run_name must be a list of the same length as prompts" + raise ValueError(msg) + tags_list = cast("list[list[str] | None]", tags or ([None] * len(prompts))) + metadata_list = cast( + "list[builtins.dict[str, Any] | None]", + metadata or ([{}] * len(prompts)), + ) + run_name_list = run_name or cast( + "list[str | None]", ([None] * len(prompts)) + ) + params = self._dict_for_compat() + params["stop"] = stop + callback_managers = [ + AsyncCallbackManager.configure( + callback, + self.callbacks, + self.verbose, + tag, + self.tags, + meta, + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + for callback, tag, meta in zip( + callbacks, tags_list, metadata_list, strict=False + ) + ] + else: + # We've received a single callbacks arg to apply to all inputs + params = self._dict_for_compat() + params["stop"] = stop + callback_managers = [ + AsyncCallbackManager.configure( + callbacks, + self.callbacks, + self.verbose, + cast("list[str]", tags), + self.tags, + cast("builtins.dict[str, Any]", metadata), + self.metadata, + langsmith_inheritable_metadata=_filter_invocation_params_for_tracing( + params + ), + ) + ] * len(prompts) + run_name_list = [cast("str | None", run_name)] * len(prompts) + run_ids_list = self._get_run_ids_list(run_id, prompts) + options = {"stop": stop} + ( + existing_prompts, + llm_string, + missing_prompt_idxs, + missing_prompts, + ) = await aget_prompts(params, prompts, self.cache) + + # Verify whether the cache is set, and if the cache is set, + # verify whether the cache is available. + new_arg_supported = inspect.signature(self._agenerate).parameters.get( + "run_manager" + ) + if (self.cache is None and get_llm_cache() is None) or self.cache is False: + run_managers = await asyncio.gather( + *[ + callback_manager.on_llm_start( + self._serialized, + [prompt], + invocation_params=params, + options=options, + name=run_name, + batch_size=len(prompts), + run_id=run_id_, + ) + for callback_manager, prompt, run_name, run_id_ in zip( + callback_managers, + prompts, + run_name_list, + run_ids_list, + strict=False, + ) + ] + ) + run_managers = [r[0] for r in run_managers] # type: ignore[misc] + return await self._agenerate_helper( + prompts, + stop, + run_managers, # type: ignore[arg-type] + new_arg_supported=bool(new_arg_supported), + **kwargs, + ) + if len(missing_prompts) > 0: + run_managers = await asyncio.gather( + *[ + callback_managers[idx].on_llm_start( + self._serialized, + [prompts[idx]], + invocation_params=params, + options=options, + name=run_name_list[idx], + batch_size=len(missing_prompts), + ) + for idx in missing_prompt_idxs + ] + ) + run_managers = [r[0] for r in run_managers] # type: ignore[misc] + new_results = await self._agenerate_helper( + missing_prompts, + stop, + run_managers, # type: ignore[arg-type] + new_arg_supported=bool(new_arg_supported), + **kwargs, + ) + llm_output = await aupdate_cache( + self.cache, + existing_prompts, + llm_string, + missing_prompt_idxs, + new_results, + prompts, + ) + run_info = ( + [RunInfo(run_id=run_manager.run_id) for run_manager in run_managers] # type: ignore[attr-defined] + if run_managers + else None + ) + else: + llm_output = {} + run_info = None + generations = [existing_prompts[i] for i in range(len(prompts))] + return LLMResult(generations=generations, llm_output=llm_output, run=run_info) + + async def _call_async( + self, + prompt: str, + stop: list[str] | None = None, + callbacks: Callbacks = None, + *, + tags: list[str] | None = None, + metadata: builtins.dict[str, Any] | None = None, + **kwargs: Any, + ) -> str: + """Check Cache and run the LLM on the given prompt and input.""" + result = await self.agenerate( + [prompt], + stop=stop, + callbacks=callbacks, + tags=tags, + metadata=metadata, + **kwargs, + ) + return result.generations[0][0].text + + def __str__(self) -> str: + """Return a string representation of the object for printing.""" + cls_name = f"\033[1m{self.__class__.__name__}\033[0m" + return f"{cls_name}\nParams: {self._identifying_params}" + + @property + @abstractmethod + def _llm_type(self) -> str: + """Return type of llm.""" + + @deprecated("1.4.2", alternative="asdict", removal="2.0.0") + @override + def dict(self, **_kwargs: Any) -> builtins.dict[str, Any]: + """DEPRECATED - use `asdict()` instead. + + Return a dictionary representation of the LLM. + """ + return self.asdict() + + def asdict(self) -> builtins.dict[str, Any]: + """Return a dictionary representation of the LLM.""" + starter_dict = dict(self._identifying_params) + starter_dict["_type"] = self._llm_type + return starter_dict + + def _dict_for_compat(self) -> builtins.dict[str, Any]: + """Return the LLM dictionary while preserving deprecated overrides.""" + with suppress_langchain_deprecation_warning(): + return self.dict() + + def save(self, file_path: Path | str) -> None: + """Save the LLM. + + Args: + file_path: Path to file to save the LLM to. + + Raises: + ValueError: If the file path is not a string or Path object. + + Example: + ```python + llm.save(file_path="path/llm.yaml") + ``` + """ + # Convert file to Path object. + save_path = Path(file_path) + + directory_path = save_path.parent + directory_path.mkdir(parents=True, exist_ok=True) + + # Fetch dictionary to save + prompt_dict = self._dict_for_compat() + + if save_path.suffix == ".json": + with save_path.open("w", encoding="utf-8") as f: + json.dump(prompt_dict, f, indent=4) + elif save_path.suffix.endswith((".yaml", ".yml")): + with save_path.open("w", encoding="utf-8") as f: + yaml.dump(prompt_dict, f, default_flow_style=False) + else: + msg = f"{save_path} must be json or yaml" + raise ValueError(msg) + + +class LLM(BaseLLM): + """Simple interface for implementing a custom LLM. + + You should subclass this class and implement the following: + + - `_call` method: Run the LLM on the given prompt and input (used by `invoke`). + - `_identifying_params` property: Return a dictionary of the identifying parameters + This is critical for caching and tracing purposes. Identifying parameters + is a dict that identifies the LLM. + It should mostly include a `model_name`. + + Optional: Override the following methods to provide more optimizations: + + - `_acall`: Provide a native async version of the `_call` method. + If not provided, will delegate to the synchronous version using + `run_in_executor`. (Used by `ainvoke`). + - `_stream`: Stream the LLM on the given prompt and input. + `stream` will use `_stream` if provided, otherwise it + use `_call` and output will arrive in one chunk. + - `_astream`: Override to provide a native async version of the `_stream` method. + `astream` will use `_astream` if provided, otherwise it will implement + a fallback behavior that will use `_stream` if `_stream` is implemented, + and use `_acall` if `_stream` is not implemented. + """ + + @abstractmethod + def _call( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + """Run the LLM on the given input. + + Override this method to implement the LLM logic. + + Args: + prompt: The prompt to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + + If stop tokens are not supported consider raising `NotImplementedError`. + run_manager: Callback manager for the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + The model output as a string. SHOULD NOT include the prompt. + """ + + async def _acall( + self, + prompt: str, + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> str: + """Async version of the _call method. + + The default implementation delegates to the synchronous _call method using + `run_in_executor`. Subclasses that need to provide a true async implementation + should override this method to reduce the overhead of using `run_in_executor`. + + Args: + prompt: The prompt to generate from. + stop: Stop words to use when generating. + + Model output is cut off at the first occurrence of any of these + substrings. + + If stop tokens are not supported consider raising `NotImplementedError`. + run_manager: Callback manager for the run. + **kwargs: Arbitrary additional keyword arguments. + + These are usually passed to the model provider API call. + + Returns: + The model output as a string. SHOULD NOT include the prompt. + """ + return await run_in_executor( + None, + self._call, + prompt, + stop, + run_manager.get_sync() if run_manager else None, + **kwargs, + ) + + def _generate( + self, + prompts: list[str], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> LLMResult: + # TODO: add caching here. + generations = [] + new_arg_supported = inspect.signature(self._call).parameters.get("run_manager") + for prompt in prompts: + text = ( + self._call(prompt, stop=stop, run_manager=run_manager, **kwargs) + if new_arg_supported + else self._call(prompt, stop=stop, **kwargs) + ) + generations.append([Generation(text=text)]) + return LLMResult(generations=generations) + + async def _agenerate( + self, + prompts: list[str], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> LLMResult: + generations = [] + new_arg_supported = inspect.signature(self._acall).parameters.get("run_manager") + for prompt in prompts: + text = ( + await self._acall(prompt, stop=stop, run_manager=run_manager, **kwargs) + if new_arg_supported + else await self._acall(prompt, stop=stop, **kwargs) + ) + generations.append([Generation(text=text)]) + return LLMResult(generations=generations) diff --git a/libs/core/langchain_core/language_models/model_profile.py b/libs/core/langchain_core/language_models/model_profile.py new file mode 100644 index 0000000..e5eedef --- /dev/null +++ b/libs/core/langchain_core/language_models/model_profile.py @@ -0,0 +1,167 @@ +"""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): + """Description of a chat model's capabilities, exposed via `model.profile`. + + See the + [model profiles guide](https://docs.langchain.com/oss/python/langchain/models#model-profiles) + for concepts and usage. Data is sourced from + [models.dev](https://github.com/sst/models.dev), augmented with additional + fields, and generated by the + [`langchain-model-profiles`](https://github.com/langchain-ai/langchain/tree/master/libs/model-profiles) + package (via its `langchain-profiles` CLI). + + !!! warning "Beta feature" + + Fields and format are subject to change. This is a `total=False` + `TypedDict`, so any field may be absent — guard accesses with `.get()`. + """ + + __pydantic_config__ = ConfigDict(extra="allow") # type: ignore[misc] + + # --- Model metadata --- + + name: str + """Human-readable model name (e.g., `'GPT-5'`).""" + + status: str + """Model lifecycle 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 + """Maximum context window (tokens).""" + + text_inputs: bool + """Whether text inputs are supported.""" + + image_inputs: bool + """Whether image inputs are supported.""" + # TODO: add more detail about formats? + + image_url_inputs: bool + """Whether [image URL inputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + + pdf_inputs: bool + """Whether [PDF inputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + # TODO: add more detail about formats? e.g. bytes or base64 + + audio_inputs: bool + """Whether [audio inputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + # TODO: add more detail about formats? e.g. bytes or base64 + + video_inputs: bool + """Whether [video inputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + # TODO: add more detail about formats? e.g. bytes or base64 + + image_tool_message: bool + """Whether images can be included in `ToolMessage` content.""" + + pdf_tool_message: bool + """Whether PDFs can be included in `ToolMessage` content.""" + + # --- Output constraints --- + + max_output_tokens: int + """Maximum output tokens.""" + + reasoning_output: bool + """Whether the model supports [reasoning / chain-of-thought](https://docs.langchain.com/oss/python/langchain/models#reasoning).""" + + text_outputs: bool + """Whether text outputs are supported.""" + + image_outputs: bool + """Whether [image outputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + + audio_outputs: bool + """Whether [audio outputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + + video_outputs: bool + """Whether [video outputs](https://docs.langchain.com/oss/python/langchain/models#multimodal) + are supported.""" + + # --- Tool calling --- + tool_calling: bool + """Whether the model supports [tool calling](https://docs.langchain.com/oss/python/langchain/models#tool-calling).""" + + tool_choice: bool + """Whether the model supports [tool choice](https://docs.langchain.com/oss/python/langchain/models#forcing-tool-calls).""" + + tool_call_streaming: bool + """Whether the model returns properly structured `tool_call_chunks` when streaming. + + Only meaningful when `tool_calling` is `True`. + """ + + # --- Structured output --- + structured_output: bool + """Whether the model supports native [structured output](https://docs.langchain.com/oss/python/langchain/models#structured-outputs).""" + + # --- 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 # type: ignore[unreachable] + + 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/langchain_core/load/__init__.py b/libs/core/langchain_core/load/__init__.py new file mode 100644 index 0000000..e656fed --- /dev/null +++ b/libs/core/langchain_core/load/__init__.py @@ -0,0 +1,44 @@ +"""**Load** module helps with serialization and deserialization.""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr + +if TYPE_CHECKING: + from langchain_core.load.dump import dumpd, dumps + from langchain_core.load.load import InitValidator, loads + from langchain_core.load.serializable import Serializable + +# Unfortunately, we have to eagerly import load from langchain_core/load/load.py +# eagerly to avoid a namespace conflict. We want users to still be able to use +# `from langchain_core.load import load` to get the load function, but +# the `from langchain_core.load.load import load` absolute import should also work. +from langchain_core.load.load import load + +__all__ = ( + "InitValidator", + "Serializable", + "dumpd", + "dumps", + "load", + "loads", +) + +_dynamic_imports = { + "dumpd": "dump", + "dumps": "dump", + "InitValidator": "load", + "loads": "load", + "Serializable": "serializable", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/load/_validation.py b/libs/core/langchain_core/load/_validation.py new file mode 100644 index 0000000..8bf9f76 --- /dev/null +++ b/libs/core/langchain_core/load/_validation.py @@ -0,0 +1,191 @@ +"""Validation utilities for LangChain serialization. + +Provides escape-based protection against injection attacks in serialized objects. The +approach uses an allowlist design: only dicts explicitly produced by +`Serializable.to_json()` are treated as LC objects during deserialization. + +## How escaping works + +During serialization, plain dicts (user data) that contain an `'lc'` key are wrapped: + +```python +{"lc": 1, ...} # user data that looks like LC object +# becomes: +{"__lc_escaped__": {"lc": 1, ...}} +``` + +During deserialization, escaped dicts are unwrapped and returned as plain dicts, +NOT instantiated as LC objects. +""" + +from typing import Any, cast + +from langchain_core.load.serializable import ( + Serializable, + to_json_not_implemented, +) + +_LC_ESCAPED_KEY = "__lc_escaped__" +"""Sentinel key used to mark escaped user dicts during serialization. + +When a plain dict contains 'lc' key (which could be confused with LC objects), +we wrap it as {"__lc_escaped__": {...original...}}. +""" + + +def _needs_escaping(obj: dict[str, Any]) -> bool: + """Check if a dict needs escaping to prevent confusion with LC objects. + + A dict needs escaping if: + + 1. It has an `'lc'` key (could be confused with LC serialization format) + 2. It has only the escape key (would be mistaken for an escaped dict) + """ + return "lc" in obj or (len(obj) == 1 and _LC_ESCAPED_KEY in obj) + + +def _escape_dict(obj: dict[str, Any]) -> dict[str, Any]: + """Wrap a dict in the escape marker. + + Example: + ```python + {"key": "value"} # becomes {"__lc_escaped__": {"key": "value"}} + ``` + """ + return {_LC_ESCAPED_KEY: obj} + + +def _is_escaped_dict(obj: dict[str, Any]) -> bool: + """Check if a dict is an escaped user dict. + + Example: + ```python + {"__lc_escaped__": {...}} # is an escaped dict + ``` + """ + return len(obj) == 1 and _LC_ESCAPED_KEY in obj + + +def _serialize_value(obj: Any) -> Any: + """Serialize a value with escaping of user dicts. + + Called recursively on kwarg values to escape any plain dicts that could be confused + with LC objects. + + Args: + obj: The value to serialize. + + Returns: + The serialized value with user dicts escaped as needed. + """ + if isinstance(obj, Serializable): + # This is an LC object - serialize it properly (not escaped) + return _serialize_lc_object(obj) + if isinstance(obj, dict): + if not all(isinstance(k, (str, int, float, bool, type(None))) for k in obj): + # if keys are not json serializable + return to_json_not_implemented(obj) + # Check if dict needs escaping BEFORE recursing into values. + # If it needs escaping, wrap it as-is - the contents are user data that + # will be returned as-is during deserialization (no instantiation). + # This prevents re-escaping of already-escaped nested content. + if _needs_escaping(obj): + return _escape_dict(obj) + # Safe dict (no 'lc' key) - recurse into values + return {k: _serialize_value(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_serialize_value(item) for item in obj] + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + + # Non-JSON-serializable object (datetime, custom objects, etc.) + return to_json_not_implemented(obj) + + +def _get_secret_keys(obj: Serializable) -> set[str]: + """Return the merged set of constructor kwarg names declared as secrets. + + Mirrors the MRO walk in `Serializable.to_json` so the keys returned here + match the keys whose values `_replace_secrets` rewrites into secret + markers. Used by `_serialize_lc_object` to decide which kwargs to skip + when escaping user data. + """ + secrets: dict[str, str] = {} + model_fields = type(obj).model_fields + for cls in [None, *obj.__class__.mro()]: + if cls is Serializable: + break + this = cast("Serializable", obj if cls is None else super(cls, obj)) + secrets.update(this.lc_secrets) + for key in list(secrets): + if (key in model_fields) and (alias := model_fields[key].alias) is not None: + secrets[alias] = secrets[key] + return set(secrets) + + +def _serialize_lc_object(obj: Any) -> dict[str, Any]: + """Serialize a `Serializable` object with escaping of user data in kwargs. + + Args: + obj: The `Serializable` object to serialize. + + Returns: + The serialized dict with user data in kwargs escaped as needed. + + Note: + Kwargs values are processed with `_serialize_value` to escape user data + (like metadata) that contains `'lc'` keys. Secret fields are identified + by the class's declared `lc_secrets` and skipped because `to_json()` + already converted their values to secret markers. + + The check is key-based rather than shape-based. A shape-based check + ("this dict looks like a secret marker") can be forged by user data, + letting attacker-controlled free-form dicts bypass escaping and reach + the Reviver. + """ + if not isinstance(obj, Serializable): + msg = f"Expected Serializable, got {type(obj)}" + raise TypeError(msg) + + serialized: dict[str, Any] = dict(obj.to_json()) + + # Process kwargs to escape user data that could be confused with LC objects. + # Skip kwargs declared as secrets - `to_json()` already replaced their + # values with secret markers via `_replace_secrets`. + if serialized.get("type") == "constructor" and "kwargs" in serialized: + secret_keys = _get_secret_keys(obj) + serialized["kwargs"] = { + k: v if k in secret_keys else _serialize_value(v) + for k, v in serialized["kwargs"].items() + } + + return serialized + + +def _unescape_value(obj: Any) -> Any: + """Unescape a value, processing escape markers in dict values and lists. + + When an escaped dict is encountered (`{"__lc_escaped__": ...}`), it's + unwrapped and the contents are returned AS-IS (no further processing). + The contents represent user data that should not be modified. + + For regular dicts and lists, we recurse to find any nested escape markers. + + Args: + obj: The value to unescape. + + Returns: + The unescaped value. + """ + if isinstance(obj, dict): + if _is_escaped_dict(obj): + # Unwrap and return the user data as-is (no further unescaping). + # The contents are user data that may contain more escape keys, + # but those are part of the user's actual data. + return obj[_LC_ESCAPED_KEY] + + # Regular dict - recurse into values to find nested escape markers + return {k: _unescape_value(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_unescape_value(item) for item in obj] + return obj diff --git a/libs/core/langchain_core/load/dump.py b/libs/core/langchain_core/load/dump.py new file mode 100644 index 0000000..07bc309 --- /dev/null +++ b/libs/core/langchain_core/load/dump.py @@ -0,0 +1,120 @@ +"""Serialize LangChain objects to JSON. + +Provides `dumps` (to JSON string) and `dumpd` (to dict) for serializing +`Serializable` objects. + +## Escaping + +During serialization, plain dicts (user data) that contain an `'lc'` key are escaped +by wrapping them: `{"__lc_escaped__": {...original...}}`. This prevents injection +attacks where malicious data could trick the deserializer into instantiating +arbitrary classes. The escape marker is removed during deserialization. + +This is an allowlist approach: only dicts explicitly produced by +`Serializable.to_json()` are treated as LC objects; everything else is escaped if it +could be confused with the LC format. +""" + +import json +from typing import Any + +from pydantic import BaseModel + +from langchain_core.load._validation import _serialize_value +from langchain_core.load.serializable import Serializable, to_json_not_implemented +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration + + +def default(obj: Any) -> Any: + """Return a default value for an object. + + Args: + obj: The object to serialize to json if it is a Serializable object. + + Returns: + A JSON serializable object or a SerializedNotImplemented object. + """ + if isinstance(obj, Serializable): + return obj.to_json() + return to_json_not_implemented(obj) + + +def _dump_pydantic_models(obj: Any) -> Any: + """Convert nested Pydantic models to dicts for JSON serialization. + + Handles the special case where a `ChatGeneration` contains an `AIMessage` + with a parsed Pydantic model in `additional_kwargs["parsed"]`. Since + Pydantic models aren't directly JSON serializable, this converts them to + dicts. + + Args: + obj: The object to process. + + Returns: + A copy of the object with nested Pydantic models converted to dicts, or + the original object unchanged if no conversion was needed. + """ + if ( + isinstance(obj, ChatGeneration) + and isinstance(obj.message, AIMessage) + and (parsed := obj.message.additional_kwargs.get("parsed")) + and isinstance(parsed, BaseModel) + ): + obj_copy = obj.model_copy(deep=True) + obj_copy.message.additional_kwargs["parsed"] = parsed.model_dump() + return obj_copy + return obj + + +def dumps(obj: Any, *, pretty: bool = False, **kwargs: Any) -> str: + """Return a JSON string representation of an object. + + Note: + Plain dicts containing an `'lc'` key are automatically escaped to prevent + confusion with LC serialization format. The escape marker is removed during + deserialization. + + Args: + obj: The object to dump. + pretty: Whether to pretty print the json. + + If `True`, the json will be indented by either 2 spaces or the amount + provided in the `indent` kwarg. + **kwargs: Additional arguments to pass to `json.dumps` + + Returns: + A JSON string representation of the object. + + Raises: + ValueError: If `default` is passed as a kwarg. + """ + if "default" in kwargs: + msg = "`default` should not be passed to dumps" + raise ValueError(msg) + + obj = _dump_pydantic_models(obj) + serialized = _serialize_value(obj) + + if pretty: + indent = kwargs.pop("indent", 2) + return json.dumps(serialized, indent=indent, **kwargs) + return json.dumps(serialized, **kwargs) + + +def dumpd(obj: Any) -> Any: + """Return a dict representation of an object. + + Note: + Plain dicts containing an `'lc'` key are automatically escaped to prevent + confusion with LC serialization format. The escape marker is removed during + deserialization. + + Args: + obj: The object to dump. + + Returns: + Dictionary that can be serialized to json using `json.dumps`. + """ + obj = _dump_pydantic_models(obj) + return _serialize_value(obj) diff --git a/libs/core/langchain_core/load/load.py b/libs/core/langchain_core/load/load.py new file mode 100644 index 0000000..030cafc --- /dev/null +++ b/libs/core/langchain_core/load/load.py @@ -0,0 +1,828 @@ +"""Load LangChain objects from JSON strings or objects. + +## How it works + +Each `Serializable` LangChain object has a unique identifier (its "class path"), which +is a list of strings representing the module path and class name. For example: + +- `AIMessage` -> `["langchain_core", "messages", "ai", "AIMessage"]` +- `ChatPromptTemplate` -> `["langchain_core", "prompts", "chat", "ChatPromptTemplate"]` + +When deserializing, the class path from the JSON `'id'` field is checked against an +allowlist. If the class is not in the allowlist, deserialization raises a `ValueError`. + +## Threat model + +A serialized LangChain payload crosses a trust boundary because the manifest +may contain serialized objects and configuration that affect runtime behavior. +For example, a payload can configure a chat model with a custom `base_url`, +custom headers, a different model name, or other constructor arguments. These +are supported features, but they also mean the payload contents should be +treated as executable configuration rather than plain text. + +Concretely, deserialization instantiates Python objects, so any constructor +(`__init__`) or validator on an allowed class can run during `load()`. A +crafted payload that is allowed to reach an unintended class — or an intended +class with attacker-controlled kwargs — could cause network calls, file +operations, or environment-variable access while the object is being built. + +!!! warning "Do not use with untrusted input" + + If the source is untrusted, avoid calling `load()` / `loads()` on it. If + you must, restrict `allowed_objects` to types that do not execute logic + during init — `allowed_objects='messages'` (or an explicit list of + message classes) is the safe choice. Keep `secrets_from_env=False`. + +The `allowed_objects` parameter controls which classes can be deserialized: + +- **Explicit list of classes** (recommended for untrusted input): only those + specific classes are allowed. +- **`'messages'`**: chat-message classes only (e.g. `AIMessage`, + `HumanMessage`). Safe for untrusted input. +- **`'core'` (current default)** — *unsafe with untrusted manifests.* + Classes defined in the serialization mappings under `langchain_core` + (messages, documents, prompts, etc.). +- **`'all'`** — *unsafe with untrusted manifests.* Every class in the + serialization mappings, including partner chat models and LLMs and their + constructor kwargs (endpoint URLs, headers, model names, etc.). + +!!! note "Side effects in allowed classes" + + Deserialization calls `__init__` on allowed classes. If those classes perform + side effects during initialization (network calls, file operations, etc.), + those side effects will occur. The allowlist prevents instantiation of + classes outside the allowlist, but does not sandbox the allowed classes + themselves or constrain their constructor kwargs. + + For example, an untrusted manifest could deserialize a chat model whose + `base_url` (or `endpoint_url`) points at an attacker-controlled host. Any + request that model makes is then directed there — a Server-Side Request + Forgery (SSRF) vector. This is *expected behavior*: deserialization + faithfully reconstructs the configuration carried by the manifest, custom + endpoints included, and LangChain does not special-case or strip such + kwargs. The mitigation is to **only deserialize manifests you trust**, + and for untrusted input to restrict `allowed_objects` to `'messages'` + or an explicit list of classes that take no endpoint configuration. + +Import paths are also validated against trusted namespaces before any module is +imported. + +### Best practices + +- Use the most restrictive `allowed_objects` possible. For untrusted input, + pass an explicit list of classes or `'messages'`. `'core'` and `'all'` + are unsafe with untrusted manifests — only use them when the source + serves the entire payload, including its configuration. +- Keep `secrets_from_env` set to `False` (the default). If you must use it, + ensure the serialized data comes from a fully trusted source, as a crafted + payload can read arbitrary environment variables. +- When using `secrets_map`, include only the specific secrets that the + serialized object requires. + +### Injection protection (escape-based) + +During serialization, plain dicts that contain an `'lc'` key are escaped by wrapping +them: `{"__lc_escaped__": {...}}`. During deserialization, escaped dicts are unwrapped +and returned as plain dicts, NOT instantiated as LC objects. + +This is an allowlist approach: only dicts explicitly produced by +`Serializable.to_json()` (which are NOT escaped) are treated as LC objects; +everything else is user data. + +Even if an attacker's payload includes `__lc_escaped__` wrappers, it will be unwrapped +to plain dicts and NOT instantiated as malicious objects. + +## Examples + +```python +from langchain_core.load import load +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.messages import AIMessage, HumanMessage + +# Use default allowlist (classes from mappings) - recommended +obj = load(data) + +# Allow only specific classes (most restrictive) +obj = load( + data, + allowed_objects=[ + ChatPromptTemplate, + AIMessage, + HumanMessage, + ], +) +``` +""" + +import importlib +import json +import os +from collections.abc import Callable, Iterable +from typing import Any, Literal, cast + +from langchain_core._api import beta +from langchain_core._api.deprecation import warn_deprecated +from langchain_core.load._validation import _is_escaped_dict, _unescape_value +from langchain_core.load.mapping import ( + _JS_SERIALIZABLE_MAPPING, + _OG_SERIALIZABLE_MAPPING, + OLD_CORE_NAMESPACES_MAPPING, + SERIALIZABLE_MAPPING, +) +from langchain_core.load.serializable import Serializable + +DEFAULT_NAMESPACES = [ + "langchain", + "langchain_core", + "langchain_community", + "langchain_anthropic", + "langchain_groq", + "langchain_google_genai", + "langchain_aws", + "langchain_openai", + "langchain_google_vertexai", + "langchain_mistralai", + "langchain_fireworks", + "langchain_xai", + "langchain_sambanova", + "langchain_perplexity", +] +# Namespaces for which only deserializing via the SERIALIZABLE_MAPPING is allowed. +# Load by path is not allowed. +DISALLOW_LOAD_FROM_PATH = [ + "langchain_community", + "langchain", +] + +ALL_SERIALIZABLE_MAPPINGS = { + **SERIALIZABLE_MAPPING, + **OLD_CORE_NAMESPACES_MAPPING, + **_OG_SERIALIZABLE_MAPPING, + **_JS_SERIALIZABLE_MAPPING, +} + +# Modern message classes admitted by `allowed_objects='messages'`. Legacy types +# (BaseMessage / BaseMessageChunk, ChatMessage / ChatMessageChunk, FunctionMessage / +# FunctionMessageChunk) are intentionally excluded — `BaseMessage` is abstract and +# the chat/function variants are superseded by `ToolMessage` and tool calling. +_MESSAGES_ALLOWED_CLASS_NAMES = frozenset( + { + "AIMessage", + "AIMessageChunk", + "HumanMessage", + "HumanMessageChunk", + "SystemMessage", + "SystemMessageChunk", + "ToolMessage", + "ToolMessageChunk", + "RemoveMessage", + } +) + +# Cache for the default allowed class paths computed from mappings +# Maps mode ("all", "core", or "messages") to the cached set of paths +_default_class_paths_cache: dict[str, set[tuple[str, ...]]] = {} + + +def _get_default_allowed_class_paths( + allowed_object_mode: Literal["all", "core", "messages"], +) -> set[tuple[str, ...]]: + """Get the default allowed class paths from the serialization mappings. + + This uses the mappings as the source of truth for what classes are allowed + by default. Both the legacy paths (keys) and current paths (values) are included. + + Args: + allowed_object_mode: either `'all'`, `'core'`, or `'messages'`. + + Returns: + Set of class path tuples that are allowed by default. + """ + if allowed_object_mode in _default_class_paths_cache: + return _default_class_paths_cache[allowed_object_mode] + + allowed_paths: set[tuple[str, ...]] = set() + for key, value in ALL_SERIALIZABLE_MAPPINGS.items(): + if allowed_object_mode == "core" and value[0] != "langchain_core": + continue + if allowed_object_mode == "messages" and ( + value[0] != "langchain_core" + or value[-1] not in _MESSAGES_ALLOWED_CLASS_NAMES + ): + continue + allowed_paths.add(key) + allowed_paths.add(value) + + _default_class_paths_cache[allowed_object_mode] = allowed_paths + return _default_class_paths_cache[allowed_object_mode] + + +def _block_jinja2_templates( + class_path: tuple[str, ...], + kwargs: dict[str, Any], +) -> None: + """Block jinja2 templates during deserialization for security. + + Jinja2 templates can execute arbitrary code, so they are blocked by default when + deserializing objects with `template_format='jinja2'`. + + Note: + We intentionally do NOT check the `class_path` here to keep this simple and + future-proof. If any new class is added that accepts `template_format='jinja2'`, + it will be automatically blocked without needing to update this function. + + Args: + class_path: The class path tuple being deserialized (unused). + kwargs: The kwargs dict for the class constructor. + + Raises: + ValueError: If `template_format` is `'jinja2'`. + """ + _ = class_path # Unused - see docstring for rationale. Kept to satisfy signature. + if kwargs.get("template_format") == "jinja2": + msg = ( + "Jinja2 templates are not allowed during deserialization for security " + "reasons. Use 'f-string' template format instead, or explicitly allow " + "jinja2 by providing a custom init_validator." + ) + raise ValueError(msg) + + +def default_init_validator( + class_path: tuple[str, ...], + kwargs: dict[str, Any], +) -> None: + """Default init validator that blocks jinja2 templates. + + This is the default validator used by `load()` and `loads()` when no custom + validator is provided. + + Args: + class_path: The class path tuple being deserialized. + kwargs: The kwargs dict for the class constructor. + + Raises: + ValueError: If template_format is `'jinja2'`. + """ + _block_jinja2_templates(class_path, kwargs) + + +AllowedObject = type[Serializable] +"""Type alias for classes that can be included in the `allowed_objects` parameter. + +Must be a `Serializable` subclass (the class itself, not an instance). +""" + +InitValidator = Callable[[tuple[str, ...], dict[str, Any]], None] +"""Type alias for a callable that validates kwargs during deserialization. + +The callable receives: + +- `class_path`: A tuple of strings identifying the class being instantiated + (e.g., `('langchain', 'schema', 'messages', 'AIMessage')`). +- `kwargs`: The kwargs dict that will be passed to the constructor. + +The validator should raise an exception if the object should not be deserialized. +""" + + +def _compute_allowed_class_paths( + allowed_objects: Iterable[AllowedObject], + import_mappings: dict[tuple[str, ...], tuple[str, ...]], +) -> set[tuple[str, ...]]: + """Return allowed class paths from an explicit list of classes. + + A class path is a tuple of strings identifying a serializable class, derived from + `Serializable.lc_id()`. For example: `('langchain_core', 'messages', 'AIMessage')`. + + Args: + allowed_objects: Iterable of `Serializable` subclasses to allow. + import_mappings: Mapping of legacy class paths to current class paths. + + Returns: + Set of allowed class paths. + + Example: + ```python + # Allow a specific class + _compute_allowed_class_paths([MyPrompt], {}) -> + {("langchain_core", "prompts", "MyPrompt")} + + # Include legacy paths that map to the same class + import_mappings = {("old", "Prompt"): ("langchain_core", "prompts", "MyPrompt")} + _compute_allowed_class_paths([MyPrompt], import_mappings) -> + {("langchain_core", "prompts", "MyPrompt"), ("old", "Prompt")} + ``` + """ + allowed_objects_list = list(allowed_objects) + + allowed_class_paths: set[tuple[str, ...]] = set() + for allowed_obj in allowed_objects_list: + if not isinstance(allowed_obj, type) or not issubclass( + allowed_obj, Serializable + ): + msg = "allowed_objects must contain Serializable subclasses." # type: ignore[unreachable] + raise TypeError(msg) + + class_path = tuple(allowed_obj.lc_id()) + allowed_class_paths.add(class_path) + # Add legacy paths that map to the same class. + for mapping_key, mapping_value in import_mappings.items(): + if tuple(mapping_value) == class_path: + allowed_class_paths.add(mapping_key) + return allowed_class_paths + + +class Reviver: + """Reviver for JSON objects. + + Used as the `object_hook` for `json.loads` to reconstruct LangChain objects from + their serialized JSON representation. + + Only classes in the allowlist can be instantiated. + """ + + def __init__( + self, + allowed_objects: Iterable[AllowedObject] + | Literal["all", "core", "messages"] + | None = None, + secrets_map: dict[str, str] | None = None, + valid_namespaces: list[str] | None = None, + secrets_from_env: bool = False, # noqa: FBT001,FBT002 + additional_import_mappings: dict[tuple[str, ...], tuple[str, ...]] + | None = None, + *, + ignore_unserializable_fields: bool = False, + init_validator: InitValidator | None = default_init_validator, + ) -> None: + """Initialize the reviver. + + See the module docstring for the threat model around `load()`/`loads()`: + a serialized payload may carry constructor configuration that affects + runtime behavior (custom `base_url`, headers, model name, etc.). Do not + use `'core'` or `'all'` with untrusted manifests. + + Args: + allowed_objects: Allowlist of classes that can be deserialized. + - Explicit list of classes (recommended for untrusted input): + only those specific classes are allowed. + - `'messages'`: chat-message classes only (e.g. `AIMessage`, + `HumanMessage`). Safe for untrusted input. + - `'core'` (current default): unsafe with untrusted manifests. + Classes defined in the serialization mappings under + `langchain_core`. + - `'all'`: unsafe with untrusted manifests. Every class in the + serialization mappings, including partner chat models and + LLMs and their constructor kwargs. See + `langchain_core.load.mapping` for the full list. + secrets_map: A map of secrets to load. + + Only include the specific secrets the serialized object + requires. If a secret is not found in the map, it will be loaded + from the environment if `secrets_from_env` is `True`. + valid_namespaces: Additional namespaces (modules) to allow during + deserialization, beyond the default trusted namespaces. + secrets_from_env: Whether to load secrets from the environment. + + A crafted payload can name arbitrary environment variables in + its `secret` fields, so enabling this on untrusted data can leak + sensitive values. Keep this `False` (the default) unless the + serialized data is fully trusted. + additional_import_mappings: A dictionary of additional namespace mappings. + + You can use this to override default mappings or add new mappings. + + When `allowed_objects` is `None` (using defaults), paths from these + mappings are also added to the allowed class paths. + ignore_unserializable_fields: Whether to ignore unserializable fields. + init_validator: Optional callable to validate kwargs before instantiation. + + If provided, this function is called with `(class_path, kwargs)` where + `class_path` is the class path tuple and `kwargs` is the kwargs dict. + The validator should raise an exception if the object should not be + deserialized, otherwise return `None`. + + Defaults to `default_init_validator` which blocks jinja2 templates. + """ + if allowed_objects is None: + warn_deprecated( + since="1.3.3", + message=( + "The default value of `allowed_objects` will change in a future " + "version. Pass an explicit value (e.g., " + "allowed_objects='messages' or allowed_objects='core') to suppress " + "this warning." + ), + pending=True, + ) + allowed_objects = "core" + + self.secrets_from_env = secrets_from_env + self.secrets_map = secrets_map or {} + # By default, only support langchain, but user can pass in additional namespaces + self.valid_namespaces = ( + [*DEFAULT_NAMESPACES, *valid_namespaces] + if valid_namespaces + else DEFAULT_NAMESPACES + ) + self.additional_import_mappings = additional_import_mappings or {} + self.import_mappings = ( + { + **ALL_SERIALIZABLE_MAPPINGS, + **self.additional_import_mappings, + } + if self.additional_import_mappings + else ALL_SERIALIZABLE_MAPPINGS + ) + # Compute allowed class paths: + # - "all" -> use default paths from mappings (+ additional_import_mappings) + # - Explicit list -> compute from those classes + if allowed_objects in ("all", "core", "messages"): + self.allowed_class_paths: set[tuple[str, ...]] | None = ( + _get_default_allowed_class_paths(allowed_objects).copy() + ) + # Add paths from additional_import_mappings to the defaults + if self.additional_import_mappings: + for key, value in self.additional_import_mappings.items(): + self.allowed_class_paths.add(key) + self.allowed_class_paths.add(value) + else: + self.allowed_class_paths = _compute_allowed_class_paths( + cast("Iterable[AllowedObject]", allowed_objects), self.import_mappings + ) + self.ignore_unserializable_fields = ignore_unserializable_fields + self.init_validator = init_validator + + def __call__(self, value: dict[str, Any]) -> Any: + """Revive the value. + + Args: + value: The value to revive. + + Returns: + The revived value. + + Raises: + ValueError: If the namespace is invalid. + ValueError: If trying to deserialize something that cannot + be deserialized in the current version of langchain-core. + NotImplementedError: If the object is not implemented and + `ignore_unserializable_fields` is False. + """ + if ( + value.get("lc") == 1 + and value.get("type") == "secret" + and value.get("id") is not None + ): + [key] = value["id"] + if key in self.secrets_map: + return self.secrets_map[key] + if self.secrets_from_env and key in os.environ and os.environ[key]: + return os.environ[key] + return None + + if ( + value.get("lc") == 1 + and value.get("type") == "not_implemented" + and value.get("id") is not None + ): + if self.ignore_unserializable_fields: + return None + msg = ( + "Trying to load an object that doesn't implement " + f"serialization: {value}" + ) + raise NotImplementedError(msg) + + if ( + value.get("lc") == 1 + and value.get("type") == "constructor" + and value.get("id") is not None + ): + [*namespace, name] = value["id"] + mapping_key = tuple(value["id"]) + + if ( + self.allowed_class_paths is not None + and mapping_key not in self.allowed_class_paths + ): + msg = ( + f"Deserialization of {mapping_key!r} is not allowed. " + "The default (allowed_objects='core') only permits core " + "langchain-core classes. To allow trusted partner integrations, " + "use allowed_objects='all'. Alternatively, pass an explicit list " + "of allowed classes via allowed_objects=[...]. " + "See langchain_core.load.mapping for the full allowlist." + ) + raise ValueError(msg) + + if ( + namespace[0] not in self.valid_namespaces + # The root namespace ["langchain"] is not a valid identifier. + or namespace == ["langchain"] + ): + msg = f"Invalid namespace: {value}" + raise ValueError(msg) + # Determine explicit import path + if mapping_key in self.import_mappings: + import_path = self.import_mappings[mapping_key] + # Split into module and name + import_dir, name = import_path[:-1], import_path[-1] + elif namespace[0] in DISALLOW_LOAD_FROM_PATH: + msg = ( + "Trying to deserialize something that cannot " + "be deserialized in current version of langchain-core: " + f"{mapping_key}." + ) + raise ValueError(msg) + else: + # Otherwise, treat namespace as path. + import_dir = namespace + + # Validate import path is in trusted namespaces before importing + if import_dir[0] not in self.valid_namespaces: + msg = f"Invalid namespace: {value}" + raise ValueError(msg) + + # We don't need to recurse on kwargs + # as json.loads will do that for us. + kwargs = value.get("kwargs", {}) + + # Run the init_validator (e.g., jinja2 blocking) before importing + # to fail fast on security violations. + if self.init_validator is not None: + self.init_validator(mapping_key, kwargs) + + mod = importlib.import_module(".".join(import_dir)) + + cls = getattr(mod, name) + + # The class must be a subclass of Serializable. + if not issubclass(cls, Serializable): + msg = f"Invalid namespace: {value}" + raise ValueError(msg) + + return cls(**kwargs) + + return value + + +@beta() +def loads( + text: str, + *, + allowed_objects: Iterable[AllowedObject] + | Literal["all", "core", "messages"] + | None = None, + secrets_map: dict[str, str] | None = None, + valid_namespaces: list[str] | None = None, + secrets_from_env: bool = False, + additional_import_mappings: dict[tuple[str, ...], tuple[str, ...]] | None = None, + ignore_unserializable_fields: bool = False, + init_validator: InitValidator | None = default_init_validator, +) -> Any: + """Revive a LangChain class from a JSON string. + + Equivalent to `load(json.loads(text))`. + + Only classes in the allowlist can be instantiated. The default allowlist + includes core LangChain types (messages, prompts, documents, etc.). See + `langchain_core.load.mapping` for the full list. + + !!! warning "Do not use with untrusted input" + + A serialized payload may carry constructor kwargs that affect runtime + behavior (custom `base_url`, headers, model name, etc.), so it should be + treated as executable configuration rather than plain text. For example, + deserializing a model whose `base_url` points at an attacker-controlled + host can result in Server-Side Request Forgery (SSRF); this is expected + behavior, since `loads()` faithfully reconstructs the configuration in + the manifest. If the source is untrusted, avoid calling `loads()` on it; + if you must, pass `allowed_objects='messages'` or an explicit list of + message classes. See the module-level threat model for details. + + Args: + text: The string to load. + allowed_objects: Allowlist of classes that can be deserialized. + + - Explicit list of classes (recommended for untrusted input): only + those specific classes are allowed. + - `'messages'`: chat-message classes only. Safe for untrusted input. + - `'core'` (current default): unsafe with untrusted manifests. + Classes defined in the serialization mappings under + `langchain_core`. + - `'all'`: unsafe with untrusted manifests. Every class in the + serialization mappings, including partner chat models and LLMs + and their constructor kwargs. See `langchain_core.load.mapping` + for the full list. + - `[]`: Disallow all deserialization (will raise on any object). + secrets_map: A map of secrets to load. + + Only include the specific secrets the serialized object requires. If + a secret is not found in the map, it will be loaded from the + environment if `secrets_from_env` is `True`. + valid_namespaces: Additional namespaces (modules) to allow during + deserialization, beyond the default trusted namespaces. + secrets_from_env: Whether to load secrets from the environment. + + A crafted payload can name arbitrary environment variables in its + `secret` fields, so enabling this on untrusted data can leak + sensitive values. Keep this `False` (the default) unless the + serialized data is fully trusted. + additional_import_mappings: A dictionary of additional namespace mappings. + + You can use this to override default mappings or add new mappings. + + When `allowed_objects` is `None` (using defaults), paths from these + mappings are also added to the allowed class paths. + ignore_unserializable_fields: Whether to ignore unserializable fields. + init_validator: Optional callable to validate kwargs before instantiation. + + If provided, this function is called with `(class_path, kwargs)` where + `class_path` is the class path tuple and `kwargs` is the kwargs dict. + The validator should raise an exception if the object should not be + deserialized, otherwise return `None`. + + Defaults to `default_init_validator` which blocks jinja2 templates. + + Returns: + Revived LangChain objects. + + Raises: + ValueError: If an object's class path is not in the `allowed_objects` allowlist. + """ + if allowed_objects is None: + warn_deprecated( + since="1.3.3", + message=( + "The default value of `allowed_objects` will change in a future " + "version. Pass an explicit list of allowed classes (or " + "'messages' for untrusted input that contains only chat " + "messages) to suppress this warning." + ), + pending=True, + ) + allowed_objects = "core" + + # Parse JSON and delegate to load() for proper escape handling + raw_obj = json.loads(text) + return load( + raw_obj, + allowed_objects=allowed_objects, + secrets_map=secrets_map, + valid_namespaces=valid_namespaces, + secrets_from_env=secrets_from_env, + additional_import_mappings=additional_import_mappings, + ignore_unserializable_fields=ignore_unserializable_fields, + init_validator=init_validator, + ) + + +@beta() +def load( + obj: Any, + *, + allowed_objects: Iterable[AllowedObject] + | Literal["all", "core", "messages"] + | None = None, + secrets_map: dict[str, str] | None = None, + valid_namespaces: list[str] | None = None, + secrets_from_env: bool = False, + additional_import_mappings: dict[tuple[str, ...], tuple[str, ...]] | None = None, + ignore_unserializable_fields: bool = False, + init_validator: InitValidator | None = default_init_validator, +) -> Any: + """Revive a LangChain class from a JSON object. + + Use this if you already have a parsed JSON object, eg. from `json.load` or + `orjson.loads`. + + Only classes in the allowlist can be instantiated. The default allowlist + includes core LangChain types (messages, prompts, documents, etc.). See + `langchain_core.load.mapping` for the full list. + + !!! warning "Do not use with untrusted input" + + A serialized payload may carry constructor kwargs that affect runtime + behavior (custom `base_url`, headers, model name, etc.), so it should be + treated as executable configuration rather than plain text. For example, + deserializing a model whose `base_url` points at an attacker-controlled + host can result in Server-Side Request Forgery (SSRF); this is expected + behavior, since `load()` faithfully reconstructs the configuration in + the manifest. If the source is untrusted, avoid calling `load()` on it; + if you must, pass `allowed_objects='messages'` or an explicit list of + message classes. See the module-level threat model for details. + + Args: + obj: The object to load. + allowed_objects: Allowlist of classes that can be deserialized. + + - Explicit list of classes (recommended for untrusted input): only + those specific classes are allowed. + - `'messages'`: chat-message classes only. Safe for untrusted input. + - `'core'` (current default): unsafe with untrusted manifests. + Classes defined in the serialization mappings under + `langchain_core`. + - `'all'`: unsafe with untrusted manifests. Every class in the + serialization mappings, including partner chat models and LLMs + and their constructor kwargs. See `langchain_core.load.mapping` + for the full list. + - `[]`: Disallow all deserialization (will raise on any object). + secrets_map: A map of secrets to load. + + Only include the specific secrets the serialized object requires. + + If a secret is not found in the map, it will be loaded from the environment + if `secrets_from_env` is `True`. + valid_namespaces: Additional namespaces (modules) to allow during + deserialization, beyond the default trusted namespaces. + secrets_from_env: Whether to load secrets from the environment. + + A crafted payload can name arbitrary environment variables in its + `secret` fields, so enabling this on untrusted data can leak + sensitive values. Keep this `False` (the default) unless the + serialized data is fully trusted. + additional_import_mappings: A dictionary of additional namespace mappings. + + You can use this to override default mappings or add new mappings. + + When `allowed_objects` is `None` (using defaults), paths from these + mappings are also added to the allowed class paths. + ignore_unserializable_fields: Whether to ignore unserializable fields. + init_validator: Optional callable to validate kwargs before instantiation. + + If provided, this function is called with `(class_path, kwargs)` where + `class_path` is the class path tuple and `kwargs` is the kwargs dict. + The validator should raise an exception if the object should not be + deserialized, otherwise return `None`. + + Defaults to `default_init_validator` which blocks jinja2 templates. + + Returns: + Revived LangChain objects. + + Raises: + ValueError: If an object's class path is not in the `allowed_objects` allowlist. + + Example: + ```python + from langchain_core.load import load, dumpd + from langchain_core.messages import AIMessage + + msg = AIMessage(content="Hello") + data = dumpd(msg) + + # Deserialize using default allowlist + loaded = load(data) + + # Or with explicit allowlist + loaded = load(data, allowed_objects=[AIMessage]) + + # Or extend defaults with additional mappings + loaded = load( + data, + additional_import_mappings={ + ("my_pkg", "MyClass"): ("my_pkg", "module", "MyClass"), + }, + ) + ``` + """ + if allowed_objects is None: + warn_deprecated( + since="1.3.3", + message=( + "The default value of `allowed_objects` will change in a future " + "version. Pass an explicit list of allowed classes (or " + "'messages' for untrusted input that contains only chat " + "messages) to suppress this warning." + ), + pending=True, + ) + allowed_objects = "core" + + reviver = Reviver( + allowed_objects, + secrets_map, + valid_namespaces, + secrets_from_env, + additional_import_mappings, + ignore_unserializable_fields=ignore_unserializable_fields, + init_validator=init_validator, + ) + + def _load(obj: Any) -> Any: + if isinstance(obj, dict): + # Check for escaped dict FIRST (before recursing). + # Escaped dicts are user data that should NOT be processed as LC objects. + if _is_escaped_dict(obj): + return _unescape_value(obj) + + # Not escaped - recurse into children then apply reviver + loaded_obj = {k: _load(v) for k, v in obj.items()} + return reviver(loaded_obj) + if isinstance(obj, list): + return [_load(o) for o in obj] + return obj + + return _load(obj) diff --git a/libs/core/langchain_core/load/mapping.py b/libs/core/langchain_core/load/mapping.py new file mode 100644 index 0000000..53a9282 --- /dev/null +++ b/libs/core/langchain_core/load/mapping.py @@ -0,0 +1,1085 @@ +"""Serialization mapping. + +This file contains a mapping between the `lc_namespace` path for a given +subclass that implements from `Serializable` to the namespace +where that class is actually located. + +This mapping helps maintain the ability to serialize and deserialize +well-known LangChain objects even if they are moved around in the codebase +across different LangChain versions. + +For example, the code for the `AIMessage` class is located in +`langchain_core.messages.ai.AIMessage`. This message is associated with the +`lc_namespace` of `["langchain", "schema", "messages", "AIMessage"]`, +because this code was originally in `langchain.schema.messages.AIMessage`. + +The mapping allows us to deserialize an `AIMessage` created with an older +version of LangChain where the code was in a different location. +""" + +# First value is the value that it is serialized as +# Second value is the path to load it from +SERIALIZABLE_MAPPING: dict[tuple[str, ...], tuple[str, ...]] = { + ("langchain", "schema", "messages", "AIMessage"): ( + "langchain_core", + "messages", + "ai", + "AIMessage", + ), + ("langchain", "schema", "messages", "AIMessageChunk"): ( + "langchain_core", + "messages", + "ai", + "AIMessageChunk", + ), + ("langchain", "schema", "messages", "BaseMessage"): ( + "langchain_core", + "messages", + "base", + "BaseMessage", + ), + ("langchain", "schema", "messages", "BaseMessageChunk"): ( + "langchain_core", + "messages", + "base", + "BaseMessageChunk", + ), + ("langchain", "schema", "messages", "ChatMessage"): ( + "langchain_core", + "messages", + "chat", + "ChatMessage", + ), + ("langchain", "schema", "messages", "FunctionMessage"): ( + "langchain_core", + "messages", + "function", + "FunctionMessage", + ), + ("langchain", "schema", "messages", "HumanMessage"): ( + "langchain_core", + "messages", + "human", + "HumanMessage", + ), + ("langchain", "schema", "messages", "SystemMessage"): ( + "langchain_core", + "messages", + "system", + "SystemMessage", + ), + ("langchain", "schema", "messages", "ToolMessage"): ( + "langchain_core", + "messages", + "tool", + "ToolMessage", + ), + ("langchain", "schema", "messages", "RemoveMessage"): ( + "langchain_core", + "messages", + "modifier", + "RemoveMessage", + ), + ("langchain", "schema", "agent", "AgentAction"): ( + "langchain_core", + "agents", + "AgentAction", + ), + ("langchain", "schema", "agent", "AgentFinish"): ( + "langchain_core", + "agents", + "AgentFinish", + ), + ("langchain", "schema", "prompt_template", "BasePromptTemplate"): ( + "langchain_core", + "prompts", + "base", + "BasePromptTemplate", + ), + ("langchain", "chains", "llm", "LLMChain"): ( + "langchain", + "chains", + "llm", + "LLMChain", + ), + ("langchain", "prompts", "prompt", "PromptTemplate"): ( + "langchain_core", + "prompts", + "prompt", + "PromptTemplate", + ), + ("langchain", "prompts", "chat", "MessagesPlaceholder"): ( + "langchain_core", + "prompts", + "chat", + "MessagesPlaceholder", + ), + ("langchain", "llms", "openai", "OpenAI"): ( + "langchain_openai", + "llms", + "base", + "OpenAI", + ), + ("langchain", "prompts", "chat", "ChatPromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "ChatPromptTemplate", + ), + ("langchain", "prompts", "chat", "HumanMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "HumanMessagePromptTemplate", + ), + ("langchain", "prompts", "chat", "SystemMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "SystemMessagePromptTemplate", + ), + ("langchain", "prompts", "image", "ImagePromptTemplate"): ( + "langchain_core", + "prompts", + "image", + "ImagePromptTemplate", + ), + ("langchain", "schema", "agent", "AgentActionMessageLog"): ( + "langchain_core", + "agents", + "AgentActionMessageLog", + ), + ("langchain", "schema", "agent", "ToolAgentAction"): ( + "langchain", + "agents", + "output_parsers", + "tools", + "ToolAgentAction", + ), + ("langchain", "prompts", "chat", "BaseMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseMessagePromptTemplate", + ), + ("langchain", "schema", "output", "ChatGeneration"): ( + "langchain_core", + "outputs", + "chat_generation", + "ChatGeneration", + ), + ("langchain", "schema", "output", "Generation"): ( + "langchain_core", + "outputs", + "generation", + "Generation", + ), + ("langchain", "schema", "document", "Document"): ( + "langchain_core", + "documents", + "base", + "Document", + ), + ("langchain", "output_parsers", "fix", "OutputFixingParser"): ( + "langchain", + "output_parsers", + "fix", + "OutputFixingParser", + ), + ("langchain", "prompts", "chat", "AIMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "AIMessagePromptTemplate", + ), + ("langchain", "output_parsers", "regex", "RegexParser"): ( + "langchain", + "output_parsers", + "regex", + "RegexParser", + ), + ("langchain", "schema", "runnable", "DynamicRunnable"): ( + "langchain_core", + "runnables", + "configurable", + "DynamicRunnable", + ), + ("langchain", "schema", "prompt", "PromptValue"): ( + "langchain_core", + "prompt_values", + "PromptValue", + ), + ("langchain", "schema", "runnable", "RunnableBinding"): ( + "langchain_core", + "runnables", + "base", + "RunnableBinding", + ), + ("langchain", "schema", "runnable", "RunnableBranch"): ( + "langchain_core", + "runnables", + "branch", + "RunnableBranch", + ), + ("langchain", "schema", "runnable", "RunnableWithFallbacks"): ( + "langchain_core", + "runnables", + "fallbacks", + "RunnableWithFallbacks", + ), + ("langchain", "schema", "output_parser", "StrOutputParser"): ( + "langchain_core", + "output_parsers", + "string", + "StrOutputParser", + ), + ("langchain", "chat_models", "openai", "ChatOpenAI"): ( + "langchain_openai", + "chat_models", + "base", + "ChatOpenAI", + ), + ("langchain", "output_parsers", "list", "CommaSeparatedListOutputParser"): ( + "langchain_core", + "output_parsers", + "list", + "CommaSeparatedListOutputParser", + ), + ("langchain", "schema", "runnable", "RunnableParallel"): ( + "langchain_core", + "runnables", + "base", + "RunnableParallel", + ), + ("langchain", "chat_models", "azure_openai", "AzureChatOpenAI"): ( + "langchain_openai", + "chat_models", + "azure", + "AzureChatOpenAI", + ), + ("langchain", "chat_models", "bedrock", "BedrockChat"): ( + "langchain_aws", + "chat_models", + "bedrock", + "ChatBedrock", + ), + ("langchain", "chat_models", "anthropic", "ChatAnthropic"): ( + "langchain_anthropic", + "chat_models", + "ChatAnthropic", + ), + ("langchain_groq", "chat_models", "ChatGroq"): ( + "langchain_groq", + "chat_models", + "ChatGroq", + ), + ("langchain_openrouter", "chat_models", "ChatOpenRouter"): ( + "langchain_openrouter", + "chat_models", + "ChatOpenRouter", + ), + ("langchain_xai", "chat_models", "ChatXAI"): ( + "langchain_xai", + "chat_models", + "ChatXAI", + ), + ("langchain_baseten", "chat_models", "ChatBaseten"): ( + "langchain_baseten", + "chat_models", + "ChatBaseten", + ), + ("langchain", "chat_models", "fireworks", "ChatFireworks"): ( + "langchain_fireworks", + "chat_models", + "ChatFireworks", + ), + ("langchain", "chat_models", "google_palm", "ChatGooglePalm"): ( + "langchain", + "chat_models", + "google_palm", + "ChatGooglePalm", + ), + ("langchain", "chat_models", "vertexai", "ChatVertexAI"): ( + "langchain_google_vertexai", + "chat_models", + "ChatVertexAI", + ), + ("langchain", "chat_models", "mistralai", "ChatMistralAI"): ( + "langchain_mistralai", + "chat_models", + "ChatMistralAI", + ), + ("langchain", "chat_models", "anthropic_bedrock", "ChatAnthropicBedrock"): ( + "langchain_aws", + "chat_models", + "anthropic", + "ChatAnthropicBedrock", + ), + ("langchain", "chat_models", "bedrock", "ChatBedrock"): ( + "langchain_aws", + "chat_models", + "bedrock", + "ChatBedrock", + ), + ("langchain_aws", "chat_models", "ChatBedrockConverse"): ( + "langchain_aws", + "chat_models", + "bedrock_converse", + "ChatBedrockConverse", + ), + ("langchain_google_genai", "chat_models", "ChatGoogleGenerativeAI"): ( + "langchain_google_genai", + "chat_models", + "ChatGoogleGenerativeAI", + ), + ("langchain", "schema", "output", "ChatGenerationChunk"): ( + "langchain_core", + "outputs", + "chat_generation", + "ChatGenerationChunk", + ), + ("langchain", "schema", "messages", "ChatMessageChunk"): ( + "langchain_core", + "messages", + "chat", + "ChatMessageChunk", + ), + ("langchain", "schema", "messages", "HumanMessageChunk"): ( + "langchain_core", + "messages", + "human", + "HumanMessageChunk", + ), + ("langchain", "schema", "messages", "FunctionMessageChunk"): ( + "langchain_core", + "messages", + "function", + "FunctionMessageChunk", + ), + ("langchain", "schema", "messages", "SystemMessageChunk"): ( + "langchain_core", + "messages", + "system", + "SystemMessageChunk", + ), + ("langchain", "schema", "messages", "ToolMessageChunk"): ( + "langchain_core", + "messages", + "tool", + "ToolMessageChunk", + ), + ("langchain", "schema", "output", "GenerationChunk"): ( + "langchain_core", + "outputs", + "generation", + "GenerationChunk", + ), + ("langchain", "llms", "openai", "BaseOpenAI"): ( + "langchain", + "llms", + "openai", + "BaseOpenAI", + ), + ("langchain", "llms", "bedrock", "Bedrock"): ( + "langchain_aws", + "llms", + "bedrock", + "BedrockLLM", + ), + ("langchain", "llms", "bedrock", "BedrockLLM"): ( + "langchain_aws", + "llms", + "bedrock", + "BedrockLLM", + ), + ("langchain", "llms", "fireworks", "Fireworks"): ( + "langchain_fireworks", + "llms", + "Fireworks", + ), + ("langchain", "llms", "google_palm", "GooglePalm"): ( + "langchain", + "llms", + "google_palm", + "GooglePalm", + ), + ("langchain", "llms", "openai", "AzureOpenAI"): ( + "langchain_openai", + "llms", + "azure", + "AzureOpenAI", + ), + ("langchain", "llms", "replicate", "Replicate"): ( + "langchain", + "llms", + "replicate", + "Replicate", + ), + ("langchain", "llms", "vertexai", "VertexAI"): ( + "langchain_vertexai", + "llms", + "VertexAI", + ), + ("langchain", "output_parsers", "combining", "CombiningOutputParser"): ( + "langchain", + "output_parsers", + "combining", + "CombiningOutputParser", + ), + ("langchain", "schema", "prompt_template", "BaseChatPromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseChatPromptTemplate", + ), + ("langchain", "prompts", "chat", "ChatMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "ChatMessagePromptTemplate", + ), + ("langchain", "prompts", "few_shot_with_templates", "FewShotPromptWithTemplates"): ( + "langchain_core", + "prompts", + "few_shot_with_templates", + "FewShotPromptWithTemplates", + ), + ("langchain", "prompts", "pipeline"): ( + "langchain_core", + "prompts", + "pipeline", + ), + ("langchain", "prompts", "base", "StringPromptTemplate"): ( + "langchain_core", + "prompts", + "string", + "StringPromptTemplate", + ), + ("langchain", "prompts", "base", "StringPromptValue"): ( + "langchain_core", + "prompt_values", + "StringPromptValue", + ), + ("langchain", "prompts", "chat", "BaseStringMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseStringMessagePromptTemplate", + ), + ("langchain", "prompts", "chat", "ChatPromptValue"): ( + "langchain_core", + "prompt_values", + "ChatPromptValue", + ), + ("langchain", "prompts", "chat", "ChatPromptValueConcrete"): ( + "langchain_core", + "prompt_values", + "ChatPromptValueConcrete", + ), + ("langchain", "schema", "runnable", "HubRunnable"): ( + "langchain", + "runnables", + "hub", + "HubRunnable", + ), + ("langchain", "schema", "runnable", "RunnableBindingBase"): ( + "langchain_core", + "runnables", + "base", + "RunnableBindingBase", + ), + ("langchain", "schema", "runnable", "OpenAIFunctionsRouter"): ( + "langchain", + "runnables", + "openai_functions", + "OpenAIFunctionsRouter", + ), + ("langchain", "schema", "runnable", "RouterRunnable"): ( + "langchain_core", + "runnables", + "router", + "RouterRunnable", + ), + ("langchain", "schema", "runnable", "RunnablePassthrough"): ( + "langchain_core", + "runnables", + "passthrough", + "RunnablePassthrough", + ), + ("langchain", "schema", "runnable", "RunnableSequence"): ( + "langchain_core", + "runnables", + "base", + "RunnableSequence", + ), + ("langchain", "schema", "runnable", "RunnableEach"): ( + "langchain_core", + "runnables", + "base", + "RunnableEach", + ), + ("langchain", "schema", "runnable", "RunnableEachBase"): ( + "langchain_core", + "runnables", + "base", + "RunnableEachBase", + ), + ("langchain", "schema", "runnable", "RunnableConfigurableAlternatives"): ( + "langchain_core", + "runnables", + "configurable", + "RunnableConfigurableAlternatives", + ), + ("langchain", "schema", "runnable", "RunnableConfigurableFields"): ( + "langchain_core", + "runnables", + "configurable", + "RunnableConfigurableFields", + ), + ("langchain", "schema", "runnable", "RunnableWithMessageHistory"): ( + "langchain_core", + "runnables", + "history", + "RunnableWithMessageHistory", + ), + ("langchain", "schema", "runnable", "RunnableAssign"): ( + "langchain_core", + "runnables", + "passthrough", + "RunnableAssign", + ), + ("langchain", "schema", "runnable", "RunnableRetry"): ( + "langchain_core", + "runnables", + "retry", + "RunnableRetry", + ), + ("langchain_core", "prompts", "structured", "StructuredPrompt"): ( + "langchain_core", + "prompts", + "structured", + "StructuredPrompt", + ), + ("langchain_core", "prompts", "message", "_DictMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "dict", + "DictPromptTemplate", + ), +} + +# Needed for backwards compatibility for old versions of LangChain where things +# Were in different place +_OG_SERIALIZABLE_MAPPING: dict[tuple[str, ...], tuple[str, ...]] = { + ("langchain", "schema", "AIMessage"): ( + "langchain_core", + "messages", + "ai", + "AIMessage", + ), + ("langchain", "schema", "ChatMessage"): ( + "langchain_core", + "messages", + "chat", + "ChatMessage", + ), + ("langchain", "schema", "FunctionMessage"): ( + "langchain_core", + "messages", + "function", + "FunctionMessage", + ), + ("langchain", "schema", "HumanMessage"): ( + "langchain_core", + "messages", + "human", + "HumanMessage", + ), + ("langchain", "schema", "SystemMessage"): ( + "langchain_core", + "messages", + "system", + "SystemMessage", + ), + ("langchain", "schema", "prompt_template", "ImagePromptTemplate"): ( + "langchain_core", + "prompts", + "image", + "ImagePromptTemplate", + ), + ("langchain", "schema", "agent", "OpenAIToolAgentAction"): ( + "langchain", + "agents", + "output_parsers", + "openai_tools", + "OpenAIToolAgentAction", + ), +} + +# Needed for backwards compatibility for a few versions where we serialized +# with langchain_core paths. +OLD_CORE_NAMESPACES_MAPPING: dict[tuple[str, ...], tuple[str, ...]] = { + ("langchain_core", "messages", "ai", "AIMessage"): ( + "langchain_core", + "messages", + "ai", + "AIMessage", + ), + ("langchain_core", "messages", "ai", "AIMessageChunk"): ( + "langchain_core", + "messages", + "ai", + "AIMessageChunk", + ), + ("langchain_core", "messages", "base", "BaseMessage"): ( + "langchain_core", + "messages", + "base", + "BaseMessage", + ), + ("langchain_core", "messages", "base", "BaseMessageChunk"): ( + "langchain_core", + "messages", + "base", + "BaseMessageChunk", + ), + ("langchain_core", "messages", "chat", "ChatMessage"): ( + "langchain_core", + "messages", + "chat", + "ChatMessage", + ), + ("langchain_core", "messages", "function", "FunctionMessage"): ( + "langchain_core", + "messages", + "function", + "FunctionMessage", + ), + ("langchain_core", "messages", "human", "HumanMessage"): ( + "langchain_core", + "messages", + "human", + "HumanMessage", + ), + ("langchain_core", "messages", "system", "SystemMessage"): ( + "langchain_core", + "messages", + "system", + "SystemMessage", + ), + ("langchain_core", "messages", "tool", "ToolMessage"): ( + "langchain_core", + "messages", + "tool", + "ToolMessage", + ), + ("langchain_core", "agents", "AgentAction"): ( + "langchain_core", + "agents", + "AgentAction", + ), + ("langchain_core", "agents", "AgentFinish"): ( + "langchain_core", + "agents", + "AgentFinish", + ), + ("langchain_core", "prompts", "base", "BasePromptTemplate"): ( + "langchain_core", + "prompts", + "base", + "BasePromptTemplate", + ), + ("langchain_core", "prompts", "prompt", "PromptTemplate"): ( + "langchain_core", + "prompts", + "prompt", + "PromptTemplate", + ), + ("langchain_core", "prompts", "chat", "MessagesPlaceholder"): ( + "langchain_core", + "prompts", + "chat", + "MessagesPlaceholder", + ), + ("langchain_core", "prompts", "chat", "ChatPromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "ChatPromptTemplate", + ), + ("langchain_core", "prompts", "chat", "HumanMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "HumanMessagePromptTemplate", + ), + ("langchain_core", "prompts", "chat", "SystemMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "SystemMessagePromptTemplate", + ), + ("langchain_core", "agents", "AgentActionMessageLog"): ( + "langchain_core", + "agents", + "AgentActionMessageLog", + ), + ("langchain_core", "prompts", "chat", "BaseMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseMessagePromptTemplate", + ), + ("langchain_core", "outputs", "chat_generation", "ChatGeneration"): ( + "langchain_core", + "outputs", + "chat_generation", + "ChatGeneration", + ), + ("langchain_core", "outputs", "generation", "Generation"): ( + "langchain_core", + "outputs", + "generation", + "Generation", + ), + ("langchain_core", "documents", "base", "Document"): ( + "langchain_core", + "documents", + "base", + "Document", + ), + ("langchain_core", "prompts", "chat", "AIMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "AIMessagePromptTemplate", + ), + ("langchain_core", "runnables", "configurable", "DynamicRunnable"): ( + "langchain_core", + "runnables", + "configurable", + "DynamicRunnable", + ), + ("langchain_core", "prompt_values", "PromptValue"): ( + "langchain_core", + "prompt_values", + "PromptValue", + ), + ("langchain_core", "runnables", "base", "RunnableBinding"): ( + "langchain_core", + "runnables", + "base", + "RunnableBinding", + ), + ("langchain_core", "runnables", "branch", "RunnableBranch"): ( + "langchain_core", + "runnables", + "branch", + "RunnableBranch", + ), + ("langchain_core", "runnables", "fallbacks", "RunnableWithFallbacks"): ( + "langchain_core", + "runnables", + "fallbacks", + "RunnableWithFallbacks", + ), + ("langchain_core", "output_parsers", "string", "StrOutputParser"): ( + "langchain_core", + "output_parsers", + "string", + "StrOutputParser", + ), + ("langchain_core", "output_parsers", "list", "CommaSeparatedListOutputParser"): ( + "langchain_core", + "output_parsers", + "list", + "CommaSeparatedListOutputParser", + ), + ("langchain_core", "runnables", "base", "RunnableParallel"): ( + "langchain_core", + "runnables", + "base", + "RunnableParallel", + ), + ("langchain_core", "outputs", "chat_generation", "ChatGenerationChunk"): ( + "langchain_core", + "outputs", + "chat_generation", + "ChatGenerationChunk", + ), + ("langchain_core", "messages", "chat", "ChatMessageChunk"): ( + "langchain_core", + "messages", + "chat", + "ChatMessageChunk", + ), + ("langchain_core", "messages", "human", "HumanMessageChunk"): ( + "langchain_core", + "messages", + "human", + "HumanMessageChunk", + ), + ("langchain_core", "messages", "function", "FunctionMessageChunk"): ( + "langchain_core", + "messages", + "function", + "FunctionMessageChunk", + ), + ("langchain_core", "messages", "system", "SystemMessageChunk"): ( + "langchain_core", + "messages", + "system", + "SystemMessageChunk", + ), + ("langchain_core", "messages", "tool", "ToolMessageChunk"): ( + "langchain_core", + "messages", + "tool", + "ToolMessageChunk", + ), + ("langchain_core", "outputs", "generation", "GenerationChunk"): ( + "langchain_core", + "outputs", + "generation", + "GenerationChunk", + ), + ("langchain_core", "prompts", "chat", "BaseChatPromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseChatPromptTemplate", + ), + ("langchain_core", "prompts", "chat", "ChatMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "ChatMessagePromptTemplate", + ), + ( + "langchain_core", + "prompts", + "few_shot_with_templates", + "FewShotPromptWithTemplates", + ): ( + "langchain_core", + "prompts", + "few_shot_with_templates", + "FewShotPromptWithTemplates", + ), + ("langchain_core", "prompts", "pipeline"): ( + "langchain_core", + "prompts", + "pipeline", + ), + ("langchain_core", "prompts", "string", "StringPromptTemplate"): ( + "langchain_core", + "prompts", + "string", + "StringPromptTemplate", + ), + ("langchain_core", "prompt_values", "StringPromptValue"): ( + "langchain_core", + "prompt_values", + "StringPromptValue", + ), + ("langchain_core", "prompts", "chat", "BaseStringMessagePromptTemplate"): ( + "langchain_core", + "prompts", + "chat", + "BaseStringMessagePromptTemplate", + ), + ("langchain_core", "prompt_values", "ChatPromptValue"): ( + "langchain_core", + "prompt_values", + "ChatPromptValue", + ), + ("langchain_core", "prompt_values", "ChatPromptValueConcrete"): ( + "langchain_core", + "prompt_values", + "ChatPromptValueConcrete", + ), + ("langchain_core", "runnables", "base", "RunnableBindingBase"): ( + "langchain_core", + "runnables", + "base", + "RunnableBindingBase", + ), + ("langchain_core", "runnables", "router", "RouterRunnable"): ( + "langchain_core", + "runnables", + "router", + "RouterRunnable", + ), + ("langchain_core", "runnables", "passthrough", "RunnablePassthrough"): ( + "langchain_core", + "runnables", + "passthrough", + "RunnablePassthrough", + ), + ("langchain_core", "runnables", "base", "RunnableSequence"): ( + "langchain_core", + "runnables", + "base", + "RunnableSequence", + ), + ("langchain_core", "runnables", "base", "RunnableEach"): ( + "langchain_core", + "runnables", + "base", + "RunnableEach", + ), + ("langchain_core", "runnables", "base", "RunnableEachBase"): ( + "langchain_core", + "runnables", + "base", + "RunnableEachBase", + ), + ( + "langchain_core", + "runnables", + "configurable", + "RunnableConfigurableAlternatives", + ): ( + "langchain_core", + "runnables", + "configurable", + "RunnableConfigurableAlternatives", + ), + ("langchain_core", "runnables", "configurable", "RunnableConfigurableFields"): ( + "langchain_core", + "runnables", + "configurable", + "RunnableConfigurableFields", + ), + ("langchain_core", "runnables", "history", "RunnableWithMessageHistory"): ( + "langchain_core", + "runnables", + "history", + "RunnableWithMessageHistory", + ), + ("langchain_core", "runnables", "passthrough", "RunnableAssign"): ( + "langchain_core", + "runnables", + "passthrough", + "RunnableAssign", + ), + ("langchain_core", "runnables", "retry", "RunnableRetry"): ( + "langchain_core", + "runnables", + "retry", + "RunnableRetry", + ), +} + +_JS_SERIALIZABLE_MAPPING: dict[tuple[str, ...], tuple[str, ...]] = { + ("langchain_core", "messages", "AIMessage"): ( + "langchain_core", + "messages", + "ai", + "AIMessage", + ), + ("langchain_core", "messages", "AIMessageChunk"): ( + "langchain_core", + "messages", + "ai", + "AIMessageChunk", + ), + ("langchain_core", "messages", "BaseMessage"): ( + "langchain_core", + "messages", + "base", + "BaseMessage", + ), + ("langchain_core", "messages", "BaseMessageChunk"): ( + "langchain_core", + "messages", + "base", + "BaseMessageChunk", + ), + ("langchain_core", "messages", "ChatMessage"): ( + "langchain_core", + "messages", + "chat", + "ChatMessage", + ), + ("langchain_core", "messages", "ChatMessageChunk"): ( + "langchain_core", + "messages", + "chat", + "ChatMessageChunk", + ), + ("langchain_core", "messages", "FunctionMessage"): ( + "langchain_core", + "messages", + "function", + "FunctionMessage", + ), + ("langchain_core", "messages", "FunctionMessageChunk"): ( + "langchain_core", + "messages", + "function", + "FunctionMessageChunk", + ), + ("langchain_core", "messages", "HumanMessage"): ( + "langchain_core", + "messages", + "human", + "HumanMessage", + ), + ("langchain_core", "messages", "HumanMessageChunk"): ( + "langchain_core", + "messages", + "human", + "HumanMessageChunk", + ), + ("langchain_core", "messages", "SystemMessage"): ( + "langchain_core", + "messages", + "system", + "SystemMessage", + ), + ("langchain_core", "messages", "SystemMessageChunk"): ( + "langchain_core", + "messages", + "system", + "SystemMessageChunk", + ), + ("langchain_core", "messages", "ToolMessage"): ( + "langchain_core", + "messages", + "tool", + "ToolMessage", + ), + ("langchain_core", "messages", "ToolMessageChunk"): ( + "langchain_core", + "messages", + "tool", + "ToolMessageChunk", + ), + ("langchain_core", "prompts", "image", "ImagePromptTemplate"): ( + "langchain_core", + "prompts", + "image", + "ImagePromptTemplate", + ), + ("langchain", "chat_models", "bedrock", "ChatBedrock"): ( + "langchain_aws", + "chat_models", + "ChatBedrock", + ), + ("langchain", "chat_models", "google_genai", "ChatGoogleGenerativeAI"): ( + "langchain_google_genai", + "chat_models", + "ChatGoogleGenerativeAI", + ), + ("langchain", "chat_models", "groq", "ChatGroq"): ( + "langchain_groq", + "chat_models", + "ChatGroq", + ), + ("langchain", "chat_models", "bedrock", "BedrockChat"): ( + "langchain_aws", + "chat_models", + "ChatBedrock", + ), +} diff --git a/libs/core/langchain_core/load/serializable.py b/libs/core/langchain_core/load/serializable.py new file mode 100644 index 0000000..4764282 --- /dev/null +++ b/libs/core/langchain_core/load/serializable.py @@ -0,0 +1,388 @@ +"""Serializable base class.""" + +import contextlib +import logging +from abc import ABC +from typing import ( + Any, + Literal, + TypedDict, + cast, +) + +from pydantic import BaseModel, ConfigDict +from pydantic.fields import FieldInfo +from typing_extensions import NotRequired, override + +logger = logging.getLogger(__name__) + + +class BaseSerialized(TypedDict): + """Base class for serialized objects.""" + + lc: int + """The version of the serialization format.""" + id: list[str] + """The unique identifier of the object.""" + name: NotRequired[str] + """The name of the object.""" + graph: NotRequired[dict[str, Any]] + """The graph of the object.""" + + +class SerializedConstructor(BaseSerialized): + """Serialized constructor.""" + + type: Literal["constructor"] + """The type of the object. Must be `'constructor'`.""" + kwargs: dict[str, Any] + """The constructor arguments.""" + + +class SerializedSecret(BaseSerialized): + """Serialized secret.""" + + type: Literal["secret"] + """The type of the object. Must be `'secret'`.""" + + +class SerializedNotImplemented(BaseSerialized): + """Serialized not implemented.""" + + type: Literal["not_implemented"] + """The type of the object. Must be `'not_implemented'`.""" + repr: str | None + """The representation of the object.""" + + +def try_neq_default(value: Any, key: str, model: BaseModel) -> bool: + """Try to determine if a value is different from the default. + + Args: + value: The value. + key: The key. + model: The Pydantic model. + + Returns: + Whether the value is different from the default. + """ + field = type(model).model_fields[key] + return _try_neq_default(value, field) + + +def _try_neq_default(value: Any, field: FieldInfo) -> bool: + # Handle edge case: inequality of two objects does not evaluate to a bool (e.g. two + # Pandas DataFrames). + try: + return bool(field.get_default() != value) + except Exception as _: + try: + return all(field.get_default() != value) + except Exception as _: + try: + return value is not field.default + except Exception as _: + return False + + +class Serializable(BaseModel, ABC): + """Serializable base class. + + This class is used to serialize objects to JSON. + + It relies on the following methods and properties: + + - [`is_lc_serializable`][langchain_core.load.serializable.Serializable.is_lc_serializable]: Is this class serializable? + + By design, even if a class inherits from `Serializable`, it is not serializable + by default. This is to prevent accidental serialization of objects that should + not be serialized. + - [`get_lc_namespace`][langchain_core.load.serializable.Serializable.get_lc_namespace]: Get the namespace of the LangChain object. + + During deserialization, this namespace is used to identify + the correct class to instantiate. + + Please see the `Reviver` class in `langchain_core.load.load` for more details. + + During deserialization an additional mapping is handle classes that have moved + or been renamed across package versions. + + - [`lc_secrets`][langchain_core.load.serializable.Serializable.lc_secrets]: A map of constructor argument names to secret ids. + - [`lc_attributes`][langchain_core.load.serializable.Serializable.lc_attributes]: List of additional attribute names that should be included + as part of the serialized representation. + """ # noqa: E501 + + # Remove default BaseModel init docstring. + def __init__(self, *args: Any, **kwargs: Any) -> None: + """""" # noqa: D419 # Intentional blank docstring + super().__init__(*args, **kwargs) + + @classmethod + def is_lc_serializable(cls) -> bool: + """Is this class serializable? + + By design, even if a class inherits from `Serializable`, it is not serializable + by default. This is to prevent accidental serialization of objects that should + not be serialized. + + Returns: + Whether the class is serializable. Default is `False`. + """ + return False + + @classmethod + def get_lc_namespace(cls) -> list[str]: + """Get the namespace of the LangChain object. + + The default implementation splits `cls.__module__` on `'.'`, e.g. + `langchain_openai.chat_models` becomes + `["langchain_openai", "chat_models"]`. This value is used by `lc_id` to + build the serialization identifier. + + New partner packages should **not** override this method. The default + behavior is correct for any class whose module path already reflects + its package name. Some older packages (e.g. `langchain-openai`, + `langchain-anthropic`) override it to return a legacy-style namespace + like `["langchain", "chat_models", "openai"]`, matching the module + paths that existed before those integrations were split out of the + main `langchain` package. Those overrides are kept for + backwards-compatible deserialization; new packages should not copy them. + + Deserialization mapping is handled separately by + `SERIALIZABLE_MAPPING` in `langchain_core.load.mapping`. + + Returns: + The namespace. + """ + return cls.__module__.split(".") + + @property + def lc_secrets(self) -> dict[str, str]: + """A map of constructor argument names to secret ids. + + For example, `{"openai_api_key": "OPENAI_API_KEY"}` + """ + return {} + + @property + def lc_attributes(self) -> dict[str, Any]: + """List of attribute names that should be included in the serialized kwargs. + + These attributes must be accepted by the constructor. + + Default is an empty dictionary. + """ + return {} + + @classmethod + def lc_id(cls) -> list[str]: + """Return a unique identifier for this class for serialization purposes. + + The unique identifier is a list of strings that describes the path + to the object. + + For example, for the class `langchain.llms.openai.OpenAI`, the id is + `["langchain", "llms", "openai", "OpenAI"]`. + """ + # Pydantic generics change the class name. So we need to do the following + if ( + "origin" in cls.__pydantic_generic_metadata__ + and cls.__pydantic_generic_metadata__["origin"] is not None + ): + original_name = cls.__pydantic_generic_metadata__["origin"].__name__ + else: + original_name = cls.__name__ + return [*cls.get_lc_namespace(), original_name] + + model_config = ConfigDict( + extra="ignore", + ) + + @override + def __repr_args__(self) -> Any: + return [ + (k, v) + for k, v in super().__repr_args__() + if (k not in type(self).model_fields or try_neq_default(v, k, self)) + ] + + def to_json(self) -> SerializedConstructor | SerializedNotImplemented: + """Serialize the object to JSON. + + Raises: + ValueError: If the class has deprecated attributes. + + Returns: + A JSON serializable object or a `SerializedNotImplemented` object. + """ + if not self.is_lc_serializable(): + return self.to_json_not_implemented() + + model_fields = type(self).model_fields + secrets = {} + # Get latest values for kwargs if there is an attribute with same name + lc_kwargs = {} + for k, v in self: + if not _is_field_useful(self, k, v): + continue + # Do nothing if the field is excluded + if k in model_fields and model_fields[k].exclude: + continue + + lc_kwargs[k] = getattr(self, k, v) + + # Merge the lc_secrets and lc_attributes from every class in the MRO + for cls in [None, *self.__class__.mro()]: + # Once we get to Serializable, we're done + if cls is Serializable: + break + + if cls: + deprecated_attributes = [ + "lc_namespace", + "lc_serializable", + ] + + for attr in deprecated_attributes: + if hasattr(cls, attr): + msg = ( + f"Class {self.__class__} has a deprecated " + f"attribute {attr}. Please use the corresponding " + f"classmethod instead." + ) + raise ValueError(msg) + + # Get a reference to self bound to each class in the MRO + this = cast("Serializable", self if cls is None else super(cls, self)) + + secrets.update(this.lc_secrets) + # Now also add the aliases for the secrets + # This ensures known secret aliases are hidden. + # Note: this does NOT hide any other extra kwargs + # that are not present in the fields. + for key in list(secrets): + value = secrets[key] + if (key in model_fields) and ( + alias := model_fields[key].alias + ) is not None: + secrets[alias] = value + lc_kwargs.update(this.lc_attributes) + + # include all secrets, even if not specified in kwargs + # as these secrets may be passed as an environment variable instead + for key in secrets: + secret_value = getattr(self, key, None) or lc_kwargs.get(key) + if secret_value is not None: + lc_kwargs.update({key: secret_value}) + + return { + "lc": 1, + "type": "constructor", + "id": self.lc_id(), + "kwargs": lc_kwargs + if not secrets + else _replace_secrets(lc_kwargs, secrets), + } + + def to_json_not_implemented(self) -> SerializedNotImplemented: + """Serialize a "not implemented" object. + + Returns: + `SerializedNotImplemented`. + """ + return to_json_not_implemented(self) + + +def _is_field_useful(inst: Serializable, key: str, value: Any) -> bool: + """Check if a field is useful as a constructor argument. + + Args: + inst: The instance. + key: The key. + value: The value. + + Returns: + Whether the field is useful. If the field is required, it is useful. + If the field is not required, it is useful if the value is not `None`. + If the field is not required and the value is `None`, it is useful if the + default value is different from the value. + """ + field = type(inst).model_fields.get(key) + if not field: + return False + + if field.is_required(): + return True + + # Handle edge case: a value cannot be converted to a boolean (e.g. a + # Pandas DataFrame). + try: + value_is_truthy = bool(value) + except Exception as _: + value_is_truthy = False + + if value_is_truthy: + return True + + # Value is still falsy here! + if field.default_factory is dict and isinstance(value, dict): + return False + + # Value is still falsy here! + if field.default_factory is list and isinstance(value, list): + return False + + value_neq_default = _try_neq_default(value, field) + + # If value is falsy and does not match the default + return value_is_truthy or value_neq_default + + +def _replace_secrets( + root: dict[Any, Any], secrets_map: dict[str, str] +) -> dict[Any, Any]: + result = root.copy() + for path, secret_id in secrets_map.items(): + [*parts, last] = path.split(".") + current = result + for part in parts: + if part not in current: + break + current[part] = current[part].copy() + current = current[part] + if last in current: + current[last] = { + "lc": 1, + "type": "secret", + "id": [secret_id], + } + return result + + +def to_json_not_implemented(obj: object) -> SerializedNotImplemented: + """Serialize a "not implemented" object. + + Args: + obj: Object to serialize. + + Returns: + `SerializedNotImplemented` + """ + id_: list[str] = [] + try: + if hasattr(obj, "__name__"): + id_ = [*obj.__module__.split("."), obj.__name__] + elif hasattr(obj, "__class__"): + id_ = [*obj.__class__.__module__.split("."), obj.__class__.__name__] + except Exception: + logger.debug("Failed to serialize object", exc_info=True) + + result: SerializedNotImplemented = { + "lc": 1, + "type": "not_implemented", + "id": id_, + "repr": None, + } + with contextlib.suppress(Exception): + result["repr"] = repr(obj) + return result diff --git a/libs/core/langchain_core/messages/__init__.py b/libs/core/langchain_core/messages/__init__.py new file mode 100644 index 0000000..97171f5 --- /dev/null +++ b/libs/core/langchain_core/messages/__init__.py @@ -0,0 +1,198 @@ +"""**Messages** are objects used in prompts and chat conversations.""" + +from typing import TYPE_CHECKING + +from langchain_core._import_utils import import_attr +from langchain_core.utils.utils import LC_AUTO_PREFIX, LC_ID_PREFIX, ensure_id + +if TYPE_CHECKING: + from langchain_core.messages.ai import ( + AIMessage, + AIMessageChunk, + InputTokenDetails, + OutputTokenDetails, + UsageMetadata, + ) + from langchain_core.messages.base import ( + BaseMessage, + BaseMessageChunk, + merge_content, + message_to_dict, + messages_to_dict, + ) + from langchain_core.messages.block_translators.openai import ( + convert_to_openai_data_block, + convert_to_openai_image_block, + ) + from langchain_core.messages.chat import ChatMessage, ChatMessageChunk + from langchain_core.messages.content import ( + Annotation, + AudioContentBlock, + Citation, + ContentBlock, + DataContentBlock, + FileContentBlock, + ImageContentBlock, + InvalidToolCall, + NonStandardAnnotation, + NonStandardContentBlock, + PlainTextContentBlock, + ReasoningContentBlock, + ServerToolCall, + ServerToolCallChunk, + ServerToolResult, + TextContentBlock, + VideoContentBlock, + is_data_content_block, + ) + from langchain_core.messages.function import FunctionMessage, FunctionMessageChunk + from langchain_core.messages.human import HumanMessage, HumanMessageChunk + from langchain_core.messages.modifier import RemoveMessage + from langchain_core.messages.system import SystemMessage, SystemMessageChunk + from langchain_core.messages.tool import ( + ToolCall, + ToolCallChunk, + ToolMessage, + ToolMessageChunk, + ) + from langchain_core.messages.utils import ( + AnyMessage, + MessageLikeRepresentation, + _message_from_dict, + convert_to_messages, + convert_to_openai_messages, + filter_messages, + get_buffer_string, + merge_message_runs, + message_chunk_to_message, + messages_from_dict, + trim_messages, + ) + +__all__ = ( + "LC_AUTO_PREFIX", + "LC_ID_PREFIX", + "AIMessage", + "AIMessageChunk", + "Annotation", + "AnyMessage", + "AudioContentBlock", + "BaseMessage", + "BaseMessageChunk", + "ChatMessage", + "ChatMessageChunk", + "Citation", + "ContentBlock", + "DataContentBlock", + "FileContentBlock", + "FunctionMessage", + "FunctionMessageChunk", + "HumanMessage", + "HumanMessageChunk", + "ImageContentBlock", + "InputTokenDetails", + "InvalidToolCall", + "MessageLikeRepresentation", + "NonStandardAnnotation", + "NonStandardContentBlock", + "OutputTokenDetails", + "PlainTextContentBlock", + "ReasoningContentBlock", + "RemoveMessage", + "ServerToolCall", + "ServerToolCallChunk", + "ServerToolResult", + "SystemMessage", + "SystemMessageChunk", + "TextContentBlock", + "ToolCall", + "ToolCallChunk", + "ToolMessage", + "ToolMessageChunk", + "UsageMetadata", + "VideoContentBlock", + "_message_from_dict", + "convert_to_messages", + "convert_to_openai_data_block", + "convert_to_openai_image_block", + "convert_to_openai_messages", + "ensure_id", + "filter_messages", + "get_buffer_string", + "is_data_content_block", + "merge_content", + "merge_message_runs", + "message_chunk_to_message", + "message_to_dict", + "messages_from_dict", + "messages_to_dict", + "trim_messages", +) + +_dynamic_imports = { + "AIMessage": "ai", + "AIMessageChunk": "ai", + "Annotation": "content", + "AudioContentBlock": "content", + "BaseMessage": "base", + "BaseMessageChunk": "base", + "merge_content": "base", + "message_to_dict": "base", + "messages_to_dict": "base", + "Citation": "content", + "ContentBlock": "content", + "ChatMessage": "chat", + "ChatMessageChunk": "chat", + "DataContentBlock": "content", + "FileContentBlock": "content", + "FunctionMessage": "function", + "FunctionMessageChunk": "function", + "HumanMessage": "human", + "HumanMessageChunk": "human", + "NonStandardAnnotation": "content", + "NonStandardContentBlock": "content", + "OutputTokenDetails": "ai", + "PlainTextContentBlock": "content", + "ReasoningContentBlock": "content", + "RemoveMessage": "modifier", + "ServerToolCall": "content", + "ServerToolCallChunk": "content", + "ServerToolResult": "content", + "SystemMessage": "system", + "SystemMessageChunk": "system", + "ImageContentBlock": "content", + "InputTokenDetails": "ai", + "InvalidToolCall": "tool", + "TextContentBlock": "content", + "ToolCall": "tool", + "ToolCallChunk": "tool", + "ToolMessage": "tool", + "ToolMessageChunk": "tool", + "UsageMetadata": "ai", + "VideoContentBlock": "content", + "AnyMessage": "utils", + "MessageLikeRepresentation": "utils", + "_message_from_dict": "utils", + "convert_to_messages": "utils", + "convert_to_openai_data_block": "block_translators.openai", + "convert_to_openai_image_block": "block_translators.openai", + "convert_to_openai_messages": "utils", + "filter_messages": "utils", + "get_buffer_string": "utils", + "is_data_content_block": "content", + "merge_message_runs": "utils", + "message_chunk_to_message": "utils", + "messages_from_dict": "utils", + "trim_messages": "utils", +} + + +def __getattr__(attr_name: str) -> object: + module_name = _dynamic_imports.get(attr_name) + result = import_attr(attr_name, module_name, __spec__.parent) + globals()[attr_name] = result + return result + + +def __dir__() -> list[str]: + return list(__all__) diff --git a/libs/core/langchain_core/messages/ai.py b/libs/core/langchain_core/messages/ai.py new file mode 100644 index 0000000..213f0f1 --- /dev/null +++ b/libs/core/langchain_core/messages/ai.py @@ -0,0 +1,854 @@ +"""AI message.""" + +import itertools +import json +import logging +import operator +from collections.abc import Sequence +from typing import Any, Literal, cast, overload + +from pydantic import Field, model_validator +from typing_extensions import NotRequired, Self, TypedDict, override + +from langchain_core.messages import content as types +from langchain_core.messages.base import ( + BaseMessage, + BaseMessageChunk, + _extract_reasoning_from_additional_kwargs, + merge_content, +) +from langchain_core.messages.content import InvalidToolCall +from langchain_core.messages.tool import ( + ToolCall, + ToolCallChunk, + default_tool_chunk_parser, + default_tool_parser, +) +from langchain_core.messages.tool import invalid_tool_call as create_invalid_tool_call +from langchain_core.messages.tool import tool_call as create_tool_call +from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk +from langchain_core.utils._merge import merge_dicts, merge_lists +from langchain_core.utils.json import parse_partial_json +from langchain_core.utils.usage import _dict_int_op +from langchain_core.utils.utils import LC_AUTO_PREFIX, LC_ID_PREFIX + +logger = logging.getLogger(__name__) + + +class InputTokenDetails(TypedDict, total=False): + """Breakdown of input token counts. + + Does *not* need to sum to full input token count. Does *not* need to have all keys. + + Example: + ```python + { + "audio": 10, + "cache_creation": 200, + "cache_read": 100, + } + ``` + + May also hold extra provider-specific keys. + + !!! version-added "Added in `langchain-core` 0.3.9" + """ + + audio: int + """Audio input tokens.""" + + cache_creation: int + """Input tokens that were cached and there was a cache miss. + + Since there was a cache miss, the cache was created from these tokens. + """ + + cache_read: int + """Input tokens that were cached and there was a cache hit. + + Since there was a cache hit, the tokens were read from the cache. More precisely, + the model state given these tokens was read from the cache. + """ + + +class OutputTokenDetails(TypedDict, total=False): + """Breakdown of output token counts. + + Does *not* need to sum to full output token count. Does *not* need to have all keys. + + Example: + ```python + { + "audio": 10, + "reasoning": 200, + } + ``` + + May also hold extra provider-specific keys. + + !!! version-added "Added in `langchain-core` 0.3.9" + + """ + + audio: int + """Audio output tokens.""" + + reasoning: int + """Reasoning output tokens. + + Tokens generated by the model in a chain of thought process that are not + returned as part of model output. + """ + + +class UsageMetadata(TypedDict): + """Usage metadata for a message, such as token counts. + + This is a standard representation of token usage that is consistent across models. + + Example: + ```python + { + "input_tokens": 350, + "output_tokens": 240, + "total_tokens": 590, + "input_token_details": { + "audio": 10, + "cache_creation": 200, + "cache_read": 100, + }, + "output_token_details": { + "audio": 10, + "reasoning": 200, + }, + } + ``` + + !!! warning "Behavior changed in `langchain-core` 0.3.9" + + Added `input_token_details` and `output_token_details`. + + !!! note "LangSmith SDK" + + The LangSmith SDK also has a `UsageMetadata` class. While the two share fields, + LangSmith's `UsageMetadata` has additional fields to capture cost information + used by the LangSmith platform. + """ + + input_tokens: int + """Count of input (or prompt) tokens. Sum of all input token types.""" + + output_tokens: int + """Count of output (or completion) tokens. Sum of all output token types.""" + + total_tokens: int + """Total token count. Sum of `input_tokens` + `output_tokens`.""" + + input_token_details: NotRequired[InputTokenDetails] + """Breakdown of input token counts. + + Does *not* need to sum to full input token count. Does *not* need to have all keys. + """ + + output_token_details: NotRequired[OutputTokenDetails] + """Breakdown of output token counts. + + Does *not* need to sum to full output token count. Does *not* need to have all keys. + """ + + +class AIMessage(BaseMessage): + """Message from an AI. + + An `AIMessage` is returned from a chat model as a response to a prompt. + + This message represents the output of the model and consists of both + the raw output as returned by the model and standardized fields + (e.g., tool calls, usage metadata) added by the LangChain framework. + """ + + tool_calls: list[ToolCall] = Field(default_factory=list) + """If present, tool calls associated with the message.""" + + invalid_tool_calls: list[InvalidToolCall] = Field(default_factory=list) + """If present, tool calls with parsing errors associated with the message.""" + + usage_metadata: UsageMetadata | None = None + """If present, usage metadata for a message, such as token counts. + + This is a standard representation of token usage that is consistent across models. + """ + + type: Literal["ai"] = "ai" + """The type of the message (used for deserialization).""" + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]], + **kwargs: Any, + ) -> None: ... + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: ... + + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: + """Initialize an `AIMessage`. + + Specify `content` as positional arg or `content_blocks` for typing. + + Args: + content: The content of the message. + content_blocks: Typed standard content. + **kwargs: Additional arguments to pass to the parent class. + """ + if content_blocks is not None: + # If there are tool calls in content_blocks, but not in tool_calls, add them + content_tool_calls = [ + block for block in content_blocks if block.get("type") == "tool_call" + ] + if content_tool_calls and "tool_calls" not in kwargs: + kwargs["tool_calls"] = content_tool_calls + + super().__init__( + content=cast("list[str | dict[Any, Any]]", content_blocks), + **kwargs, + ) + else: + super().__init__(content=content, **kwargs) + + @property + def lc_attributes(self) -> dict[str, Any]: + """Attributes to be serialized. + + Includes all attributes, even if they are derived from other initialization + arguments. + """ + return { + "tool_calls": self.tool_calls, + "invalid_tool_calls": self.invalid_tool_calls, + } + + @property + def content_blocks(self) -> list[types.ContentBlock]: + """Return standard, typed `ContentBlock` dicts from the message. + + If the message has a known model provider, use the provider-specific translator + first before falling back to best-effort parsing. For details, see the property + on `BaseMessage`. + """ + if self.response_metadata.get("output_version") == "v1" and isinstance( + self.content, list + ): + # Only short-circuit when content is a list (assumed under v1 to + # already hold ContentBlock dicts; the cast is unchecked). See + # AIMessageChunk.content_blocks for full rationale. + return cast("list[types.ContentBlock]", self.content) + + model_provider = self.response_metadata.get("model_provider") + if model_provider: + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + get_translator, + ) + + translator = get_translator(model_provider) + if translator: + try: + return translator["translate_content"](self) + except NotImplementedError: + pass + + # Otherwise, use best-effort parsing + blocks = super().content_blocks + + if self.tool_calls: + # Add from tool_calls if missing from content + content_tool_call_ids = { + block.get("id") + for block in self.content + if isinstance(block, dict) and block.get("type") == "tool_call" + } + for tool_call in self.tool_calls: + if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids: + tool_call_block: types.ToolCall = { + "type": "tool_call", + "id": id_, + "name": tool_call["name"], + "args": tool_call["args"], + } + if "index" in tool_call: + tool_call_block["index"] = tool_call["index"] # type: ignore[typeddict-item] + if "extras" in tool_call: + tool_call_block["extras"] = tool_call["extras"] # type: ignore[typeddict-item] + blocks.append(tool_call_block) + + # Best-effort reasoning extraction from additional_kwargs + # Only add reasoning if not already present + # Insert before all other blocks to keep reasoning at the start + has_reasoning = any(block.get("type") == "reasoning" for block in blocks) + if not has_reasoning and ( + reasoning_block := _extract_reasoning_from_additional_kwargs(self) + ): + blocks.insert(0, reasoning_block) + + return blocks + + # TODO: remove this logic if possible, reducing breaking nature of changes + @model_validator(mode="before") + @classmethod + def _backwards_compat_tool_calls(cls, values: dict[str, Any]) -> Any: + check_additional_kwargs = not any( + values.get(k) + for k in ("tool_calls", "invalid_tool_calls", "tool_call_chunks") + ) + if check_additional_kwargs and ( + raw_tool_calls := values.get("additional_kwargs", {}).get("tool_calls") + ): + try: + if issubclass(cls, AIMessageChunk): + values["tool_call_chunks"] = default_tool_chunk_parser( + raw_tool_calls + ) + else: + parsed_tool_calls, parsed_invalid_tool_calls = default_tool_parser( + raw_tool_calls + ) + values["tool_calls"] = parsed_tool_calls + values["invalid_tool_calls"] = parsed_invalid_tool_calls + except Exception: + logger.debug("Failed to parse tool calls", exc_info=True) + + # Ensure "type" is properly set on all tool call-like dicts. + if tool_calls := values.get("tool_calls"): + values["tool_calls"] = [ + create_tool_call( + **{k: v for k, v in tc.items() if k not in {"type", "extras"}} + ) + for tc in tool_calls + ] + if invalid_tool_calls := values.get("invalid_tool_calls"): + values["invalid_tool_calls"] = [ + create_invalid_tool_call(**{k: v for k, v in tc.items() if k != "type"}) + for tc in invalid_tool_calls + ] + + if tool_call_chunks := values.get("tool_call_chunks"): + values["tool_call_chunks"] = [ + create_tool_call_chunk(**{k: v for k, v in tc.items() if k != "type"}) + for tc in tool_call_chunks + ] + + return values + + @override + def pretty_repr(self, html: bool = False) -> str: + """Return a pretty representation of the message for display. + + Args: + html: Whether to return an HTML-formatted string. + + Returns: + A pretty representation of the message. + + Example: + ```python + from langchain_core.messages import AIMessage + + msg = AIMessage( + content="Let me check the weather.", + tool_calls=[ + {"name": "get_weather", "args": {"city": "Paris"}, "id": "1"} + ], + ) + ``` + + Results in: + ```python + >>> print(msg.pretty_repr()) + ================================== Ai Message ================================== + + Let me check the weather. + Tool Calls: + get_weather (1) + Call ID: 1 + Args: + city: Paris + ``` + """ # noqa: E501 + base = super().pretty_repr(html=html) + lines = [] + + def _format_tool_args(tc: ToolCall | InvalidToolCall) -> list[str]: + lines = [ + f" {tc.get('name', 'Tool')} ({tc.get('id')})", + f" Call ID: {tc.get('id')}", + ] + if tc.get("error"): + lines.append(f" Error: {tc.get('error')}") + lines.append(" Args:") + args = tc.get("args") + if isinstance(args, str): + lines.append(f" {args}") + elif isinstance(args, dict): + for arg, value in args.items(): + lines.append(f" {arg}: {value}") + return lines + + if self.tool_calls: + lines.append("Tool Calls:") + for tc in self.tool_calls: + lines.extend(_format_tool_args(tc)) + if self.invalid_tool_calls: + lines.append("Invalid Tool Calls:") + for itc in self.invalid_tool_calls: + lines.extend(_format_tool_args(itc)) + return (base.strip() + "\n" + "\n".join(lines)).strip() + + +class AIMessageChunk(AIMessage, BaseMessageChunk): + """Message chunk from an AI (yielded when streaming).""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["AIMessageChunk"] = "AIMessageChunk" # type: ignore[assignment] + """The type of the message (used for deserialization).""" + + tool_call_chunks: list[ToolCallChunk] = Field(default_factory=list) + """If provided, tool call chunks associated with the message.""" + + chunk_position: Literal["last"] | None = None + """Optional span represented by an aggregated `AIMessageChunk`. + + If a chunk with `chunk_position="last"` is aggregated into a stream, + `tool_call_chunks` in message content will be parsed into `tool_calls`. + """ + + @property + @override + def lc_attributes(self) -> dict[str, Any]: + return { + "tool_calls": self.tool_calls, + "invalid_tool_calls": self.invalid_tool_calls, + } + + @property + def content_blocks(self) -> list[types.ContentBlock]: + """Return standard, typed `ContentBlock` dicts from the message.""" + if self.response_metadata.get("output_version") == "v1" and isinstance( + self.content, list + ): + # Only short-circuit when content is already a list of ContentBlock + # dicts. Some streaming implementations keep content as a string + # even when output_version="v1" is set (e.g., OpenAI Chat + # Completions), so it must fall through to the model_provider + # translator which builds ContentBlock dicts from tool_calls / + # tool_call_chunks. Without this guard, string content would be + # returned directly, silently dropping tool calls. + return cast("list[types.ContentBlock]", self.content) + + model_provider = self.response_metadata.get("model_provider") + if model_provider: + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + get_translator, + ) + + translator = get_translator(model_provider) + if translator: + try: + return translator["translate_content_chunk"](self) + except NotImplementedError: + pass + + # Otherwise, use best-effort parsing + blocks = super().content_blocks + + if ( + self.tool_call_chunks + and not self.content + and self.chunk_position != "last" # keep tool_calls if aggregated + ): + blocks = [ + block + for block in blocks + if block["type"] not in {"tool_call", "invalid_tool_call"} + ] + for tool_call_chunk in self.tool_call_chunks: + tc: types.ToolCallChunk = { + "type": "tool_call_chunk", + "id": tool_call_chunk.get("id"), + "name": tool_call_chunk.get("name"), + "args": tool_call_chunk.get("args"), + } + if (idx := tool_call_chunk.get("index")) is not None: + tc["index"] = idx + blocks.append(tc) + + # Best-effort reasoning extraction from additional_kwargs + # Only add reasoning if not already present + # Insert before all other blocks to keep reasoning at the start + has_reasoning = any(block.get("type") == "reasoning" for block in blocks) + if not has_reasoning and ( + reasoning_block := _extract_reasoning_from_additional_kwargs(self) + ): + blocks.insert(0, reasoning_block) + + return blocks + + @model_validator(mode="after") + def init_tool_calls(self) -> Self: + """Initialize tool calls from tool call chunks. + + Returns: + The values with tool calls initialized. + + Raises: + ValueError: If the tool call chunks are malformed. + """ + if not self.tool_call_chunks: + if self.tool_calls: + self.tool_call_chunks = [ + create_tool_call_chunk( + name=tc["name"], + args=json.dumps(tc["args"]), + id=tc["id"], + index=None, + ) + for tc in self.tool_calls + ] + if self.invalid_tool_calls: + tool_call_chunks = self.tool_call_chunks + tool_call_chunks.extend( + [ + create_tool_call_chunk( + name=tc["name"], args=tc["args"], id=tc["id"], index=None + ) + for tc in self.invalid_tool_calls + ] + ) + self.tool_call_chunks = tool_call_chunks + + return self + tool_calls = [] + invalid_tool_calls = [] + + def add_chunk_to_invalid_tool_calls(chunk: ToolCallChunk) -> None: + invalid_tool_calls.append( + create_invalid_tool_call( + name=chunk["name"], + args=chunk["args"], + id=chunk["id"], + error=None, + ) + ) + + for chunk in self.tool_call_chunks: + try: + args_ = parse_partial_json(chunk["args"]) if chunk["args"] else {} + if isinstance(args_, dict): + tool_calls.append( + create_tool_call( + name=chunk["name"] or "", + args=args_, + id=chunk["id"], + ) + ) + else: + add_chunk_to_invalid_tool_calls(chunk) + except Exception: + add_chunk_to_invalid_tool_calls(chunk) + self.tool_calls = tool_calls + self.invalid_tool_calls = invalid_tool_calls + + if ( + self.chunk_position == "last" + and self.tool_call_chunks + and self.response_metadata.get("output_version") == "v1" + and isinstance(self.content, list) + ): + id_to_tc: dict[str, types.ToolCall] = { + cast("str", tc.get("id")): { + "type": "tool_call", + "name": tc["name"], + "args": tc["args"], + "id": tc.get("id"), + } + for tc in self.tool_calls + if "id" in tc + } + for idx, block in enumerate(self.content): + if ( + isinstance(block, dict) + and block.get("type") == "tool_call_chunk" + and (call_id := block.get("id")) + and call_id in id_to_tc + ): + self.content[idx] = cast("dict[str, Any]", id_to_tc[call_id]) + if "extras" in block: + # mypy does not account for instance check for dict above + self.content[idx]["extras"] = block["extras"] # type: ignore[index] + + return self + + @model_validator(mode="after") + def init_server_tool_calls(self) -> Self: + """Initialize server tool calls. + + Parse `server_tool_call_chunks` from + [`ServerToolCallChunk`][langchain.messages.ServerToolCallChunk] objects. + """ + if ( + self.chunk_position == "last" + and self.response_metadata.get("output_version") == "v1" + and isinstance(self.content, list) + ): + for idx, block in enumerate(self.content): + if ( + isinstance(block, dict) + and block.get("type") + in {"server_tool_call", "server_tool_call_chunk"} + and (args_str := block.get("args")) + and isinstance(args_str, str) + ): + try: + args = json.loads(args_str) + if isinstance(args, dict): + self.content[idx]["type"] = "server_tool_call" # type: ignore[index] + self.content[idx]["args"] = args # type: ignore[index] + except json.JSONDecodeError: + pass + return self + + @overload # type: ignore[override] # summing BaseMessages gives ChatPromptTemplate + def __add__(self, other: "AIMessageChunk") -> "AIMessageChunk": ... + + @overload + def __add__(self, other: Sequence["AIMessageChunk"]) -> "AIMessageChunk": ... + + @overload + def __add__(self, other: Any) -> BaseMessageChunk: ... + + @override + def __add__(self, other: Any) -> BaseMessageChunk: + if isinstance(other, AIMessageChunk): + return add_ai_message_chunks(self, other) + if isinstance(other, (list, tuple)) and all( + isinstance(o, AIMessageChunk) for o in other + ): + return add_ai_message_chunks(self, *other) + return super().__add__(other) + + +def add_ai_message_chunks( + left: AIMessageChunk, *others: AIMessageChunk +) -> AIMessageChunk: + """Add multiple `AIMessageChunk`s together. + + Args: + left: The first `AIMessageChunk`. + *others: Other `AIMessageChunk`s to add. + + Returns: + The resulting `AIMessageChunk`. + + """ + content = merge_content(left.content, *(o.content for o in others)) + additional_kwargs = merge_dicts( + left.additional_kwargs, *(o.additional_kwargs for o in others) + ) + response_metadata = merge_dicts( + left.response_metadata, *(o.response_metadata for o in others) + ) + + # Merge tool call chunks + if raw_tool_calls := merge_lists( + left.tool_call_chunks, *(o.tool_call_chunks for o in others) + ): + tool_call_chunks = [ + create_tool_call_chunk( + name=rtc.get("name"), + args=rtc.get("args"), + index=rtc.get("index"), + id=rtc.get("id"), + ) + for rtc in raw_tool_calls + ] + else: + tool_call_chunks = [] + + # Token usage + if left.usage_metadata or any(o.usage_metadata is not None for o in others): + usage_metadata: UsageMetadata | None = left.usage_metadata + for other in others: + usage_metadata = add_usage(usage_metadata, other.usage_metadata) + else: + usage_metadata = None + + # Ranks are defined by the order of preference. Higher is better: + # 2. Provider-assigned IDs (non lc_* and non lc_run-*) + # 1. lc_run-* IDs + # 0. lc_* and other remaining IDs + best_rank = -1 + chunk_id = None + candidates = itertools.chain([left.id], (o.id for o in others)) + + for id_ in candidates: + if not id_: + continue + + if not id_.startswith(LC_ID_PREFIX) and not id_.startswith(LC_AUTO_PREFIX): + chunk_id = id_ + # Highest rank, return instantly + break + + rank = 1 if id_.startswith(LC_ID_PREFIX) else 0 + + if rank > best_rank: + best_rank = rank + chunk_id = id_ + + chunk_position: Literal["last"] | None = ( + "last" if any(x.chunk_position == "last" for x in [left, *others]) else None + ) + + return left.__class__( + content=content, + additional_kwargs=additional_kwargs, + tool_call_chunks=tool_call_chunks, + response_metadata=response_metadata, + usage_metadata=usage_metadata, + id=chunk_id, + chunk_position=chunk_position, + ) + + +def add_usage(left: UsageMetadata | None, right: UsageMetadata | None) -> UsageMetadata: + """Recursively add two UsageMetadata objects. + + Example: + ```python + from langchain_core.messages.ai import add_usage + + left = UsageMetadata( + input_tokens=5, + output_tokens=0, + total_tokens=5, + input_token_details=InputTokenDetails(cache_read=3), + ) + right = UsageMetadata( + input_tokens=0, + output_tokens=10, + total_tokens=10, + output_token_details=OutputTokenDetails(reasoning=4), + ) + + add_usage(left, right) + ``` + + results in + + ```python + UsageMetadata( + input_tokens=5, + output_tokens=10, + total_tokens=15, + input_token_details=InputTokenDetails(cache_read=3), + output_token_details=OutputTokenDetails(reasoning=4), + ) + ``` + Args: + left: The first `UsageMetadata` object. + right: The second `UsageMetadata` object. + + Returns: + The sum of the two `UsageMetadata` objects. + + """ + if not (left or right): + return UsageMetadata(input_tokens=0, output_tokens=0, total_tokens=0) + if not (left and right): + return cast("UsageMetadata", left or right) + + return UsageMetadata( + **cast( + "UsageMetadata", + _dict_int_op( + cast("dict[str, Any]", left), + cast("dict[str, Any]", right), + operator.add, + ), + ) + ) + + +def subtract_usage( + left: UsageMetadata | None, right: UsageMetadata | None +) -> UsageMetadata: + """Recursively subtract two `UsageMetadata` objects. + + Token counts cannot be negative so the actual operation is `max(left - right, 0)`. + + Example: + ```python + from langchain_core.messages.ai import subtract_usage + + left = UsageMetadata( + input_tokens=5, + output_tokens=10, + total_tokens=15, + input_token_details=InputTokenDetails(cache_read=4), + ) + right = UsageMetadata( + input_tokens=3, + output_tokens=8, + total_tokens=11, + output_token_details=OutputTokenDetails(reasoning=4), + ) + + subtract_usage(left, right) + ``` + + results in + + ```python + UsageMetadata( + input_tokens=2, + output_tokens=2, + total_tokens=4, + input_token_details=InputTokenDetails(cache_read=4), + output_token_details=OutputTokenDetails(reasoning=0), + ) + ``` + Args: + left: The first `UsageMetadata` object. + right: The second `UsageMetadata` object. + + Returns: + The resulting `UsageMetadata` after subtraction. + + """ + if not (left or right): + return UsageMetadata(input_tokens=0, output_tokens=0, total_tokens=0) + if not (left and right): + return cast("UsageMetadata", left or right) + + return UsageMetadata( + **cast( + "UsageMetadata", + _dict_int_op( + cast("dict[str, Any]", left), + cast("dict[str, Any]", right), + (lambda le, ri: max(le - ri, 0)), + ), + ) + ) diff --git a/libs/core/langchain_core/messages/base.py b/libs/core/langchain_core/messages/base.py new file mode 100644 index 0000000..21760e2 --- /dev/null +++ b/libs/core/langchain_core/messages/base.py @@ -0,0 +1,518 @@ +"""Base message.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast, overload + +from pydantic import ConfigDict, Field + +from langchain_core._api.deprecation import warn_deprecated +from langchain_core.load.serializable import Serializable +from langchain_core.messages import content as types +from langchain_core.utils import get_bolded_text +from langchain_core.utils._merge import merge_dicts, merge_lists +from langchain_core.utils.interactive_env import is_interactive_env + +if TYPE_CHECKING: + from collections.abc import Sequence + + from typing_extensions import Self + + from langchain_core.prompts.chat import ChatPromptTemplate + + +def _extract_reasoning_from_additional_kwargs( + message: BaseMessage, +) -> types.ReasoningContentBlock | None: + """Extract `reasoning_content` from `additional_kwargs`. + + Handles reasoning content stored in various formats: + - `additional_kwargs["reasoning_content"]` (string) - Ollama, DeepSeek, XAI, Groq + + Args: + message: The message to extract reasoning from. + + Returns: + A `ReasoningContentBlock` if reasoning content is found, None otherwise. + """ + additional_kwargs = getattr(message, "additional_kwargs", {}) + + reasoning_content = additional_kwargs.get("reasoning_content") + if reasoning_content is not None and isinstance(reasoning_content, str): + return {"type": "reasoning", "reasoning": reasoning_content} + + return None + + +class TextAccessor(str): + """String-like object that supports both property and method access patterns. + + Exists to maintain backward compatibility while transitioning from method-based to + property-based text access in message objects. In LangChain Self: + """Create new TextAccessor instance.""" + return str.__new__(cls, value) + + def __call__(self) -> str: + """Enable method-style text access for backward compatibility. + + This method exists solely to support legacy code that calls `.text()` + as a method. New code should use property access (`.text`) instead. + + !!! deprecated + As of `langchain-core` 1.0.0, calling `.text()` as a method is deprecated. + Use `.text` as a property instead. This method will be removed in 2.0.0. + + Returns: + The string content, identical to property access. + + """ + warn_deprecated( + since="1.0.0", + message=( + "Calling .text() as a method is deprecated. " + "Use .text as a property instead (e.g., message.text)." + ), + removal="2.0.0", + ) + return str(self) + + +class BaseMessage(Serializable): + """Base abstract message class. + + Messages are the inputs and outputs of a chat model. + + Examples include [`HumanMessage`][langchain.messages.HumanMessage], + [`AIMessage`][langchain.messages.AIMessage], and + [`SystemMessage`][langchain.messages.SystemMessage]. + """ + + content: str | list[str | dict[Any, Any]] + """The contents of the message.""" + + additional_kwargs: dict[Any, Any] = Field(default_factory=dict) + """Reserved for additional payload data associated with the message. + + For example, for a message from an AI, this could include tool calls as + encoded by the model provider. + + """ + + response_metadata: dict[Any, Any] = Field(default_factory=dict) + """Examples: response headers, logprobs, token counts, model name.""" + + type: str + """The type of the message. Must be a string that is unique to the message type. + + The purpose of this field is to allow for easy identification of the message type + when deserializing messages. + + """ + + name: str | None = None + """An optional name for the message. + + This can be used to provide a human-readable name for the message. + + Usage of this field is optional, and whether it's used or not is up to the + model implementation. + + """ + + id: str | None = Field(default=None, coerce_numbers_to_str=True) + """An optional unique identifier for the message. + + This should ideally be provided by the provider/model which created the message. + + """ + + model_config = ConfigDict( + extra="allow", + ) + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]], + **kwargs: Any, + ) -> None: ... + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: ... + + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: + """Initialize a `BaseMessage`. + + Specify `content` as positional arg or `content_blocks` for typing. + + Args: + content: The contents of the message. + content_blocks: Typed standard content. + **kwargs: Additional arguments to pass to the parent class. + """ + if content_blocks is not None: + super().__init__(content=content_blocks, **kwargs) + else: + super().__init__(content=content, **kwargs) + + @classmethod + def is_lc_serializable(cls) -> bool: + """`BaseMessage` is serializable. + + Returns: + True + """ + return True + + @classmethod + def get_lc_namespace(cls) -> list[str]: + """Get the namespace of the LangChain object. + + Returns: + `["langchain", "schema", "messages"]` + """ + return ["langchain", "schema", "messages"] + + @property + def content_blocks(self) -> list[types.ContentBlock]: + r"""Load content blocks from the message content. + + !!! version-added "Added in `langchain-core` 1.0.0" + + """ + # Needed here to avoid circular import, as these classes import BaseMessages + from langchain_core.messages.block_translators.anthropic import ( # noqa: PLC0415 + _convert_to_v1_from_anthropic_input, + ) + from langchain_core.messages.block_translators.bedrock_converse import ( # noqa: PLC0415 + _convert_to_v1_from_converse_input, + ) + from langchain_core.messages.block_translators.google_genai import ( # noqa: PLC0415 + _convert_to_v1_from_genai_input, + ) + from langchain_core.messages.block_translators.langchain_v0 import ( # noqa: PLC0415 + _convert_v0_multimodal_input_to_v1, + ) + from langchain_core.messages.block_translators.openai import ( # noqa: PLC0415 + _convert_to_v1_from_chat_completions_input, + ) + + blocks: list[types.ContentBlock] = [] + content = ( + # Transpose string content to list, otherwise assumed to be list + [self.content] + if isinstance(self.content, str) and self.content + else self.content + ) + for item in content: + if isinstance(item, str): + # Plain string content is treated as a text block + blocks.append({"type": "text", "text": item}) + elif isinstance(item, dict): + item_type = item.get("type") + if item_type not in types.KNOWN_BLOCK_TYPES: + # Handle all provider-specific or None type blocks as non-standard - + # we'll come back to these later + blocks.append({"type": "non_standard", "value": item}) + else: + # Guard against v0 blocks that share the same `type` keys + if "source_type" in item: + blocks.append({"type": "non_standard", "value": item}) + continue + + # This can't be a v0 block (since they require `source_type`), + # so it's a known v1 block type + blocks.append(cast("types.ContentBlock", item)) + + # Subsequent passes: attempt to unpack non-standard blocks. + # This is the last stop - if we can't parse it here, it is left as non-standard + for parsing_step in [ + _convert_v0_multimodal_input_to_v1, + _convert_to_v1_from_chat_completions_input, + _convert_to_v1_from_anthropic_input, + _convert_to_v1_from_genai_input, + _convert_to_v1_from_converse_input, + ]: + blocks = parsing_step(blocks) + return blocks + + @property + def text(self) -> TextAccessor: + """Get the text content of the message as a string. + + Can be used as both property (`message.text`) and method (`message.text()`). + + Handles both string and list content types (e.g. for content blocks). Only + extracts blocks with `type: 'text'`; other block types are ignored. + + !!! deprecated + As of `langchain-core` 1.0.0, calling `.text()` as a method is deprecated. + Use `.text` as a property instead. This method will be removed in 2.0.0. + + Returns: + The text content of the message. + + """ + if isinstance(self.content, str): + text_value = self.content + else: + # Must be a list + blocks = [ + block + for block in self.content + if isinstance(block, str) + or (block.get("type") == "text" and isinstance(block.get("text"), str)) + ] + text_value = "".join( + block if isinstance(block, str) else block["text"] for block in blocks + ) + return TextAccessor(text_value) + + def __add__(self, other: Any) -> ChatPromptTemplate: + """Concatenate this message with another message. + + Args: + other: Another message to concatenate with this one. + + Returns: + A ChatPromptTemplate containing both messages. + """ + # Import locally to prevent circular imports. + from langchain_core.prompts.chat import ChatPromptTemplate # noqa: PLC0415 + + prompt = ChatPromptTemplate(messages=[self]) + return prompt.__add__(other) + + def pretty_repr( + self, + html: bool = False, # noqa: FBT001,FBT002 + ) -> str: + """Get a pretty representation of the message. + + Args: + html: Whether to format the message as HTML. If `True`, the message will be + formatted with HTML tags. + + Returns: + A pretty representation of the message. + + Example: + ```python + from langchain_core.messages import HumanMessage + + msg = HumanMessage(content="What is the capital of France?") + print(msg.pretty_repr()) + ``` + + Results in: + + ```txt + ================================ Human Message ================================= + + What is the capital of France? + ``` + """ # noqa: E501 + title = get_msg_title_repr(self.type.title() + " Message", bold=html) + # TODO: handle non-string content. + if self.name is not None: + title += f"\nName: {self.name}" + return f"{title}\n\n{self.content}" + + def pretty_print(self) -> None: + """Print a pretty representation of the message. + + Example: + ```python + from langchain_core.messages import AIMessage + + msg = AIMessage(content="The capital of France is Paris.") + msg.pretty_print() + ``` + + Results in: + + ```txt + ================================== Ai Message ================================== + + The capital of France is Paris. + ``` + """ # noqa: E501 + print(self.pretty_repr(html=is_interactive_env())) # noqa: T201 + + +def merge_content( + first_content: str | list[str | dict[Any, Any]], + *contents: str | list[str | dict[Any, Any]], +) -> str | list[str | dict[Any, Any]]: + """Merge multiple message contents. + + Args: + first_content: The first `content`. Can be a string or a list. + contents: The other `content`s. Can be a string or a list. + + Returns: + The merged content. + + """ + merged: str | list[str | dict[Any, Any]] + merged = "" if first_content is None else first_content + + for content in contents: + # If current is a string + if isinstance(merged, str): + # If the next chunk is also a string, then merge them naively + if isinstance(content, str): + merged += content + # If the next chunk is a list, add the current to the start of the list + else: + merged = [merged, *content] + elif isinstance(content, list): + # If both are lists + merged = merge_lists(merged, content) # type: ignore[assignment] + # If the first content is a list, and the second content is a string + # If the last element of the first content is a string + # Add the second content to the last element + elif merged and isinstance(merged[-1], str): + merged[-1] += content + # If second content is an empty string, treat as a no-op + elif content == "": + pass + # Otherwise, add the second content as a new element of the list + elif merged: + merged.append(content) + return merged + + +class BaseMessageChunk(BaseMessage): + """Message chunk, which can be concatenated with other Message chunks.""" + + def __add__(self, other: Any) -> BaseMessageChunk: # type: ignore[override] + """Message chunks support concatenation with other message chunks. + + This functionality is useful to combine message chunks yielded from + a streaming model into a complete message. + + Args: + other: Another message chunk to concatenate with this one. + + Returns: + A new message chunk that is the concatenation of this message chunk + and the other message chunk. + + Raises: + TypeError: If the other object is not a message chunk. + + Example: + ```txt + AIMessageChunk(content="Hello", ...) + + AIMessageChunk(content=" World", ...) + = AIMessageChunk(content="Hello World", ...) + ``` + """ + if isinstance(other, BaseMessageChunk): + # If both are (subclasses of) BaseMessageChunk, + # concat into a single BaseMessageChunk + + return self.__class__( + id=self.id, + type=self.type, + content=merge_content(self.content, other.content), + additional_kwargs=merge_dicts( + self.additional_kwargs, other.additional_kwargs + ), + response_metadata=merge_dicts( + self.response_metadata, other.response_metadata + ), + ) + if isinstance(other, list) and all( + isinstance(o, BaseMessageChunk) for o in other + ): + content = merge_content(self.content, *(o.content for o in other)) + additional_kwargs = merge_dicts( + self.additional_kwargs, *(o.additional_kwargs for o in other) + ) + response_metadata = merge_dicts( + self.response_metadata, *(o.response_metadata for o in other) + ) + return self.__class__( # type: ignore[call-arg] + id=self.id, + content=content, + additional_kwargs=additional_kwargs, + response_metadata=response_metadata, + ) + msg = ( + 'unsupported operand type(s) for +: "' + f"{self.__class__.__name__}" + f'" and "{other.__class__.__name__}"' + ) + raise TypeError(msg) + + +def message_to_dict(message: BaseMessage) -> dict[str, Any]: + """Convert a Message to a dictionary. + + Args: + message: Message to convert. + + Returns: + Message as a dict. The dict will have a `type` key with the message type + and a `data` key with the message data as a dict. + + """ + return {"type": message.type, "data": message.model_dump()} + + +def messages_to_dict(messages: Sequence[BaseMessage]) -> list[dict[str, Any]]: + """Convert a sequence of Messages to a list of dictionaries. + + Args: + messages: Sequence of messages (as `BaseMessage`s) to convert. + + Returns: + List of messages as dicts. + + """ + return [message_to_dict(m) for m in messages] + + +def get_msg_title_repr(title: str, *, bold: bool = False) -> str: + """Get a title representation for a message. + + Args: + title: The title. + bold: Whether to bold the title. + + Returns: + The title representation. + + """ + padded = " " + title + " " + sep_len = (80 - len(padded)) // 2 + sep = "=" * sep_len + second_sep = sep + "=" if len(padded) % 2 else sep + if bold: + padded = get_bolded_text(padded) + return f"{sep}{padded}{second_sep}" diff --git a/libs/core/langchain_core/messages/block_translators/__init__.py b/libs/core/langchain_core/messages/block_translators/__init__.py new file mode 100644 index 0000000..44ddc25 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/__init__.py @@ -0,0 +1,112 @@ +"""Derivations of standard content blocks from provider content. + +`AIMessage` will first attempt to use a provider-specific translator if +`model_provider` is set in `response_metadata` on the message. Consequently, each +provider translator must handle all possible content response types from the provider, +including text. + +If no provider is set, or if the provider does not have a registered translator, +`AIMessage` will fall back to best-effort parsing of the content into blocks using +the implementation in `BaseMessage`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from langchain_core.messages import AIMessage, AIMessageChunk + from langchain_core.messages import content as types + +# Provider to translator mapping +PROVIDER_TRANSLATORS: dict[str, dict[str, Callable[..., list[types.ContentBlock]]]] = {} +"""Map model provider names to translator functions. + +The dictionary maps provider names (e.g. `'openai'`, `'anthropic'`) to another +dictionary with two keys: +- `'translate_content'`: Function to translate `AIMessage` content. +- `'translate_content_chunk'`: Function to translate `AIMessageChunk` content. + +When calling `content_blocks` on an `AIMessage` or `AIMessageChunk`, if +`model_provider` is set in `response_metadata`, the corresponding translator +functions will be used to parse the content into blocks. Otherwise, best-effort parsing +in `BaseMessage` will be used. +""" + + +def register_translator( + provider: str, + translate_content: Callable[[AIMessage], list[types.ContentBlock]], + translate_content_chunk: Callable[[AIMessageChunk], list[types.ContentBlock]], +) -> None: + """Register content translators for a provider in `PROVIDER_TRANSLATORS`. + + Args: + provider: The model provider name (e.g. `'openai'`, `'anthropic'`). + translate_content: Function to translate `AIMessage` content. + translate_content_chunk: Function to translate `AIMessageChunk` content. + """ + PROVIDER_TRANSLATORS[provider] = { + "translate_content": translate_content, + "translate_content_chunk": translate_content_chunk, + } + + +def get_translator( + provider: str, +) -> dict[str, Callable[..., list[types.ContentBlock]]] | None: + """Get the translator functions for a provider. + + Args: + provider: The model provider name. + + Returns: + Dictionary with `'translate_content'` and `'translate_content_chunk'` + functions, or None if no translator is registered for the provider. In such + case, best-effort parsing in `BaseMessage` will be used. + """ + return PROVIDER_TRANSLATORS.get(provider) + + +def _register_translators() -> None: + """Register all translators in langchain-core. + + A unit test ensures all modules in `block_translators` are represented here. + + For translators implemented outside langchain-core, they can be registered by + calling `register_translator` from within the integration package. + """ + from langchain_core.messages.block_translators.anthropic import ( # noqa: PLC0415 + _register_anthropic_translator, + ) + from langchain_core.messages.block_translators.bedrock import ( # noqa: PLC0415 + _register_bedrock_translator, + ) + from langchain_core.messages.block_translators.bedrock_converse import ( # noqa: PLC0415 + _register_bedrock_converse_translator, + ) + from langchain_core.messages.block_translators.google_genai import ( # noqa: PLC0415 + _register_google_genai_translator, + ) + from langchain_core.messages.block_translators.google_vertexai import ( # noqa: PLC0415 + _register_google_vertexai_translator, + ) + from langchain_core.messages.block_translators.groq import ( # noqa: PLC0415 + _register_groq_translator, + ) + from langchain_core.messages.block_translators.openai import ( # noqa: PLC0415 + _register_openai_translator, + ) + + _register_bedrock_translator() + _register_bedrock_converse_translator() + _register_anthropic_translator() + _register_google_genai_translator() + _register_google_vertexai_translator() + _register_groq_translator() + _register_openai_translator() + + +_register_translators() diff --git a/libs/core/langchain_core/messages/block_translators/anthropic.py b/libs/core/langchain_core/messages/block_translators/anthropic.py new file mode 100644 index 0000000..fb70388 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/anthropic.py @@ -0,0 +1,500 @@ +"""Derivations of standard content blocks from Anthropic content.""" + +import json +from collections.abc import Iterator +from typing import Any, cast + +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages import content as types + + +def _populate_extras( + standard_block: types.ContentBlock, block: dict[str, Any], known_fields: set[str] +) -> types.ContentBlock: + """Mutate a block, populating extras.""" + if standard_block.get("type") == "non_standard": + return standard_block + + for key, value in block.items(): + if key not in known_fields: + if "extras" not in standard_block: + # Below type-ignores are because mypy thinks a non-standard block can + # get here, although we exclude them above. + standard_block["extras"] = {} # type: ignore[typeddict-unknown-key] + standard_block["extras"][key] = value # type: ignore[typeddict-item] + + return standard_block + + +def _convert_to_v1_from_anthropic_input( + content: list[types.ContentBlock], +) -> list[types.ContentBlock]: + """Convert Anthropic format blocks to v1 format. + + During the `content_blocks` parsing process, we wrap blocks not recognized as a v1 + block as a `'non_standard'` block with the original block stored in the `value` + field. This function attempts to unpack those blocks and convert any blocks that + might be Anthropic format to v1 ContentBlocks. + + If conversion fails, the block is left as a `'non_standard'` block. + + Args: + content: List of content blocks to process. + + Returns: + Updated list with Anthropic blocks converted to v1 format. + """ + + def _iter_blocks() -> Iterator[types.ContentBlock]: + blocks: list[dict[str, Any]] = [ + cast("dict[str, Any]", block) + if block.get("type") != "non_standard" + else block["value"] # type: ignore[typeddict-item] # this is only non-standard blocks + for block in content + ] + for block in blocks: + block_type = block.get("type") + + if ( + block_type == "document" + and "source" in block + and "type" in block["source"] + ): + if block["source"]["type"] == "base64": + file_block: types.FileContentBlock = { + "type": "file", + "base64": block["source"]["data"], + "mime_type": block["source"]["media_type"], + } + _populate_extras(file_block, block, {"type", "source"}) + yield file_block + + elif block["source"]["type"] == "url": + file_block = { + "type": "file", + "url": block["source"]["url"], + } + _populate_extras(file_block, block, {"type", "source"}) + yield file_block + + elif block["source"]["type"] == "file": + file_block = { + "type": "file", + "id": block["source"]["file_id"], + } + _populate_extras(file_block, block, {"type", "source"}) + yield file_block + + elif block["source"]["type"] == "text": + plain_text_block: types.PlainTextContentBlock = { + "type": "text-plain", + "text": block["source"]["data"], + "mime_type": block.get("media_type", "text/plain"), + } + _populate_extras(plain_text_block, block, {"type", "source"}) + yield plain_text_block + + else: + yield {"type": "non_standard", "value": block} + + elif ( + block_type == "image" + and "source" in block + and "type" in block["source"] + ): + if block["source"]["type"] == "base64": + image_block: types.ImageContentBlock = { + "type": "image", + "base64": block["source"]["data"], + "mime_type": block["source"]["media_type"], + } + _populate_extras(image_block, block, {"type", "source"}) + yield image_block + + elif block["source"]["type"] == "url": + image_block = { + "type": "image", + "url": block["source"]["url"], + } + _populate_extras(image_block, block, {"type", "source"}) + yield image_block + + elif block["source"]["type"] == "file": + image_block = { + "type": "image", + "id": block["source"]["file_id"], + } + _populate_extras(image_block, block, {"type", "source"}) + yield image_block + + else: + yield {"type": "non_standard", "value": block} + + elif block_type in types.KNOWN_BLOCK_TYPES: + yield cast("types.ContentBlock", block) + + else: + yield {"type": "non_standard", "value": block} + + return list(_iter_blocks()) + + +def _convert_citation_to_v1(citation: dict[str, Any]) -> types.Annotation: + citation_type = citation.get("type") + + if citation_type == "web_search_result_location": + url_citation: types.Citation = { + "type": "citation", + "cited_text": citation["cited_text"], + "url": citation["url"], + } + if title := citation.get("title"): + url_citation["title"] = title + known_fields = {"type", "cited_text", "url", "title", "index", "extras"} + for key, value in citation.items(): + if key not in known_fields: + if "extras" not in url_citation: + url_citation["extras"] = {} + url_citation["extras"][key] = value + + return url_citation + + if citation_type in { + "char_location", + "content_block_location", + "page_location", + "search_result_location", + }: + document_citation: types.Citation = { + "type": "citation", + "cited_text": citation["cited_text"], + } + if "document_title" in citation: + document_citation["title"] = citation["document_title"] + elif title := citation.get("title"): + document_citation["title"] = title + known_fields = { + "type", + "cited_text", + "document_title", + "title", + "index", + "extras", + } + for key, value in citation.items(): + if key not in known_fields: + if "extras" not in document_citation: + document_citation["extras"] = {} + document_citation["extras"][key] = value + + return document_citation + + return { + "type": "non_standard_annotation", + "value": citation, + } + + +def _convert_to_v1_from_anthropic(message: AIMessage) -> list[types.ContentBlock]: + """Convert Anthropic message content to v1 format.""" + content: list[str | dict[str, Any]] + if isinstance(message.content, str): + content = [{"type": "text", "text": message.content}] + else: + content = message.content + + def _iter_blocks() -> Iterator[types.ContentBlock]: + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + + if block_type == "text": + if citations := block.get("citations"): + text_block: types.TextContentBlock = { + "type": "text", + "text": block.get("text", ""), + "annotations": [_convert_citation_to_v1(a) for a in citations], + } + else: + text_block = {"type": "text", "text": block["text"]} + if "index" in block: + text_block["index"] = block["index"] + yield text_block + + elif block_type == "thinking": + reasoning_block: types.ReasoningContentBlock = { + "type": "reasoning", + "reasoning": block.get("thinking", ""), + } + if "index" in block: + reasoning_block["index"] = block["index"] + known_fields = {"type", "thinking", "index", "extras"} + for key in block: + if key not in known_fields: + if "extras" not in reasoning_block: + reasoning_block["extras"] = {} + reasoning_block["extras"][key] = block[key] + yield reasoning_block + + elif block_type == "tool_use": + if ( + isinstance(message, AIMessageChunk) + and len(message.tool_call_chunks) == 1 + and message.chunk_position != "last" + ): + # Isolated chunk + chunk = message.tool_call_chunks[0] + + tool_call_chunk = types.ToolCallChunk( + name=chunk.get("name"), + id=chunk.get("id"), + args=chunk.get("args"), + type="tool_call_chunk", + ) + if "caller" in block: + tool_call_chunk["extras"] = {"caller": block["caller"]} + + index = chunk.get("index") + if index is not None: + tool_call_chunk["index"] = index + yield tool_call_chunk + else: + tool_call_block: types.ToolCall | None = None + # Non-streaming or gathered chunk + if len(message.tool_calls) == 1: + tool_call_block = { + "type": "tool_call", + "name": message.tool_calls[0]["name"], + "args": message.tool_calls[0]["args"], + "id": message.tool_calls[0].get("id"), + } + elif call_id := block.get("id"): + for tc in message.tool_calls: + if tc.get("id") == call_id: + tool_call_block = { + "type": "tool_call", + "name": tc["name"], + "args": tc["args"], + "id": tc.get("id"), + } + break + if not tool_call_block: + tool_call_block = { + "type": "tool_call", + "name": block.get("name", ""), + "args": block.get("input", {}), + "id": block.get("id", ""), + } + if "index" in block: + tool_call_block["index"] = block["index"] + if "caller" in block: + if "extras" not in tool_call_block: + tool_call_block["extras"] = {} + tool_call_block["extras"]["caller"] = block["caller"] + + yield tool_call_block + + elif block_type == "input_json_delta" and isinstance( + message, AIMessageChunk + ): + if len(message.tool_call_chunks) == 1: + chunk = message.tool_call_chunks[0] + tool_call_chunk = types.ToolCallChunk( + name=chunk.get("name"), + id=chunk.get("id"), + args=chunk.get("args"), + type="tool_call_chunk", + ) + index = chunk.get("index") + if index is not None: + tool_call_chunk["index"] = index + yield tool_call_chunk + + else: + server_tool_call_chunk: types.ServerToolCallChunk = { + "type": "server_tool_call_chunk", + "args": block.get("partial_json", ""), + } + if "index" in block: + server_tool_call_chunk["index"] = block["index"] + yield server_tool_call_chunk + + elif block_type == "server_tool_use": + if block.get("name") == "code_execution": + server_tool_use_name = "code_interpreter" + else: + server_tool_use_name = block.get("name", "") + if ( + isinstance(message, AIMessageChunk) + and block.get("input") == {} + and "partial_json" not in block + and message.chunk_position != "last" + ): + # First chunk in a stream + server_tool_call_chunk = { + "type": "server_tool_call_chunk", + "name": server_tool_use_name, + "args": "", + "id": block.get("id", ""), + } + if "index" in block: + server_tool_call_chunk["index"] = block["index"] + known_fields = {"type", "name", "input", "id", "index"} + _populate_extras(server_tool_call_chunk, block, known_fields) + yield server_tool_call_chunk + else: + server_tool_call: types.ServerToolCall = { + "type": "server_tool_call", + "name": server_tool_use_name, + "args": block.get("input", {}), + "id": block.get("id", ""), + } + + if block.get("input") == {} and "partial_json" in block: + try: + input_ = json.loads(block["partial_json"]) + if isinstance(input_, dict): + server_tool_call["args"] = input_ + except json.JSONDecodeError: + pass + + if "index" in block: + server_tool_call["index"] = block["index"] + known_fields = { + "type", + "name", + "input", + "partial_json", + "id", + "index", + } + _populate_extras(server_tool_call, block, known_fields) + + yield server_tool_call + + elif block_type == "mcp_tool_use": + if ( + isinstance(message, AIMessageChunk) + and block.get("input") == {} + and "partial_json" not in block + and message.chunk_position != "last" + ): + # First chunk in a stream + server_tool_call_chunk = { + "type": "server_tool_call_chunk", + "name": "remote_mcp", + "args": "", + "id": block.get("id", ""), + } + if "name" in block: + server_tool_call_chunk["extras"] = {"tool_name": block["name"]} + known_fields = {"type", "name", "input", "id", "index"} + _populate_extras(server_tool_call_chunk, block, known_fields) + if "index" in block: + server_tool_call_chunk["index"] = block["index"] + yield server_tool_call_chunk + else: + server_tool_call = { + "type": "server_tool_call", + "name": "remote_mcp", + "args": block.get("input", {}), + "id": block.get("id", ""), + } + + if block.get("input") == {} and "partial_json" in block: + try: + input_ = json.loads(block["partial_json"]) + if isinstance(input_, dict): + server_tool_call["args"] = input_ + except json.JSONDecodeError: + pass + + if "name" in block: + server_tool_call["extras"] = {"tool_name": block["name"]} + known_fields = { + "type", + "name", + "input", + "partial_json", + "id", + "index", + } + _populate_extras(server_tool_call, block, known_fields) + if "index" in block: + server_tool_call["index"] = block["index"] + + yield server_tool_call + + elif block_type and block_type.endswith("_tool_result"): + server_tool_result: types.ServerToolResult = { + "type": "server_tool_result", + "tool_call_id": block.get("tool_use_id", ""), + "status": "success", + "extras": {"block_type": block_type}, + } + if output := block.get("content", []): + server_tool_result["output"] = output + if isinstance(output, dict) and output.get( + "error_code" # web_search, code_interpreter + ): + server_tool_result["status"] = "error" + if block.get("is_error"): # mcp_tool_result + server_tool_result["status"] = "error" + if "index" in block: + server_tool_result["index"] = block["index"] + + known_fields = {"type", "tool_use_id", "content", "is_error", "index"} + _populate_extras(server_tool_result, block, known_fields) + + yield server_tool_result + + else: + new_block: types.NonStandardContentBlock = { + "type": "non_standard", + "value": block, + } + if "index" in new_block["value"]: + new_block["index"] = new_block["value"].pop("index") + yield new_block + + return list(_iter_blocks()) + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with Anthropic content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_anthropic(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a message chunk with Anthropic content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_anthropic(message) + + +def _register_anthropic_translator() -> None: + """Register the Anthropic translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("anthropic", translate_content, translate_content_chunk) + + +_register_anthropic_translator() diff --git a/libs/core/langchain_core/messages/block_translators/bedrock.py b/libs/core/langchain_core/messages/block_translators/bedrock.py new file mode 100644 index 0000000..f37f223 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/bedrock.py @@ -0,0 +1,108 @@ +"""Derivations of standard content blocks from Bedrock content.""" + +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages import content as types +from langchain_core.messages.block_translators.anthropic import ( + _convert_to_v1_from_anthropic, +) + + +def _convert_to_v1_from_bedrock(message: AIMessage) -> list[types.ContentBlock]: + """Convert bedrock message content to v1 format.""" + out = _convert_to_v1_from_anthropic(message) + + content_tool_call_ids = { + block.get("id") + for block in out + if isinstance(block, dict) and block.get("type") == "tool_call" + } + for tool_call in message.tool_calls: + if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids: + tool_call_block: types.ToolCall = { + "type": "tool_call", + "id": id_, + "name": tool_call["name"], + "args": tool_call["args"], + } + if "index" in tool_call: + tool_call_block["index"] = tool_call["index"] # type: ignore[typeddict-item] + if "extras" in tool_call: + tool_call_block["extras"] = tool_call["extras"] # type: ignore[typeddict-item] + out.append(tool_call_block) + return out + + +def _convert_to_v1_from_bedrock_chunk( + message: AIMessageChunk, +) -> list[types.ContentBlock]: + """Convert bedrock message chunk content to v1 format.""" + if ( + message.content == "" + and not message.additional_kwargs + and not message.tool_calls + ): + # Bedrock outputs multiple chunks containing response metadata + return [] + + out = _convert_to_v1_from_anthropic(message) + + if ( + message.tool_call_chunks + and not message.content + and message.chunk_position != "last" # keep tool_calls if aggregated + ): + for tool_call_chunk in message.tool_call_chunks: + tc: types.ToolCallChunk = { + "type": "tool_call_chunk", + "id": tool_call_chunk.get("id"), + "name": tool_call_chunk.get("name"), + "args": tool_call_chunk.get("args"), + } + if (idx := tool_call_chunk.get("index")) is not None: + tc["index"] = idx + out.append(tc) + return out + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with Bedrock content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + if "claude" not in message.response_metadata.get("model_name", "").lower(): + raise NotImplementedError # fall back to best-effort parsing + return _convert_to_v1_from_bedrock(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a message chunk with Bedrock content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + # TODO: add model_name to all Bedrock chunks and update core merging logic + # to not append during aggregation. Then raise NotImplementedError here if + # not an Anthropic model to fall back to best-effort parsing. + return _convert_to_v1_from_bedrock_chunk(message) + + +def _register_bedrock_translator() -> None: + """Register the bedrock translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("bedrock", translate_content, translate_content_chunk) + + +_register_bedrock_translator() diff --git a/libs/core/langchain_core/messages/block_translators/bedrock_converse.py b/libs/core/langchain_core/messages/block_translators/bedrock_converse.py new file mode 100644 index 0000000..d2407e7 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/bedrock_converse.py @@ -0,0 +1,319 @@ +"""Derivations of standard content blocks from Amazon (Bedrock Converse) content.""" + +import base64 +from collections.abc import Iterator +from typing import Any, cast + +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages import content as types + + +def _bytes_to_b64_str(bytes_: bytes) -> str: + return base64.b64encode(bytes_).decode("utf-8") + + +def _populate_extras( + standard_block: types.ContentBlock, block: dict[str, Any], known_fields: set[str] +) -> types.ContentBlock: + """Mutate a block, populating extras.""" + if standard_block.get("type") == "non_standard": + return standard_block + + for key, value in block.items(): + if key not in known_fields: + if "extras" not in standard_block: + # Below type-ignores are because mypy thinks a non-standard block can + # get here, although we exclude them above. + standard_block["extras"] = {} # type: ignore[typeddict-unknown-key] + standard_block["extras"][key] = value # type: ignore[typeddict-item] + + return standard_block + + +def _convert_to_v1_from_converse_input( + content: list[types.ContentBlock], +) -> list[types.ContentBlock]: + """Convert Bedrock Converse format blocks to v1 format. + + During the `content_blocks` parsing process, we wrap blocks not recognized as a v1 + block as a `'non_standard'` block with the original block stored in the `value` + field. This function attempts to unpack those blocks and convert any blocks that + might be Converse format to v1 ContentBlocks. + + If conversion fails, the block is left as a `'non_standard'` block. + + Args: + content: List of content blocks to process. + + Returns: + Updated list with Converse blocks converted to v1 format. + """ + + def _iter_blocks() -> Iterator[types.ContentBlock]: + blocks: list[dict[str, Any]] = [ + cast("dict[str, Any]", block) + if block.get("type") != "non_standard" + else block["value"] # type: ignore[typeddict-item] # this is only non-standard blocks + for block in content + ] + for block in blocks: + num_keys = len(block) + + if num_keys == 1 and (text := block.get("text")): + yield {"type": "text", "text": text} + + elif ( + num_keys == 1 + and (document := block.get("document")) + and isinstance(document, dict) + and "format" in document + ): + if document.get("format") == "pdf": + if "bytes" in document.get("source", {}): + file_block: types.FileContentBlock = { + "type": "file", + "base64": _bytes_to_b64_str(document["source"]["bytes"]), + "mime_type": "application/pdf", + } + _populate_extras(file_block, document, {"format", "source"}) + yield file_block + + else: + yield {"type": "non_standard", "value": block} + + elif document["format"] == "txt": + if "text" in document.get("source", {}): + plain_text_block: types.PlainTextContentBlock = { + "type": "text-plain", + "text": document["source"]["text"], + "mime_type": "text/plain", + } + _populate_extras( + plain_text_block, document, {"format", "source"} + ) + yield plain_text_block + else: + yield {"type": "non_standard", "value": block} + + else: + yield {"type": "non_standard", "value": block} + + elif ( + num_keys == 1 + and (image := block.get("image")) + and isinstance(image, dict) + and "format" in image + ): + if "bytes" in image.get("source", {}): + image_block: types.ImageContentBlock = { + "type": "image", + "base64": _bytes_to_b64_str(image["source"]["bytes"]), + "mime_type": f"image/{image['format']}", + } + _populate_extras(image_block, image, {"format", "source"}) + yield image_block + + else: + yield {"type": "non_standard", "value": block} + + elif block.get("type") in types.KNOWN_BLOCK_TYPES: + yield cast("types.ContentBlock", block) + + else: + yield {"type": "non_standard", "value": block} + + return list(_iter_blocks()) + + +def _convert_citation_to_v1(citation: dict[str, Any]) -> types.Annotation: + standard_citation: types.Citation = {"type": "citation"} + if "title" in citation: + standard_citation["title"] = citation["title"] + if ( + (source_content := citation.get("source_content")) + and isinstance(source_content, list) + and all(isinstance(item, dict) for item in source_content) + ): + standard_citation["cited_text"] = "".join( + item.get("text", "") for item in source_content + ) + + known_fields = {"type", "source_content", "title", "index", "extras"} + + for key, value in citation.items(): + if key not in known_fields: + if "extras" not in standard_citation: + standard_citation["extras"] = {} + standard_citation["extras"][key] = value + + return standard_citation + + +def _convert_to_v1_from_converse(message: AIMessage) -> list[types.ContentBlock]: + """Convert Bedrock Converse message content to v1 format.""" + if ( + message.content == "" + and not message.additional_kwargs + and not message.tool_calls + ): + # Converse outputs multiple chunks containing response metadata + return [] + + if isinstance(message.content, str): + message.content = [{"type": "text", "text": message.content}] + + def _iter_blocks() -> Iterator[types.ContentBlock]: + for block in message.content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + + if block_type == "text": + if citations := block.get("citations"): + text_block: types.TextContentBlock = { + "type": "text", + "text": block.get("text", ""), + "annotations": [_convert_citation_to_v1(a) for a in citations], + } + else: + text_block = {"type": "text", "text": block["text"]} + if "index" in block: + text_block["index"] = block["index"] + yield text_block + + elif block_type == "reasoning_content": + reasoning_block: types.ReasoningContentBlock = {"type": "reasoning"} + if reasoning_content := block.get("reasoning_content"): + if reasoning := reasoning_content.get("text"): + reasoning_block["reasoning"] = reasoning + if signature := reasoning_content.get("signature"): + if "extras" not in reasoning_block: + reasoning_block["extras"] = {} + reasoning_block["extras"]["signature"] = signature + + if "index" in block: + reasoning_block["index"] = block["index"] + + known_fields = {"type", "reasoning_content", "index", "extras"} + for key in block: + if key not in known_fields: + if "extras" not in reasoning_block: + reasoning_block["extras"] = {} + reasoning_block["extras"][key] = block[key] + yield reasoning_block + + elif block_type == "tool_use": + if ( + isinstance(message, AIMessageChunk) + and len(message.tool_call_chunks) == 1 + and message.chunk_position != "last" + ): + # Isolated chunk + chunk = message.tool_call_chunks[0] + tool_call_chunk = types.ToolCallChunk( + name=chunk.get("name"), + id=chunk.get("id"), + args=chunk.get("args"), + type="tool_call_chunk", + ) + index = chunk.get("index") + if index is not None: + tool_call_chunk["index"] = index + yield tool_call_chunk + else: + tool_call_block: types.ToolCall | None = None + # Non-streaming or gathered chunk + if len(message.tool_calls) == 1: + tool_call_block = { + "type": "tool_call", + "name": message.tool_calls[0]["name"], + "args": message.tool_calls[0]["args"], + "id": message.tool_calls[0].get("id"), + } + elif call_id := block.get("id"): + for tc in message.tool_calls: + if tc.get("id") == call_id: + tool_call_block = { + "type": "tool_call", + "name": tc["name"], + "args": tc["args"], + "id": tc.get("id"), + } + break + if not tool_call_block: + tool_call_block = { + "type": "tool_call", + "name": block.get("name", ""), + "args": block.get("input", {}), + "id": block.get("id", ""), + } + if "index" in block: + tool_call_block["index"] = block["index"] + yield tool_call_block + + elif ( + block_type == "input_json_delta" + and isinstance(message, AIMessageChunk) + and len(message.tool_call_chunks) == 1 + ): + chunk = message.tool_call_chunks[0] + tool_call_chunk = types.ToolCallChunk( + name=chunk.get("name"), + id=chunk.get("id"), + args=chunk.get("args"), + type="tool_call_chunk", + ) + index = chunk.get("index") + if index is not None: + tool_call_chunk["index"] = index + yield tool_call_chunk + + else: + new_block: types.NonStandardContentBlock = { + "type": "non_standard", + "value": block, + } + if "index" in new_block["value"]: + new_block["index"] = new_block["value"].pop("index") + yield new_block + + return list(_iter_blocks()) + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with Bedrock Converse content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_converse(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a chunk with Bedrock Converse content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_converse(message) + + +def _register_bedrock_converse_translator() -> None: + """Register the Bedrock Converse translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("bedrock_converse", translate_content, translate_content_chunk) + + +_register_bedrock_converse_translator() diff --git a/libs/core/langchain_core/messages/block_translators/google_genai.py b/libs/core/langchain_core/messages/block_translators/google_genai.py new file mode 100644 index 0000000..c207033 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/google_genai.py @@ -0,0 +1,566 @@ +"""Derivations of standard content blocks from Google (GenAI) content.""" + +import base64 +import re +from collections.abc import Iterator +from typing import Any, cast + +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages import content as types +from langchain_core.messages.content import Citation, create_citation + +try: + import filetype # type: ignore[import-not-found] + + _HAS_FILETYPE = True +except ImportError: + _HAS_FILETYPE = False + + +def _bytes_to_b64_str(bytes_: bytes) -> str: + """Convert bytes to base64 encoded string.""" + return base64.b64encode(bytes_).decode("utf-8") + + +def translate_grounding_metadata_to_citations( + grounding_metadata: dict[str, Any], +) -> list[Citation]: + """Translate Google AI grounding metadata to LangChain Citations. + + Args: + grounding_metadata: Google AI grounding metadata containing web search + queries, grounding chunks, and grounding supports. + + Returns: + List of Citation content blocks derived from the grounding metadata. + + Example: + >>> metadata = { + ... "web_search_queries": ["UEFA Euro 2024 winner"], + ... "grounding_chunks": [ + ... { + ... "web": { + ... "uri": "https://uefa.com/euro2024", + ... "title": "UEFA Euro 2024 Results", + ... } + ... } + ... ], + ... "grounding_supports": [ + ... { + ... "segment": { + ... "start_index": 0, + ... "end_index": 47, + ... "text": "Spain won the UEFA Euro 2024 championship", + ... }, + ... "grounding_chunk_indices": [0], + ... } + ... ], + ... } + >>> citations = translate_grounding_metadata_to_citations(metadata) + >>> len(citations) + 1 + >>> citations[0]["url"] + 'https://uefa.com/euro2024' + """ + if not grounding_metadata: + return [] + + grounding_chunks = grounding_metadata.get("grounding_chunks", []) + grounding_supports = grounding_metadata.get("grounding_supports", []) + web_search_queries = grounding_metadata.get("web_search_queries", []) + + citations: list[Citation] = [] + + for support in grounding_supports: + segment = support.get("segment", {}) + chunk_indices = support.get("grounding_chunk_indices", []) + + start_index = segment.get("start_index") + end_index = segment.get("end_index") + cited_text = segment.get("text") + + # Create a citation for each referenced chunk + for chunk_index in chunk_indices: + if chunk_index < len(grounding_chunks): + chunk = grounding_chunks[chunk_index] + + # Handle web and maps grounding + web_info = chunk.get("web") or {} + maps_info = chunk.get("maps") or {} + + # Extract citation info depending on source + url = maps_info.get("uri") or web_info.get("uri") + title = maps_info.get("title") or web_info.get("title") + + # Note: confidence_scores is a legacy field from Gemini 2.0 and earlier + # that indicated confidence (0.0-1.0) for each grounding chunk. + # + # In Gemini 2.5+, this field is always None/empty and should be ignored. + extras_metadata = { + "web_search_queries": web_search_queries, + "grounding_chunk_index": chunk_index, + "confidence_scores": support.get("confidence_scores") or [], + } + + # Add maps-specific metadata if present + if maps_info.get("placeId"): + extras_metadata["place_id"] = maps_info["placeId"] + + citation = create_citation( + url=url, + title=title, + start_index=start_index, + end_index=end_index, + cited_text=cited_text, + google_ai_metadata=extras_metadata, + ) + citations.append(citation) + + return citations + + +def _convert_to_v1_from_genai_input( + content: list[types.ContentBlock], +) -> list[types.ContentBlock]: + """Convert Google GenAI format blocks to v1 format. + + Called when message isn't an `AIMessage` or `model_provider` isn't set on + `response_metadata`. + + During the `content_blocks` parsing process, we wrap blocks not recognized as a v1 + block as a `'non_standard'` block with the original block stored in the `value` + field. This function attempts to unpack those blocks and convert any blocks that + might be GenAI format to v1 ContentBlocks. + + If conversion fails, the block is left as a `'non_standard'` block. + + Args: + content: List of content blocks to process. + + Returns: + Updated list with GenAI blocks converted to v1 format. + """ + + def _iter_blocks() -> Iterator[types.ContentBlock]: + blocks: list[dict[str, Any]] = [ + cast("dict[str, Any]", block) + if block.get("type") != "non_standard" + else block["value"] # type: ignore[typeddict-item] # this is only non-standard blocks + for block in content + ] + for block in blocks: + num_keys = len(block) + block_type = block.get("type") + + if num_keys == 1 and (text := block.get("text")): + # This is probably a TextContentBlock + yield {"type": "text", "text": text} + + elif ( + num_keys == 1 + and (document := block.get("document")) + and isinstance(document, dict) + and "format" in document + ): + # Handle document format conversion + doc_format = document.get("format") + source = document.get("source", {}) + + if doc_format == "pdf" and "bytes" in source: + # PDF document with byte data + file_block: types.FileContentBlock = { + "type": "file", + "base64": source["bytes"] + if isinstance(source["bytes"], str) + else _bytes_to_b64_str(source["bytes"]), + "mime_type": "application/pdf", + } + # Preserve extra fields + extras = { + key: value + for key, value in document.items() + if key not in {"format", "source"} + } + if extras: + file_block["extras"] = extras + yield file_block + + elif doc_format == "txt" and "text" in source: + # Text document + plain_text_block: types.PlainTextContentBlock = { + "type": "text-plain", + "text": source["text"], + "mime_type": "text/plain", + } + # Preserve extra fields + extras = { + key: value + for key, value in document.items() + if key not in {"format", "source"} + } + if extras: + plain_text_block["extras"] = extras + yield plain_text_block + + else: + # Unknown document format + yield {"type": "non_standard", "value": block} + + elif ( + num_keys == 1 + and (image := block.get("image")) + and isinstance(image, dict) + and "format" in image + ): + # Handle image format conversion + img_format = image.get("format") + source = image.get("source", {}) + + if "bytes" in source: + # Image with byte data + image_block: types.ImageContentBlock = { + "type": "image", + "base64": source["bytes"] + if isinstance(source["bytes"], str) + else _bytes_to_b64_str(source["bytes"]), + "mime_type": f"image/{img_format}", + } + # Preserve extra fields + extras = {} + for key, value in image.items(): + if key not in {"format", "source"}: + extras[key] = value + if extras: + image_block["extras"] = extras + yield image_block + + else: + # Image without byte data + yield {"type": "non_standard", "value": block} + + elif block_type == "file_data" and "file_uri" in block: + # Handle FileData URI-based content + uri_file_block: types.FileContentBlock = { + "type": "file", + "url": block["file_uri"], + } + if mime_type := block.get("mime_type"): + uri_file_block["mime_type"] = mime_type + yield uri_file_block + + elif block_type == "function_call" and "name" in block: + # Handle function calls + tool_call_block: types.ToolCall = { + "type": "tool_call", + "name": block["name"], + "args": block.get("args", {}), + "id": block.get("id", ""), + } + yield tool_call_block + + elif block_type == "executable_code": + server_tool_call_input: types.ServerToolCall = { + "type": "server_tool_call", + "name": "code_interpreter", + "args": { + "code": block.get("executable_code", ""), + "language": block.get("language", "python"), + }, + "id": block.get("id", ""), + } + yield server_tool_call_input + + elif block_type == "code_execution_result": + outcome = block.get("outcome", 1) + status = "success" if outcome == 1 else "error" + server_tool_result_input: types.ServerToolResult = { + "type": "server_tool_result", + "tool_call_id": block.get("tool_call_id", ""), + "status": status, # type: ignore[typeddict-item] + "output": block.get("code_execution_result", ""), + } + if outcome is not None: + server_tool_result_input["extras"] = {"outcome": outcome} + yield server_tool_result_input + + elif block.get("type") in types.KNOWN_BLOCK_TYPES: + # We see a standard block type, so we just cast it, even if + # we don't fully understand it. This may be dangerous, but + # it's better than losing information. + yield cast("types.ContentBlock", block) + + else: + # We don't understand this block at all. + yield {"type": "non_standard", "value": block} + + return list(_iter_blocks()) + + +def _convert_to_v1_from_genai(message: AIMessage) -> list[types.ContentBlock]: + """Convert Google GenAI message content to v1 format. + + Calling `.content_blocks` on an `AIMessage` where `response_metadata.model_provider` + is set to `'google_genai'` will invoke this function to parse the content into + standard content blocks for returning. + + Args: + message: The `AIMessage` or `AIMessageChunk` to convert. + + Returns: + List of standard content blocks derived from the message content. + """ + if isinstance(message.content, str): + # String content -> TextContentBlock (only add if non-empty in case of audio) + string_blocks: list[types.ContentBlock] = [] + if message.content: + string_blocks.append({"type": "text", "text": message.content}) + + # Add any missing tool calls from message.tool_calls field + content_tool_call_ids = { + block.get("id") + for block in string_blocks + if isinstance(block, dict) and block.get("type") == "tool_call" + } + for tool_call in message.tool_calls: + id_ = tool_call.get("id") + if id_ and id_ not in content_tool_call_ids: + string_tool_call_block: types.ToolCall = { + "type": "tool_call", + "id": id_, + "name": tool_call["name"], + "args": tool_call["args"], + } + string_blocks.append(string_tool_call_block) + + # Handle audio from additional_kwargs if present (for empty content cases) + audio_data = message.additional_kwargs.get("audio") + if audio_data and isinstance(audio_data, bytes): + audio_block: types.AudioContentBlock = { + "type": "audio", + "base64": _bytes_to_b64_str(audio_data), + "mime_type": "audio/wav", # Default to WAV for Google GenAI + } + string_blocks.append(audio_block) + + grounding_metadata = message.response_metadata.get("grounding_metadata") + if grounding_metadata: + citations = translate_grounding_metadata_to_citations(grounding_metadata) + + for block in string_blocks: + if block["type"] == "text" and citations: + # Add citations to the first text block only + block["annotations"] = cast("list[types.Annotation]", citations) + break + + return string_blocks + + if not isinstance(message.content, list): + # Unexpected content type, attempt to represent as text + return [{"type": "text", "text": str(message.content)}] # type: ignore[unreachable] + + converted_blocks: list[types.ContentBlock] = [] + + for item in message.content: + if isinstance(item, str): + # Conversation history strings + + # Citations are handled below after all blocks are converted + converted_blocks.append({"type": "text", "text": item}) # TextContentBlock + + elif isinstance(item, dict): + item_type = item.get("type") + if item_type == "image_url": + # Convert image_url to standard image block (base64) + # (since the original implementation returned as url-base64 CC style) + image_url = item.get("image_url", {}) + url = image_url.get("url", "") + if url: + # Extract base64 data + match = re.match(r"data:([^;]+);base64,(.+)", url) + if match: + # Data URI provided + mime_type, base64_data = match.groups() + converted_blocks.append( + { + "type": "image", + "base64": base64_data, + "mime_type": mime_type, + } + ) + else: + # Assume it's raw base64 without data URI + try: + # Validate base64 and decode for MIME type detection + decoded_bytes = base64.b64decode(url, validate=True) + + image_url_b64_block = { + "type": "image", + "base64": url, + } + + if _HAS_FILETYPE: + # Guess MIME type based on file bytes + mime_type = None + kind = filetype.guess(decoded_bytes) + if kind: + mime_type = kind.mime + if mime_type: + image_url_b64_block["mime_type"] = mime_type + + converted_blocks.append( + cast("types.ImageContentBlock", image_url_b64_block) + ) + except Exception: + # Not valid base64, treat as non-standard + converted_blocks.append( + { + "type": "non_standard", + "value": item, + } + ) + else: + # This likely won't be reached according to previous implementations + converted_blocks.append({"type": "non_standard", "value": item}) + msg = "Image URL not a data URI; appending as non-standard block." + raise ValueError(msg) + elif item_type == "function_call": + # Handle Google GenAI function calls + function_call_block: types.ToolCall = { + "type": "tool_call", + "name": item.get("name", ""), + "args": item.get("args", {}), + "id": item.get("id", ""), + } + converted_blocks.append(function_call_block) + elif item_type == "file_data": + # Handle FileData URI-based content + file_block: types.FileContentBlock = { + "type": "file", + "url": item.get("file_uri", ""), + } + if mime_type := item.get("mime_type"): + file_block["mime_type"] = mime_type + converted_blocks.append(file_block) + elif item_type == "thinking": + # Handling for the 'thinking' type we package thoughts as + reasoning_block: types.ReasoningContentBlock = { + "type": "reasoning", + "reasoning": item.get("thinking", ""), + } + if signature := item.get("signature"): + reasoning_block["extras"] = {"signature": signature} + + converted_blocks.append(reasoning_block) + elif item_type == "executable_code": + # Convert to standard server tool call block at the moment + server_tool_call_block: types.ServerToolCall = { + "type": "server_tool_call", + "name": "code_interpreter", + "args": { + "code": item.get("executable_code", ""), + "language": item.get("language", "python"), # Default to python + }, + "id": item.get("id", ""), + } + converted_blocks.append(server_tool_call_block) + elif item_type == "code_execution_result": + # Map outcome to status: OUTCOME_OK (1) → success, else → error + outcome = item.get("outcome", 1) + status = "success" if outcome == 1 else "error" + server_tool_result_block: types.ServerToolResult = { + "type": "server_tool_result", + "tool_call_id": item.get("tool_call_id", ""), + "status": status, # type: ignore[typeddict-item] + "output": item.get("code_execution_result", ""), + } + server_tool_result_block["extras"] = {"block_type": item_type} + # Preserve original outcome in extras + if outcome is not None: + server_tool_result_block["extras"]["outcome"] = outcome + converted_blocks.append(server_tool_result_block) + elif item_type == "text": + converted_blocks.append(cast("types.TextContentBlock", item)) + else: + # Unknown type, preserve as non-standard + converted_blocks.append({"type": "non_standard", "value": item}) + else: + # Non-dict, non-string content + converted_blocks.append({"type": "non_standard", "value": item}) # type: ignore[unreachable] + + grounding_metadata = message.response_metadata.get("grounding_metadata") + if grounding_metadata: + citations = translate_grounding_metadata_to_citations(grounding_metadata) + + for block in converted_blocks: + if block["type"] == "text" and citations: + # Add citations to text blocks (only the first text block) + block["annotations"] = cast("list[types.Annotation]", citations) + break + + # Audio is stored on the message.additional_kwargs + audio_data = message.additional_kwargs.get("audio") + if audio_data and isinstance(audio_data, bytes): + audio_block_kwargs: types.AudioContentBlock = { + "type": "audio", + "base64": _bytes_to_b64_str(audio_data), + "mime_type": "audio/wav", # Default to WAV for Google GenAI + } + converted_blocks.append(audio_block_kwargs) + + # Add any missing tool calls from message.tool_calls field + content_tool_call_ids = { + block.get("id") + for block in converted_blocks + if isinstance(block, dict) and block.get("type") == "tool_call" + } + for tool_call in message.tool_calls: + id_ = tool_call.get("id") + if id_ and id_ not in content_tool_call_ids: + missing_tool_call_block: types.ToolCall = { + "type": "tool_call", + "id": id_, + "name": tool_call["name"], + "args": tool_call["args"], + } + converted_blocks.append(missing_tool_call_block) + + return converted_blocks + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with Google (GenAI) content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_genai(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a chunk with Google (GenAI) content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_genai(message) + + +def _register_google_genai_translator() -> None: + """Register the Google (GenAI) translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("google_genai", translate_content, translate_content_chunk) + + +_register_google_genai_translator() diff --git a/libs/core/langchain_core/messages/block_translators/google_vertexai.py b/libs/core/langchain_core/messages/block_translators/google_vertexai.py new file mode 100644 index 0000000..016f146 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/google_vertexai.py @@ -0,0 +1,21 @@ +"""Derivations of standard content blocks from Google (VertexAI) content.""" + +from langchain_core.messages.block_translators.google_genai import ( + translate_content, + translate_content_chunk, +) + + +def _register_google_vertexai_translator() -> None: + """Register the Google (VertexAI) translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("google_vertexai", translate_content, translate_content_chunk) + + +_register_google_vertexai_translator() diff --git a/libs/core/langchain_core/messages/block_translators/groq.py b/libs/core/langchain_core/messages/block_translators/groq.py new file mode 100644 index 0000000..773f3fd --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/groq.py @@ -0,0 +1,157 @@ +"""Derivations of standard content blocks from Groq content.""" + +import json +import re +from typing import Any + +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages import content as types +from langchain_core.messages.base import _extract_reasoning_from_additional_kwargs + + +def _populate_extras( + standard_block: types.ContentBlock, block: dict[str, Any], known_fields: set[str] +) -> types.ContentBlock: + """Mutate a block, populating extras.""" + if standard_block.get("type") == "non_standard": + return standard_block + + for key, value in block.items(): + if key not in known_fields: + if "extras" not in standard_block: + # Below type-ignores are because mypy thinks a non-standard block can + # get here, although we exclude them above. + standard_block["extras"] = {} # type: ignore[typeddict-unknown-key] + standard_block["extras"][key] = value # type: ignore[typeddict-item] + + return standard_block + + +def _parse_code_json(s: str) -> dict[str, Any]: + """Extract Python code from Groq built-in tool content. + + Extracts the value of the 'code' field from a string of the form: + {"code": some_arbitrary_text_with_unescaped_quotes} + + As Groq may not escape quotes in the executed tools, e.g.: + ``` + '{"code": "import math; print("The square root of 101 is: "); print(math.sqrt(101))"}' + ``` + """ # noqa: E501 + m = re.fullmatch(r'\s*\{\s*"code"\s*:\s*"(.*)"\s*\}\s*', s, flags=re.DOTALL) + if not m: + msg = ( + "Could not extract Python code from Groq tool arguments. " + "Expected a JSON object with a 'code' field." + ) + raise ValueError(msg) + return {"code": m.group(1)} + + +def _convert_to_v1_from_groq(message: AIMessage) -> list[types.ContentBlock]: + """Convert groq message content to v1 format.""" + content_blocks: list[types.ContentBlock] = [] + + if reasoning_block := _extract_reasoning_from_additional_kwargs(message): + content_blocks.append(reasoning_block) + + if executed_tools := message.additional_kwargs.get("executed_tools"): + for idx, executed_tool in enumerate(executed_tools): + args: dict[str, Any] | None = None + if arguments := executed_tool.get("arguments"): + try: + args = json.loads(arguments) + except json.JSONDecodeError: + if executed_tool.get("type") == "python": + try: + args = _parse_code_json(arguments) + except ValueError: + continue + elif ( + executed_tool.get("type") == "function" + and executed_tool.get("name") == "python" + ): + # GPT-OSS + args = {"code": arguments} + else: + continue + if isinstance(args, dict): + name = "" + if executed_tool.get("type") == "search": + name = "web_search" + elif executed_tool.get("type") == "python" or ( + executed_tool.get("type") == "function" + and executed_tool.get("name") == "python" + ): + name = "code_interpreter" + server_tool_call: types.ServerToolCall = { + "type": "server_tool_call", + "name": name, + "id": str(idx), + "args": args, + } + content_blocks.append(server_tool_call) + if tool_output := executed_tool.get("output"): + tool_result: types.ServerToolResult = { + "type": "server_tool_result", + "tool_call_id": str(idx), + "output": tool_output, + "status": "success", + } + known_fields = {"type", "arguments", "index", "output"} + _populate_extras(tool_result, executed_tool, known_fields) + content_blocks.append(tool_result) + + if isinstance(message.content, str) and message.content: + content_blocks.append({"type": "text", "text": message.content}) + + content_blocks.extend( + { + "type": "tool_call", + "name": tool_call["name"], + "args": tool_call["args"], + "id": tool_call.get("id"), + } + for tool_call in message.tool_calls + ) + + return content_blocks + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with groq content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_groq(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a message chunk with groq content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + return _convert_to_v1_from_groq(message) + + +def _register_groq_translator() -> None: + """Register the groq translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("groq", translate_content, translate_content_chunk) + + +_register_groq_translator() diff --git a/libs/core/langchain_core/messages/block_translators/langchain_v0.py b/libs/core/langchain_core/messages/block_translators/langchain_v0.py new file mode 100644 index 0000000..3d25e19 --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/langchain_v0.py @@ -0,0 +1,303 @@ +"""Derivations of standard content blocks from LangChain v0 multimodal content.""" + +from typing import Any, cast + +from langchain_core.messages import content as types + + +def _convert_v0_multimodal_input_to_v1( + content: list[types.ContentBlock], +) -> list[types.ContentBlock]: + """Convert v0 multimodal blocks to v1 format. + + During the `content_blocks` parsing process, we wrap blocks not recognized as a v1 + block as a `'non_standard'` block with the original block stored in the `value` + field. This function attempts to unpack those blocks and convert any v0 format + blocks to v1 format. + + If conversion fails, the block is left as a `'non_standard'` block. + + Args: + content: List of content blocks to process. + + Returns: + v1 content blocks. + """ + converted_blocks = [] + unpacked_blocks: list[dict[str, Any]] = [ + cast("dict[str, Any]", block) + if block.get("type") != "non_standard" + else block["value"] # type: ignore[typeddict-item] # this is only non-standard blocks + for block in content + ] + for block in unpacked_blocks: + if block.get("type") in {"image", "audio", "file"} and "source_type" in block: + converted_block = _convert_legacy_v0_content_block_to_v1(block) + converted_blocks.append(cast("types.ContentBlock", converted_block)) + elif block.get("type") in types.KNOWN_BLOCK_TYPES: + # Guard in case this function is used outside of the .content_blocks flow + converted_blocks.append(cast("types.ContentBlock", block)) + else: + converted_blocks.append({"type": "non_standard", "value": block}) + + return converted_blocks + + +def _convert_legacy_v0_content_block_to_v1( + block: dict[str, Any], +) -> types.ContentBlock | dict[str, Any]: + """Convert a LangChain v0 content block to v1 format. + + Preserves unknown keys as extras to avoid data loss. + + Returns the original block unchanged if it's not in v0 format. + """ + + def _extract_v0_extras( + block_dict: dict[str, Any], known_keys: set[str] + ) -> dict[str, Any]: + """Extract unknown keys from v0 block to preserve as extras. + + Args: + block_dict: The original v0 block dictionary. + known_keys: Set of keys known to be part of the v0 format for this block. + + Returns: + A dictionary of extra keys not part of the known v0 format. + """ + return {k: v for k, v in block_dict.items() if k not in known_keys} + + # Check if this is actually a v0 format block + block_type = block.get("type") + if block_type not in {"image", "audio", "file"} or "source_type" not in block: + # Not a v0 format block, return unchanged + return block + + if block.get("type") == "image": + source_type = block.get("source_type") + if source_type == "url": + # image-url + known_keys = {"mime_type", "type", "source_type", "url"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_image_block( + url=block["url"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + # Don't construct with an ID if not present in original block + v1_image_url = types.ImageContentBlock(type="image", url=block["url"]) + if block.get("mime_type"): + v1_image_url["mime_type"] = block["mime_type"] + + v1_image_url["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_image_url["extras"][key] = value + if v1_image_url["extras"] == {}: + del v1_image_url["extras"] + + return v1_image_url + if source_type == "base64": + # image-base64 + known_keys = {"mime_type", "type", "source_type", "data"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_image_block( + base64=block["data"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + v1_image_base64 = types.ImageContentBlock( + type="image", base64=block["data"] + ) + if block.get("mime_type"): + v1_image_base64["mime_type"] = block["mime_type"] + + v1_image_base64["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_image_base64["extras"][key] = value + if v1_image_base64["extras"] == {}: + del v1_image_base64["extras"] + + return v1_image_base64 + if source_type == "id": + # image-id + known_keys = {"type", "source_type", "id"} + extras = _extract_v0_extras(block, known_keys) + # For id `source_type`, `id` is the file reference, not block ID + v1_image_id = types.ImageContentBlock(type="image", file_id=block["id"]) + + v1_image_id["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_image_id["extras"][key] = value + if v1_image_id["extras"] == {}: + del v1_image_id["extras"] + + return v1_image_id + elif block.get("type") == "audio": + source_type = block.get("source_type") + if source_type == "url": + # audio-url + known_keys = {"mime_type", "type", "source_type", "url"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_audio_block( + url=block["url"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + # Don't construct with an ID if not present in original block + v1_audio_url: types.AudioContentBlock = types.AudioContentBlock( + type="audio", url=block["url"] + ) + if block.get("mime_type"): + v1_audio_url["mime_type"] = block["mime_type"] + + v1_audio_url["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_audio_url["extras"][key] = value + if v1_audio_url["extras"] == {}: + del v1_audio_url["extras"] + + return v1_audio_url + if source_type == "base64": + # audio-base64 + known_keys = {"mime_type", "type", "source_type", "data"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_audio_block( + base64=block["data"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + v1_audio_base64: types.AudioContentBlock = types.AudioContentBlock( + type="audio", base64=block["data"] + ) + if block.get("mime_type"): + v1_audio_base64["mime_type"] = block["mime_type"] + + v1_audio_base64["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_audio_base64["extras"][key] = value + if v1_audio_base64["extras"] == {}: + del v1_audio_base64["extras"] + + return v1_audio_base64 + if source_type == "id": + # audio-id + known_keys = {"type", "source_type", "id"} + extras = _extract_v0_extras(block, known_keys) + v1_audio_id: types.AudioContentBlock = types.AudioContentBlock( + type="audio", file_id=block["id"] + ) + + v1_audio_id["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_audio_id["extras"][key] = value + if v1_audio_id["extras"] == {}: + del v1_audio_id["extras"] + + return v1_audio_id + elif block.get("type") == "file": + source_type = block.get("source_type") + if source_type == "url": + # file-url + known_keys = {"mime_type", "type", "source_type", "url"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_file_block( + url=block["url"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + v1_file_url: types.FileContentBlock = types.FileContentBlock( + type="file", url=block["url"] + ) + if block.get("mime_type"): + v1_file_url["mime_type"] = block["mime_type"] + + v1_file_url["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_file_url["extras"][key] = value + if v1_file_url["extras"] == {}: + del v1_file_url["extras"] + + return v1_file_url + if source_type == "base64": + # file-base64 + known_keys = {"mime_type", "type", "source_type", "data"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_file_block( + base64=block["data"], + mime_type=block.get("mime_type"), + id=block["id"], + **extras, + ) + + v1_file_base64: types.FileContentBlock = types.FileContentBlock( + type="file", base64=block["data"] + ) + if block.get("mime_type"): + v1_file_base64["mime_type"] = block["mime_type"] + + v1_file_base64["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_file_base64["extras"][key] = value + if v1_file_base64["extras"] == {}: + del v1_file_base64["extras"] + + return v1_file_base64 + if source_type == "id": + # file-id + known_keys = {"type", "source_type", "id"} + extras = _extract_v0_extras(block, known_keys) + return types.create_file_block(file_id=block["id"], **extras) + if source_type == "text": + # file-text + known_keys = {"mime_type", "type", "source_type", "url"} + extras = _extract_v0_extras(block, known_keys) + if "id" in block: + return types.create_plaintext_block( + # In v0, URL points to the text file content + # TODO: attribute this claim + text=block["url"], + id=block["id"], + **extras, + ) + + v1_file_text: types.PlainTextContentBlock = types.PlainTextContentBlock( + type="text-plain", text=block["url"], mime_type="text/plain" + ) + if block.get("mime_type"): + v1_file_text["mime_type"] = block["mime_type"] + + v1_file_text["extras"] = {} + for key, value in extras.items(): + if value is not None: + v1_file_text["extras"][key] = value + if v1_file_text["extras"] == {}: + del v1_file_text["extras"] + + return v1_file_text + + # If we can't convert, return the block unchanged + return block diff --git a/libs/core/langchain_core/messages/block_translators/openai.py b/libs/core/langchain_core/messages/block_translators/openai.py new file mode 100644 index 0000000..86c201d --- /dev/null +++ b/libs/core/langchain_core/messages/block_translators/openai.py @@ -0,0 +1,1089 @@ +"""Derivations of standard content blocks from OpenAI content.""" + +from __future__ import annotations + +import json +import warnings +from typing import TYPE_CHECKING, Any, Literal, cast + +from langchain_core.language_models._utils import ( + _parse_data_uri, + is_openai_data_block, +) +from langchain_core.messages import AIMessageChunk +from langchain_core.messages import content as types + +if TYPE_CHECKING: + from collections.abc import Iterator + + from langchain_core.messages import AIMessage + + +def convert_to_openai_image_block(block: dict[str, Any]) -> dict[str, Any]: + """Convert `ImageContentBlock` to format expected by OpenAI Chat Completions. + + Args: + block: The image content block to convert. + + Raises: + ValueError: If required keys are missing. + ValueError: If source type is unsupported. + + Returns: + The formatted image content block. + """ + if "url" in block: + return { + "type": "image_url", + "image_url": { + "url": block["url"], + }, + } + if "base64" in block or block.get("source_type") == "base64": + if "mime_type" not in block: + error_message = "mime_type key is required for base64 data." + raise ValueError(error_message) + mime_type = block["mime_type"] + base64_data = block["data"] if "data" in block else block["base64"] + return { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_data}", + }, + } + error_message = "Unsupported source type. Only 'url' and 'base64' are supported." + raise ValueError(error_message) + + +def convert_to_openai_data_block( + block: dict[str, Any], + api: Literal["chat/completions", "responses"] = "chat/completions", +) -> dict[str, Any]: + """Format standard data content block to format expected by OpenAI. + + "Standard data content block" can include old-style LangChain v0 blocks + (URLContentBlock, Base64ContentBlock, IDContentBlock) or new ones. + + Args: + block: The content block to convert. + api: The OpenAI API being targeted. Either "chat/completions" or "responses". + + Raises: + ValueError: If required keys are missing. + ValueError: If file URLs are used with Chat Completions API. + ValueError: If block type is unsupported. + + Returns: + The formatted content block. + """ + if block["type"] == "image": + chat_completions_block = convert_to_openai_image_block(block) + if api == "responses": + formatted_block = { + "type": "input_image", + "image_url": chat_completions_block["image_url"]["url"], + } + if chat_completions_block["image_url"].get("detail"): + formatted_block["detail"] = chat_completions_block["image_url"][ + "detail" + ] + else: + formatted_block = chat_completions_block + + elif block["type"] == "file": + if block.get("source_type") == "base64" or "base64" in block: + # Handle v0 format (Base64CB): {"source_type": "base64", "data": "...", ...} + # Handle v1 format (IDCB): {"base64": "...", ...} + base64_data = block["data"] if "source_type" in block else block["base64"] + file = {"file_data": f"data:{block['mime_type']};base64,{base64_data}"} + if filename := block.get("filename"): + file["filename"] = filename + elif (extras := block.get("extras")) and ("filename" in extras): + file["filename"] = extras["filename"] + elif (extras := block.get("metadata")) and ("filename" in extras): + # Backward compat + file["filename"] = extras["filename"] + else: + # Can't infer filename; set a placeholder default for compatibility. + file["filename"] = "LC_AUTOGENERATED" + warnings.warn( + "OpenAI may require a filename for file uploads. Specify a filename" + " in the content block, e.g.: {'type': 'file', 'mime_type': " + "'...', 'base64': '...', 'filename': 'my-file.pdf'}. " + "Using placeholder filename 'LC_AUTOGENERATED'.", + stacklevel=1, + ) + formatted_block = {"type": "file", "file": file} + if api == "responses": + formatted_block = {"type": "input_file", **formatted_block["file"]} + elif block.get("source_type") == "id" or "file_id" in block: + # Handle v0 format (IDContentBlock): {"source_type": "id", "id": "...", ...} + # Handle v1 format (IDCB): {"file_id": "...", ...} + file_id = block["id"] if "source_type" in block else block["file_id"] + formatted_block = {"type": "file", "file": {"file_id": file_id}} + if api == "responses": + formatted_block = {"type": "input_file", **formatted_block["file"]} + elif "url" in block: # Intentionally do not check for source_type="url" + if api == "chat/completions": + error_msg = "OpenAI Chat Completions does not support file URLs." + raise ValueError(error_msg) + # Only supported by Responses API; return in that format + formatted_block = {"type": "input_file", "file_url": block["url"]} + else: + error_msg = "Keys base64, url, or file_id required for file blocks." + raise ValueError(error_msg) + + elif block["type"] == "audio": + if "base64" in block or block.get("source_type") == "base64": + # Handle v0 format: {"source_type": "base64", "data": "...", ...} + # Handle v1 format: {"base64": "...", ...} + base64_data = block["data"] if "source_type" in block else block["base64"] + audio_format = block["mime_type"].split("/")[-1] + formatted_block = { + "type": "input_audio", + "input_audio": {"data": base64_data, "format": audio_format}, + } + else: + error_msg = "Key base64 is required for audio blocks." + raise ValueError(error_msg) + else: + error_msg = f"Block of type {block['type']} is not supported." + raise ValueError(error_msg) + + return formatted_block + + +# v1 / Chat Completions +def _convert_to_v1_from_chat_completions( + message: AIMessage, +) -> list[types.ContentBlock]: + """Mutate a Chat Completions message to v1 format.""" + content_blocks: list[types.ContentBlock] = [] + if isinstance(message.content, str): + if message.content: + content_blocks = [{"type": "text", "text": message.content}] + else: + content_blocks = [] + + for tool_call in message.tool_calls: + content_blocks.append( + { + "type": "tool_call", + "name": tool_call["name"], + "args": tool_call["args"], + "id": tool_call.get("id"), + } + ) + + return content_blocks + + +def _convert_to_v1_from_chat_completions_input( + content: list[types.ContentBlock], +) -> list[types.ContentBlock]: + """Convert OpenAI Chat Completions format blocks to v1 format. + + During the `content_blocks` parsing process, we wrap blocks not recognized as a v1 + block as a `'non_standard'` block with the original block stored in the `value` + field. This function attempts to unpack those blocks and convert any blocks that + might be OpenAI format to v1 ContentBlocks. + + If conversion fails, the block is left as a `'non_standard'` block. + + Args: + content: List of content blocks to process. + + Returns: + Updated list with OpenAI blocks converted to v1 format. + """ + converted_blocks = [] + unpacked_blocks: list[dict[str, Any]] = [ + cast("dict[str, Any]", block) + if block.get("type") != "non_standard" + else block["value"] # type: ignore[typeddict-item] # this is only non-standard blocks + for block in content + ] + for block in unpacked_blocks: + if block.get("type") in { + "image_url", + "input_audio", + "file", + } and is_openai_data_block(block): + converted_block = _convert_openai_format_to_data_block(block) + # If conversion succeeded, use it; otherwise keep as non_standard + if ( + isinstance(converted_block, dict) + and converted_block.get("type") in types.KNOWN_BLOCK_TYPES + ): + converted_blocks.append(cast("types.ContentBlock", converted_block)) + else: + converted_blocks.append({"type": "non_standard", "value": block}) + elif block.get("type") in types.KNOWN_BLOCK_TYPES: + converted_blocks.append(cast("types.ContentBlock", block)) + else: + converted_blocks.append({"type": "non_standard", "value": block}) + + return converted_blocks + + +def _convert_to_v1_from_chat_completions_chunk( + chunk: AIMessageChunk, +) -> list[types.ContentBlock]: + """Mutate a Chat Completions chunk to v1 format.""" + content_blocks: list[types.ContentBlock] = [] + if isinstance(chunk.content, str): + if chunk.content: + content_blocks = [{"type": "text", "text": chunk.content}] + else: + content_blocks = [] + + if chunk.chunk_position == "last": + for tool_call in chunk.tool_calls: + content_blocks.append( + { + "type": "tool_call", + "name": tool_call["name"], + "args": tool_call["args"], + "id": tool_call.get("id"), + } + ) + + else: + for tool_call_chunk in chunk.tool_call_chunks: + tc: types.ToolCallChunk = { + "type": "tool_call_chunk", + "id": tool_call_chunk.get("id"), + "name": tool_call_chunk.get("name"), + "args": tool_call_chunk.get("args"), + } + if (idx := tool_call_chunk.get("index")) is not None: + tc["index"] = idx + content_blocks.append(tc) + + return content_blocks + + +def _convert_from_v1_to_chat_completions(message: AIMessage) -> AIMessage: + """Convert a v1 message to the Chat Completions format.""" + if isinstance(message.content, list): + new_content: list[Any] = [] + for block in message.content: + if isinstance(block, dict): + block_type = block.get("type") + if block_type == "text": + # Strip annotations + new_content.append({"type": "text", "text": block["text"]}) + elif block_type in {"reasoning", "tool_call"}: + pass + else: + new_content.append(block) + else: + new_content.append(block) + return message.model_copy(update={"content": new_content}) + + return message + + +# Responses +_FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__" + + +def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage: + """Convert v0 AIMessage into `output_version="responses/v1"` format.""" + # Only update ChatOpenAI v0.3 AIMessages + is_chatopenai_v03 = ( + isinstance(message.content, list) + and all(isinstance(b, dict) for b in message.content) + ) and ( + any( + item in message.additional_kwargs + for item in [ + "reasoning", + "tool_outputs", + "refusal", + _FUNCTION_CALL_IDS_MAP_KEY, + ] + ) + or ( + isinstance(message.id, str) + and message.id.startswith("msg_") + and (response_id := message.response_metadata.get("id")) + and isinstance(response_id, str) + and response_id.startswith("resp_") + ) + ) + if not is_chatopenai_v03: + return message + + content_order = [ + "reasoning", + "code_interpreter_call", + "mcp_call", + "image_generation_call", + "text", + "refusal", + "function_call", + "computer_call", + "mcp_list_tools", + "mcp_approval_request", + # N. B. "web_search_call" and "file_search_call" were not passed back in + # in v0.3 + ] + + # Build a bucket for every known block type + buckets: dict[str, list[Any]] = {key: [] for key in content_order} + unknown_blocks = [] + + # Reasoning + if reasoning := message.additional_kwargs.get("reasoning"): + if "type" not in reasoning: + reasoning = {**reasoning, "type": "reasoning"} + buckets["reasoning"].append(reasoning) + + # Refusal + if refusal := message.additional_kwargs.get("refusal"): + buckets["refusal"].append({"type": "refusal", "refusal": refusal}) + + # Text + for block in message.content: + if isinstance(block, dict) and block.get("type") == "text": + block_copy = block.copy() + if isinstance(message.id, str) and message.id.startswith("msg_"): + block_copy["id"] = message.id + buckets["text"].append(block_copy) + else: + unknown_blocks.append(block) + + # Function calls + function_call_ids = message.additional_kwargs.get(_FUNCTION_CALL_IDS_MAP_KEY) + if ( + isinstance(message, AIMessageChunk) + and len(message.tool_call_chunks) == 1 + and message.chunk_position != "last" + ): + # Isolated chunk + tool_call_chunk = message.tool_call_chunks[0] + function_call = { + "type": "function_call", + "name": tool_call_chunk.get("name"), + "arguments": tool_call_chunk.get("args"), + "call_id": tool_call_chunk.get("id"), + } + if function_call_ids is not None and ( + id_ := function_call_ids.get(tool_call_chunk.get("id")) + ): + function_call["id"] = id_ + buckets["function_call"].append(function_call) + else: + for tool_call in message.tool_calls: + function_call = { + "type": "function_call", + "name": tool_call["name"], + "arguments": json.dumps(tool_call["args"], ensure_ascii=False), + "call_id": tool_call["id"], + } + if function_call_ids is not None and ( + id_ := function_call_ids.get(tool_call["id"]) + ): + function_call["id"] = id_ + buckets["function_call"].append(function_call) + + # Tool outputs + tool_outputs = message.additional_kwargs.get("tool_outputs", []) + for block in tool_outputs: + if isinstance(block, dict) and (key := block.get("type")) and key in buckets: + buckets[key].append(block) + else: + unknown_blocks.append(block) + + # Re-assemble the content list in the canonical order + new_content = [] + for key in content_order: + new_content.extend(buckets[key]) + new_content.extend(unknown_blocks) + + new_additional_kwargs = dict(message.additional_kwargs) + new_additional_kwargs.pop("reasoning", None) + new_additional_kwargs.pop("refusal", None) + new_additional_kwargs.pop("tool_outputs", None) + + if "id" in message.response_metadata: + new_id = message.response_metadata["id"] + else: + new_id = message.id + + return message.model_copy( + update={ + "content": new_content, + "additional_kwargs": new_additional_kwargs, + "id": new_id, + }, + deep=False, + ) + + +def _convert_openai_format_to_data_block( + block: dict[str, Any], +) -> types.ContentBlock | dict[str, Any]: + """Convert OpenAI image/audio/file content block to respective v1 multimodal block. + + We expect that the incoming block is verified to be in OpenAI Chat Completions + format. + + If parsing fails, passes block through unchanged. + + Mappings (Chat Completions to LangChain v1): + - Image -> `ImageContentBlock` + - Audio -> `AudioContentBlock` + - File -> `FileContentBlock` + + """ + + # Extract extra keys to put them in `extras` + def _extract_extras( + block_dict: dict[str, Any], known_keys: set[str] + ) -> dict[str, Any]: + """Extract unknown keys from block to preserve as extras.""" + return {k: v for k, v in block_dict.items() if k not in known_keys} + + # base64-style image block + if (block["type"] == "image_url") and ( + parsed := _parse_data_uri(block["image_url"]["url"]) + ): + known_keys = {"type", "image_url"} + extras = _extract_extras(block, known_keys) + + # Also extract extras from nested image_url dict + image_url_known_keys = {"url"} + image_url_extras = _extract_extras(block["image_url"], image_url_known_keys) + + # Merge extras + all_extras = {**extras} + for key, value in image_url_extras.items(): + if key == "detail": # Don't rename + all_extras["detail"] = value + else: + all_extras[f"image_url_{key}"] = value + + return types.create_image_block( + # Even though this is labeled as `url`, it can be base64-encoded + base64=parsed["data"], + mime_type=parsed["mime_type"], + **all_extras, + ) + + # url-style image block + if (block["type"] == "image_url") and isinstance( + block["image_url"].get("url"), str + ): + known_keys = {"type", "image_url"} + extras = _extract_extras(block, known_keys) + + image_url_known_keys = {"url"} + image_url_extras = _extract_extras(block["image_url"], image_url_known_keys) + + all_extras = {**extras} + for key, value in image_url_extras.items(): + if key == "detail": # Don't rename + all_extras["detail"] = value + else: + all_extras[f"image_url_{key}"] = value + + return types.create_image_block( + url=block["image_url"]["url"], + **all_extras, + ) + + # base64-style audio block + # audio is only represented via raw data, no url or ID option + if block["type"] == "input_audio": + known_keys = {"type", "input_audio"} + extras = _extract_extras(block, known_keys) + + # Also extract extras from nested audio dict + audio_known_keys = {"data", "format"} + audio_extras = _extract_extras(block["input_audio"], audio_known_keys) + + all_extras = {**extras} + for key, value in audio_extras.items(): + all_extras[f"audio_{key}"] = value + + return types.create_audio_block( + base64=block["input_audio"]["data"], + mime_type=f"audio/{block['input_audio']['format']}", + **all_extras, + ) + + # id-style file block + if block.get("type") == "file" and "file_id" in block.get("file", {}): + known_keys = {"type", "file"} + extras = _extract_extras(block, known_keys) + + file_known_keys = {"file_id"} + file_extras = _extract_extras(block["file"], file_known_keys) + + all_extras = {**extras} + for key, value in file_extras.items(): + all_extras[f"file_{key}"] = value + + return types.create_file_block( + file_id=block["file"]["file_id"], + **all_extras, + ) + + # base64-style file block + if (block["type"] == "file") and ( + parsed := _parse_data_uri(block["file"]["file_data"]) + ): + known_keys = {"type", "file"} + extras = _extract_extras(block, known_keys) + + file_known_keys = {"file_data", "filename"} + file_extras = _extract_extras(block["file"], file_known_keys) + + all_extras = {**extras} + for key, value in file_extras.items(): + all_extras[f"file_{key}"] = value + + filename = block["file"].get("filename") + return types.create_file_block( + base64=parsed["data"], + mime_type="application/pdf", + filename=filename, + **all_extras, + ) + + # Escape hatch + return block + + +# v1 / Responses +def _convert_annotation_to_v1(annotation: dict[str, Any]) -> types.Annotation: + annotation_type = annotation.get("type") + + if annotation_type == "url_citation": + known_fields = { + "type", + "url", + "title", + "cited_text", + "start_index", + "end_index", + } + url_citation = cast("types.Citation", {}) + for field in ("end_index", "start_index", "title"): + if field in annotation: + url_citation[field] = annotation[field] + url_citation["type"] = "citation" + url_citation["url"] = annotation["url"] + for field, value in annotation.items(): + if field not in known_fields: + if "extras" not in url_citation: + url_citation["extras"] = {} + url_citation["extras"][field] = value + return url_citation + + if annotation_type == "file_citation": + known_fields = { + "type", + "title", + "cited_text", + "start_index", + "end_index", + "filename", + } + document_citation: types.Citation = {"type": "citation"} + if "filename" in annotation: + document_citation["title"] = annotation["filename"] + for field, value in annotation.items(): + if field not in known_fields: + if "extras" not in document_citation: + document_citation["extras"] = {} + document_citation["extras"][field] = value + + return document_citation + + # TODO: standardise container_file_citation? + non_standard_annotation: types.NonStandardAnnotation = { + "type": "non_standard_annotation", + "value": annotation, + } + return non_standard_annotation + + +def _explode_reasoning(block: dict[str, Any]) -> Iterator[types.ReasoningContentBlock]: + if "summary" not in block: + yield cast("types.ReasoningContentBlock", block) + return + + known_fields = {"type", "reasoning", "id", "index"} + unknown_fields = [ + field for field in block if field != "summary" and field not in known_fields + ] + if unknown_fields: + block["extras"] = {} + for field in unknown_fields: + block["extras"][field] = block.pop(field) + + if not block["summary"]: + # [{'id': 'rs_...', 'summary': [], 'type': 'reasoning', 'index': 0}] + block = {k: v for k, v in block.items() if k != "summary"} + if "index" in block: + meaningful_idx = f"{block['index']}_0" + block["index"] = f"lc_rs_{meaningful_idx.encode().hex()}" + yield cast("types.ReasoningContentBlock", block) + return + + # Common part for every exploded line, except 'summary' + common = {k: v for k, v in block.items() if k in known_fields} + + # Optional keys that must appear only in the first exploded item + first_only = block.pop("extras", None) + + for idx, part in enumerate(block["summary"]): + new_block = dict(common) + new_block["reasoning"] = part.get("text", "") + if idx == 0 and first_only: + new_block.update(first_only) + if "index" in new_block: + summary_index = part.get("index", 0) + meaningful_idx = f"{new_block['index']}_{summary_index}" + new_block["index"] = f"lc_rs_{meaningful_idx.encode().hex()}" + + yield cast("types.ReasoningContentBlock", new_block) + + +def _convert_to_v1_from_responses(message: AIMessage) -> list[types.ContentBlock]: + """Convert a Responses message to v1 format.""" + + def _iter_blocks() -> Iterator[types.ContentBlock]: + for raw_block in message.content: + if not isinstance(raw_block, dict): + continue + block = raw_block.copy() + block_type = block.get("type") + + if block_type == "text": + if "text" not in block: + block["text"] = "" + if "annotations" in block: + block["annotations"] = [ + _convert_annotation_to_v1(a) for a in block["annotations"] + ] + if "index" in block: + block["index"] = f"lc_txt_{block['index']}" + yield cast("types.TextContentBlock", block) + + elif block_type == "reasoning": + yield from _explode_reasoning(block) + + elif block_type == "image_generation_call" and ( + result := block.get("result") + ): + new_block = {"type": "image", "base64": result} + if output_format := block.get("output_format"): + new_block["mime_type"] = f"image/{output_format}" + if "id" in block: + new_block["id"] = block["id"] + if "index" in block: + new_block["index"] = f"lc_img_{block['index']}" + for extra_key in ( + "status", + "background", + "output_format", + "quality", + "revised_prompt", + "size", + ): + if extra_key in block: + if "extras" not in new_block: + new_block["extras"] = {} + new_block["extras"][extra_key] = block[extra_key] + yield cast("types.ImageContentBlock", new_block) + + elif block_type == "function_call": + tool_call_block: ( + types.ToolCall | types.InvalidToolCall | types.ToolCallChunk | None + ) = None + call_id = block.get("call_id", "") + + if ( + isinstance(message, AIMessageChunk) + and len(message.tool_call_chunks) == 1 + and message.chunk_position != "last" + ): + tool_call_block = message.tool_call_chunks[0].copy() # type: ignore[assignment] + elif call_id: + for tool_call in message.tool_calls or []: + if tool_call.get("id") == call_id: + tool_call_block = { + "type": "tool_call", + "name": tool_call["name"], + "args": tool_call["args"], + "id": tool_call.get("id"), + } + break + else: + for invalid_tool_call in message.invalid_tool_calls or []: + if invalid_tool_call.get("id") == call_id: + tool_call_block = invalid_tool_call.copy() + break + if tool_call_block: + if "id" in block: + if "extras" not in tool_call_block: + tool_call_block["extras"] = {} + tool_call_block["extras"]["item_id"] = block["id"] + if "index" in block: + tool_call_block["index"] = f"lc_tc_{block['index']}" + for extra_key in ("status", "namespace"): + if extra_key in block: + if "extras" not in tool_call_block: + tool_call_block["extras"] = {} + tool_call_block["extras"][extra_key] = block[extra_key] + yield tool_call_block + + elif block_type == "web_search_call": + web_search_call = { + "type": "server_tool_call", + "name": "web_search", + "args": {}, + "id": block["id"], + } + if "index" in block: + web_search_call["index"] = f"lc_wsc_{block['index']}" + + sources: dict[str, Any] | None = None + if "action" in block and isinstance(block["action"], dict): + if "sources" in block["action"]: + sources = block["action"]["sources"] + web_search_call["args"] = { + k: v for k, v in block["action"].items() if k != "sources" + } + for key in block: + if key not in {"type", "id", "action", "status", "index"}: + web_search_call[key] = block[key] + + yield cast("types.ServerToolCall", web_search_call) + + # If .content already has web_search_result, don't add + if not any( + isinstance(other_block, dict) + and other_block.get("type") == "web_search_result" + and other_block.get("id") == block["id"] + for other_block in message.content + ): + web_search_result = { + "type": "server_tool_result", + "tool_call_id": block["id"], + } + if sources: + web_search_result["output"] = {"sources": sources} + + status = block.get("status") + if status == "failed": + web_search_result["status"] = "error" + elif status == "completed": + web_search_result["status"] = "success" + elif status: + web_search_result["extras"] = {"status": status} + if "index" in block and isinstance(block["index"], int): + web_search_result["index"] = f"lc_wsr_{block['index'] + 1}" + yield cast("types.ServerToolResult", web_search_result) + + elif block_type == "file_search_call": + file_search_call = { + "type": "server_tool_call", + "name": "file_search", + "id": block["id"], + "args": {"queries": block.get("queries", [])}, + } + if "index" in block: + file_search_call["index"] = f"lc_fsc_{block['index']}" + + for key in block: + if key not in { + "type", + "id", + "queries", + "results", + "status", + "index", + }: + file_search_call[key] = block[key] + + yield cast("types.ServerToolCall", file_search_call) + + file_search_result = { + "type": "server_tool_result", + "tool_call_id": block["id"], + } + if file_search_output := block.get("results"): + file_search_result["output"] = file_search_output + + status = block.get("status") + if status == "failed": + file_search_result["status"] = "error" + elif status == "completed": + file_search_result["status"] = "success" + elif status: + file_search_result["extras"] = {"status": status} + if "index" in block and isinstance(block["index"], int): + file_search_result["index"] = f"lc_fsr_{block['index'] + 1}" + yield cast("types.ServerToolResult", file_search_result) + + elif block_type == "code_interpreter_call": + code_interpreter_call = { + "type": "server_tool_call", + "name": "code_interpreter", + "id": block["id"], + } + if "code" in block: + code_interpreter_call["args"] = {"code": block["code"]} + if "index" in block: + code_interpreter_call["index"] = f"lc_cic_{block['index']}" + known_fields = { + "type", + "id", + "outputs", + "status", + "code", + "extras", + "index", + } + for key in block: + if key not in known_fields: + if "extras" not in code_interpreter_call: + code_interpreter_call["extras"] = {} + code_interpreter_call["extras"][key] = block[key] + + code_interpreter_result = { + "type": "server_tool_result", + "tool_call_id": block["id"], + } + if "outputs" in block: + code_interpreter_result["output"] = block["outputs"] + + status = block.get("status") + if status == "failed": + code_interpreter_result["status"] = "error" + elif status == "completed": + code_interpreter_result["status"] = "success" + elif status: + code_interpreter_result["extras"] = {"status": status} + if "index" in block and isinstance(block["index"], int): + code_interpreter_result["index"] = f"lc_cir_{block['index'] + 1}" + + yield cast("types.ServerToolCall", code_interpreter_call) + yield cast("types.ServerToolResult", code_interpreter_result) + + elif block_type == "mcp_call": + mcp_call = { + "type": "server_tool_call", + "name": "remote_mcp", + "id": block["id"], + } + if (arguments := block.get("arguments")) and isinstance(arguments, str): + try: + mcp_call["args"] = json.loads(block["arguments"]) + except json.JSONDecodeError: + mcp_call["extras"] = {"arguments": arguments} + if "name" in block: + if "extras" not in mcp_call: + mcp_call["extras"] = {} + mcp_call["extras"]["tool_name"] = block["name"] + if "server_label" in block: + if "extras" not in mcp_call: + mcp_call["extras"] = {} + mcp_call["extras"]["server_label"] = block["server_label"] + if "index" in block: + mcp_call["index"] = f"lc_mcp_{block['index']}" + known_fields = { + "type", + "id", + "arguments", + "name", + "server_label", + "output", + "error", + "extras", + "index", + } + for key in block: + if key not in known_fields: + if "extras" not in mcp_call: + mcp_call["extras"] = {} + mcp_call["extras"][key] = block[key] + + yield cast("types.ServerToolCall", mcp_call) + + mcp_result = { + "type": "server_tool_result", + "tool_call_id": block["id"], + } + if mcp_output := block.get("output"): + mcp_result["output"] = mcp_output + + error = block.get("error") + if error: + if "extras" not in mcp_result: + mcp_result["extras"] = {} + mcp_result["extras"]["error"] = error + mcp_result["status"] = "error" + else: + mcp_result["status"] = "success" + + if "index" in block and isinstance(block["index"], int): + mcp_result["index"] = f"lc_mcpr_{block['index'] + 1}" + yield cast("types.ServerToolResult", mcp_result) + + elif block_type == "mcp_list_tools": + mcp_list_tools_call = { + "type": "server_tool_call", + "name": "mcp_list_tools", + "args": {}, + "id": block["id"], + } + if "server_label" in block: + mcp_list_tools_call["extras"] = {} + mcp_list_tools_call["extras"]["server_label"] = block[ + "server_label" + ] + if "index" in block: + mcp_list_tools_call["index"] = f"lc_mlt_{block['index']}" + known_fields = { + "type", + "id", + "name", + "server_label", + "tools", + "error", + "extras", + "index", + } + for key in block: + if key not in known_fields: + if "extras" not in mcp_list_tools_call: + mcp_list_tools_call["extras"] = {} + mcp_list_tools_call["extras"][key] = block[key] + + yield cast("types.ServerToolCall", mcp_list_tools_call) + + mcp_list_tools_result = { + "type": "server_tool_result", + "tool_call_id": block["id"], + } + if mcp_output := block.get("tools"): + mcp_list_tools_result["output"] = mcp_output + + error = block.get("error") + if error: + if "extras" not in mcp_list_tools_result: + mcp_list_tools_result["extras"] = {} + mcp_list_tools_result["extras"]["error"] = error + mcp_list_tools_result["status"] = "error" + else: + mcp_list_tools_result["status"] = "success" + + if "index" in block and isinstance(block["index"], int): + mcp_list_tools_result["index"] = f"lc_mltr_{block['index'] + 1}" + yield cast("types.ServerToolResult", mcp_list_tools_result) + + elif ( + block_type == "tool_search_call" and block.get("execution") == "server" + ): + tool_search_call: dict[str, Any] = { + "type": "server_tool_call", + "name": "tool_search", + "id": block["id"], + "args": block.get("arguments", {}), + } + if "index" in block: + tool_search_call["index"] = f"lc_tsc_{block['index']}" + extras: dict[str, Any] = {} + known = {"type", "id", "arguments", "index"} + for key in block: + if key not in known: + extras[key] = block[key] + if extras: + tool_search_call["extras"] = extras + yield cast("types.ServerToolCall", tool_search_call) + + elif ( + block_type == "tool_search_output" + and block.get("execution") == "server" + ): + tool_search_output: dict[str, Any] = { + "type": "server_tool_result", + "tool_call_id": block["id"], + "output": {"tools": block.get("tools", [])}, + } + status = block.get("status") + if status == "failed": + tool_search_output["status"] = "error" + elif status == "completed": + tool_search_output["status"] = "success" + if "index" in block and isinstance(block["index"], int): + tool_search_output["index"] = f"lc_tso_{block['index']}" + extras_out: dict[str, Any] = {"name": "tool_search"} + known_out = {"type", "id", "status", "tools", "index"} + for key in block: + if key not in known_out: + extras_out[key] = block[key] + if extras_out: + tool_search_output["extras"] = extras_out + yield cast("types.ServerToolResult", tool_search_output) + + elif block_type in types.KNOWN_BLOCK_TYPES: + yield cast("types.ContentBlock", block) + else: + new_block = {"type": "non_standard", "value": block} + if "index" in new_block["value"]: + new_block["index"] = f"lc_ns_{new_block['value'].pop('index')}" + yield cast("types.NonStandardContentBlock", new_block) + + return list(_iter_blocks()) + + +def translate_content(message: AIMessage) -> list[types.ContentBlock]: + """Derive standard content blocks from a message with OpenAI content. + + Args: + message: The message to translate. + + Returns: + The derived content blocks. + """ + if isinstance(message.content, str): + return _convert_to_v1_from_chat_completions(message) + message = _convert_from_v03_ai_message(message) + return _convert_to_v1_from_responses(message) + + +def translate_content_chunk(message: AIMessageChunk) -> list[types.ContentBlock]: + """Derive standard content blocks from a message chunk with OpenAI content. + + Args: + message: The message chunk to translate. + + Returns: + The derived content blocks. + """ + if isinstance(message.content, str): + return _convert_to_v1_from_chat_completions_chunk(message) + message = _convert_from_v03_ai_message(message) # type: ignore[assignment] + return _convert_to_v1_from_responses(message) + + +def _register_openai_translator() -> None: + """Register the OpenAI translator with the central registry. + + Run automatically when the module is imported. + """ + from langchain_core.messages.block_translators import ( # noqa: PLC0415 + register_translator, + ) + + register_translator("openai", translate_content, translate_content_chunk) + + +_register_openai_translator() diff --git a/libs/core/langchain_core/messages/chat.py b/libs/core/langchain_core/messages/chat.py new file mode 100644 index 0000000..6786efc --- /dev/null +++ b/libs/core/langchain_core/messages/chat.py @@ -0,0 +1,64 @@ +"""Chat Message.""" + +from typing import Any, Literal + +from typing_extensions import override + +from langchain_core.messages.base import ( + BaseMessage, + BaseMessageChunk, + merge_content, +) +from langchain_core.utils._merge import merge_dicts + + +class ChatMessage(BaseMessage): + """Message that can be assigned an arbitrary speaker (i.e. role).""" + + role: str + """The speaker / role of the Message.""" + + type: Literal["chat"] = "chat" + """The type of the message (used during serialization).""" + + +class ChatMessageChunk(ChatMessage, BaseMessageChunk): + """Chat Message chunk.""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["ChatMessageChunk"] = "ChatMessageChunk" # type: ignore[assignment] + """The type of the message (used during serialization).""" + + @override + def __add__(self, other: Any) -> BaseMessageChunk: # type: ignore[override] + if isinstance(other, ChatMessageChunk): + if self.role != other.role: + msg = "Cannot concatenate ChatMessageChunks with different roles." + raise ValueError(msg) + + return self.__class__( + role=self.role, + content=merge_content(self.content, other.content), + additional_kwargs=merge_dicts( + self.additional_kwargs, other.additional_kwargs + ), + response_metadata=merge_dicts( + self.response_metadata, other.response_metadata + ), + id=self.id, + ) + if isinstance(other, BaseMessageChunk): + return self.__class__( + role=self.role, + content=merge_content(self.content, other.content), + additional_kwargs=merge_dicts( + self.additional_kwargs, other.additional_kwargs + ), + response_metadata=merge_dicts( + self.response_metadata, other.response_metadata + ), + id=self.id, + ) + return super().__add__(other) diff --git a/libs/core/langchain_core/messages/content.py b/libs/core/langchain_core/messages/content.py new file mode 100644 index 0000000..b2e4185 --- /dev/null +++ b/libs/core/langchain_core/messages/content.py @@ -0,0 +1,1488 @@ +"""Standard, multimodal content blocks for Large Language Model I/O. + +This module provides standardized data structures for representing inputs to and outputs +from LLMs. The core abstraction is the **Content Block**, a `TypedDict`. + +**Rationale** + +Different LLM providers use distinct and incompatible API schemas. This module provides +a unified, provider-agnostic format to facilitate these interactions. A message to or +from a model is simply a list of content blocks, allowing for the natural interleaving +of text, images, and other content in a single ordered sequence. + +An adapter for a specific provider is responsible for translating this standard list of +blocks into the format required by its API. + +**Extensibility** + +Data **not yet mapped** to a standard block may be represented using the +`NonStandardContentBlock`, which allows for provider-specific data to be included +without losing the benefits of type checking and validation. + +Furthermore, provider-specific fields **within** a standard block are fully supported +by default in the `extras` field of each block. This allows for additional metadata +to be included without breaking the standard structure. For example, Google's thought +signature: + +```python +AIMessage( + content=[ + { + "type": "text", + "text": "J'adore la programmation.", + "extras": {"signature": "EpoWCpc..."}, # Thought signature + } + ], ... +) +``` + + +!!! note + + Following widespread adoption of [PEP 728](https://peps.python.org/pep-0728/), we + intend to add `extra_items=Any` as a param to Content Blocks. This will signify to + type checkers that additional provider-specific fields are allowed outside of the + `extras` field, and that will become the new standard approach to adding + provider-specific metadata. + + ??? note + + **Example with PEP 728 provider-specific fields:** + + ```python + # Content block definition + # NOTE: `extra_items=Any` + class TextContentBlock(TypedDict, extra_items=Any): + type: Literal["text"] + id: NotRequired[str] + text: str + annotations: NotRequired[list[Annotation]] + index: NotRequired[int] + ``` + + ```python + from langchain_core.messages.content import TextContentBlock + + # Create a text content block with provider-specific fields + my_block: TextContentBlock = { + # Add required fields + "type": "text", + "text": "Hello, world!", + # Additional fields not specified in the TypedDict + # These are valid with PEP 728 and are typed as Any + "openai_metadata": {"model": "gpt-5.5", "temperature": 0.7}, + "anthropic_usage": {"input_tokens": 10, "output_tokens": 20}, + "custom_field": "any value", + } + + # Mutating an existing block to add provider-specific fields + openai_data = my_block["openai_metadata"] # Type: Any + ``` + +**Example Usage** + +```python +# Direct construction +from langchain_core.messages.content import TextContentBlock, ImageContentBlock + +multimodal_message: AIMessage( + content_blocks=[ + TextContentBlock(type="text", text="What is shown in this image?"), + ImageContentBlock( + type="image", + url="https://www.langchain.com/images/brand/langchain_logo_text_w_white.png", + mime_type="image/png", + ), + ] +) + +# Using factories +from langchain_core.messages.content import create_text_block, create_image_block + +multimodal_message: AIMessage( + content=[ + create_text_block("What is shown in this image?"), + create_image_block( + url="https://www.langchain.com/images/brand/langchain_logo_text_w_white.png", + mime_type="image/png", + ), + ] +) +``` + +Factory functions offer benefits such as: + +- Automatic ID generation (when not provided) +- No need to manually specify the `type` field +""" + +from typing import Any, Literal, get_args, get_type_hints + +from typing_extensions import NotRequired, TypedDict + +from langchain_core.utils.utils import ensure_id + + +class Citation(TypedDict): + """Annotation for citing data from a document. + + !!! note + + `start`/`end` indices refer to the **response text**, + not the source text. This means that the indices are relative to the model's + response, not the original document (as specified in the `url`). + + !!! note "Factory function" + + `create_citation` may also be used as a factory to create a `Citation`. + Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["citation"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + url: NotRequired[str] + """URL of the document source.""" + + title: NotRequired[str] + """Source document title. + + For example, the page title for a web page or the title of a paper. + """ + + start_index: NotRequired[int] + """Start index of the **response text** (`TextContentBlock.text`).""" + + end_index: NotRequired[int] + """End index of the **response text** (`TextContentBlock.text`)""" + + cited_text: NotRequired[str] + """Excerpt of source text being cited.""" + + # NOTE: not including spans for the raw document text (such as `text_start_index` + # and `text_end_index`) as this is not currently supported by any provider. The + # thinking is that the `cited_text` should be sufficient for most use cases, and it + # is difficult to reliably extract spans from the raw document text across file + # formats or encoding schemes. + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class NonStandardAnnotation(TypedDict): + """Provider-specific annotation format.""" + + type: Literal["non_standard_annotation"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + value: dict[str, Any] + """Provider-specific annotation data.""" + + +Annotation = Citation | NonStandardAnnotation +"""A union of all defined `Annotation` types.""" + + +class TextContentBlock(TypedDict): + """Text output from a LLM. + + This typically represents the main text content of a message, such as the response + from a language model or the text of a user message. + + !!! note "Factory function" + + `create_text_block` may also be used as a factory to create a + `TextContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["text"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + text: str + """Block text.""" + + annotations: NotRequired[list[Annotation]] + """`Citation`s and other annotations.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ToolCall(TypedDict): + """Represents an AI's request to call a tool. + + Example: + ```python + {"name": "foo", "args": {"a": 1}, "id": "123"} + ``` + + This represents a request to call the tool named "foo" with arguments {"a": 1} + and an identifier of "123". + + !!! note "Factory function" + + `create_tool_call` may also be used as a factory to create a + `ToolCall`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["tool_call"] + """Used for discrimination.""" + + id: str | None + """An identifier associated with the tool call. + + An identifier is needed to associate a tool call request with a tool + call result in events when multiple concurrent tool calls are made. + """ + # TODO: Consider making this NotRequired[str] in the future. + + name: str + """The name of the tool to be called.""" + + args: dict[str, Any] + """The arguments to the tool call.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ToolCallChunk(TypedDict): + """A chunk of a tool call (yielded when streaming). + + When merging `ToolCallChunks` (e.g., via `AIMessageChunk.__add__`), + all string attributes are concatenated. Chunks are only merged if their + values of `index` are equal and not `None`. + + Example: + ```python + left_chunks = [ToolCallChunk(name="foo", args='{"a":', index=0)] + right_chunks = [ToolCallChunk(name=None, args="1}", index=0)] + + ( + AIMessageChunk(content="", tool_call_chunks=left_chunks) + + AIMessageChunk(content="", tool_call_chunks=right_chunks) + ).tool_call_chunks == [ToolCallChunk(name="foo", args='{"a":1}', index=0)] + ``` + """ + + # TODO: Consider making fields NotRequired[str] in the future. + + type: Literal["tool_call_chunk"] + """Used for serialization.""" + + id: str | None + """An identifier associated with the tool call. + + An identifier is needed to associate a tool call request with a tool + call result in events when multiple concurrent tool calls are made. + """ + # TODO: Consider making this NotRequired[str] in the future. + + name: str | None + """The name of the tool to be called.""" + + args: str | None + """The arguments to the tool call.""" + + index: NotRequired[int | str] + """The index of the tool call in a sequence.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class InvalidToolCall(TypedDict): + """Allowance for errors made by LLM. + + Here we add an `error` key to surface errors made during generation + (e.g., invalid JSON arguments.) + """ + + # TODO: Consider making fields NotRequired[str] in the future. + + type: Literal["invalid_tool_call"] + """Used for discrimination.""" + + id: str | None + """An identifier associated with the tool call. + + An identifier is needed to associate a tool call request with a tool + call result in events when multiple concurrent tool calls are made. + """ + # TODO: Consider making this NotRequired[str] in the future. + + name: str | None + """The name of the tool to be called.""" + + args: str | None + """The arguments to the tool call.""" + + error: str | None + """An error message associated with the tool call.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ServerToolCall(TypedDict): + """Tool call that is executed server-side. + + For example: code execution, web search, etc. + """ + + type: Literal["server_tool_call"] + """Used for discrimination.""" + + id: str + """An identifier associated with the tool call.""" + + name: str + """The name of the tool to be called.""" + + args: dict[str, Any] + """The arguments to the tool call.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ServerToolCallChunk(TypedDict): + """A chunk of a server-side tool call (yielded when streaming).""" + + type: Literal["server_tool_call_chunk"] + """Used for discrimination.""" + + name: NotRequired[str] + """The name of the tool to be called.""" + + args: NotRequired[str] + """JSON substring of the arguments to the tool call.""" + + id: NotRequired[str] + """Unique identifier for this server tool call chunk. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ServerToolResult(TypedDict): + """Result of a server-side tool call.""" + + type: Literal["server_tool_result"] + """Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this server tool result. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + tool_call_id: str + """ID of the corresponding server tool call.""" + + status: Literal["success", "error"] + """Execution status of the server-side tool.""" + + output: NotRequired[Any] + """Output of the executed tool.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +class ReasoningContentBlock(TypedDict): + """Reasoning output from a LLM. + + !!! note "Factory function" + + `create_reasoning_block` may also be used as a factory to create a + `ReasoningContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["reasoning"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + reasoning: NotRequired[str] + """Reasoning text. + + Either the thought summary or the raw reasoning text itself. + + Often parsed from `` tags in the model's response. + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata.""" + + +# Note: `title` and `context` are fields that could be used to provide additional +# information about the file, such as a description or summary of its content. +# E.g. with Claude, you can provide a context for a file which is passed to the model. +class ImageContentBlock(TypedDict): + """Image data. + + !!! note "Factory function" + + `create_image_block` may also be used as a factory to create an + `ImageContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["image"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + file_id: NotRequired[str] + """Reference to the image in an external file storage system. + + For example, OpenAI or Anthropic's Files API. + """ + + mime_type: NotRequired[str] + """MIME type of the image. + + Required for base64 data. + + [Examples from IANA](https://www.iana.org/assignments/media-types/media-types.xhtml#image) + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + url: NotRequired[str] + """URL of the image.""" + + base64: NotRequired[str] + """Data as a base64 string.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata. This shouldn't be used for the image data itself.""" + + +class VideoContentBlock(TypedDict): + """Video data. + + !!! note "Factory function" + + `create_video_block` may also be used as a factory to create a + `VideoContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["video"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + file_id: NotRequired[str] + """Reference to the video in an external file storage system. + + For example, OpenAI or Anthropic's Files API. + """ + + mime_type: NotRequired[str] + """MIME type of the video. + + Required for base64 data. + + [Examples from IANA](https://www.iana.org/assignments/media-types/media-types.xhtml#video) + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + url: NotRequired[str] + """URL of the video.""" + + base64: NotRequired[str] + """Data as a base64 string.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata. This shouldn't be used for the video data itself.""" + + +class AudioContentBlock(TypedDict): + """Audio data. + + !!! note "Factory function" + + `create_audio_block` may also be used as a factory to create an + `AudioContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["audio"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + file_id: NotRequired[str] + """Reference to the audio file in an external file storage system. + + For example, OpenAI or Anthropic's Files API. + """ + + mime_type: NotRequired[str] + """MIME type of the audio. + + Required for base64 data. + + [Examples from IANA](https://www.iana.org/assignments/media-types/media-types.xhtml#audio) + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + url: NotRequired[str] + """URL of the audio.""" + + base64: NotRequired[str] + """Data as a base64 string.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata. This shouldn't be used for the audio data itself.""" + + +class PlainTextContentBlock(TypedDict): + """Plaintext data (e.g., from a `.txt` or `.md` document). + + !!! note + + A `PlainTextContentBlock` existed in `langchain-core<1.0.0`. Although the + name has carried over, the structure has changed significantly. The only shared + keys between the old and new versions are `type` and `text`, though the + `type` value has changed from `'text'` to `'text-plain'`. + + !!! note + + Title and context are optional fields that may be passed to the model. See + Anthropic [example](https://platform.claude.com/docs/en/build-with-claude/citations#citable-vs-non-citable-content). + + !!! note "Factory function" + + `create_plaintext_block` may also be used as a factory to create a + `PlainTextContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["text-plain"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + file_id: NotRequired[str] + """Reference to the plaintext file in an external file storage system. + + For example, OpenAI or Anthropic's Files API. + """ + + mime_type: Literal["text/plain"] + """MIME type of the file. + + Required for base64 data. + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + url: NotRequired[str] + """URL of the plaintext.""" + + base64: NotRequired[str] + """Data as a base64 string.""" + + text: NotRequired[str] + """Plaintext content. This is optional if the data is provided as base64.""" + + title: NotRequired[str] + """Title of the text data, e.g., the title of a document.""" + + context: NotRequired[str] + """Context for the text, e.g., a description or summary of the text's content.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata. This shouldn't be used for the data itself.""" + + +class FileContentBlock(TypedDict): + """File data that doesn't fit into other multimodal block types. + + This block is intended for files that are not images, audio, or plaintext. For + example, it can be used for PDFs, Word documents, etc. + + If the file is an image, audio, or plaintext, you should use the corresponding + content block type (e.g., `ImageContentBlock`, `AudioContentBlock`, + `PlainTextContentBlock`). + + !!! note "Factory function" + + `create_file_block` may also be used as a factory to create a + `FileContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["file"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Used for tracking and referencing specific blocks (e.g., during streaming). + + Not to be confused with `file_id`, which references an external file in a + storage system. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + file_id: NotRequired[str] + """Reference to the file in an external file storage system. + + For example, a file ID from OpenAI's Files API or another cloud storage provider. + This is distinct from `id`, which identifies the content block itself. + """ + + mime_type: NotRequired[str] + """MIME type of the file. + + Required for base64 data. + + [Examples from IANA](https://www.iana.org/assignments/media-types/media-types.xhtml) + """ + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + url: NotRequired[str] + """URL of the file.""" + + base64: NotRequired[str] + """Data as a base64 string.""" + + extras: NotRequired[dict[str, Any]] + """Provider-specific metadata. This shouldn't be used for the file data itself.""" + + +# Future modalities to consider: +# - 3D models +# - Tabular data + + +class NonStandardContentBlock(TypedDict): + """Provider-specific content data. + + This block contains data for which there is not yet a standard type. + + The purpose of this block should be to simply hold a provider-specific payload. + If a provider's non-standard output includes reasoning and tool calls, it should be + the adapter's job to parse that payload and emit the corresponding standard + `ReasoningContentBlock` and `ToolCalls`. + + Has no `extras` field, as provider-specific data should be included in the + `value` field. + + !!! note "Factory function" + + `create_non_standard_block` may also be used as a factory to create a + `NonStandardContentBlock`. Benefits include: + + * Automatic ID generation (when not provided) + * Required arguments strictly validated at creation time + """ + + type: Literal["non_standard"] + """Type of the content block. Used for discrimination.""" + + id: NotRequired[str] + """Unique identifier for this content block. + + Either: + + - Generated by the provider + - Generated by LangChain upon creation (`UUID4` prefixed with `'lc_'`)) + """ + + value: dict[str, Any] + """Provider-specific content data.""" + + index: NotRequired[int | str] + """Index of block in aggregate response. Used during streaming.""" + + +# --- Aliases --- +DataContentBlock = ( + ImageContentBlock + | VideoContentBlock + | AudioContentBlock + | PlainTextContentBlock + | FileContentBlock +) +"""A union of all defined multimodal data `ContentBlock` types.""" + +ToolContentBlock = ( + ToolCall | ToolCallChunk | ServerToolCall | ServerToolCallChunk | ServerToolResult +) + +ContentBlock = ( + TextContentBlock + | InvalidToolCall + | ReasoningContentBlock + | NonStandardContentBlock + | DataContentBlock + | ToolContentBlock +) +"""A union of all defined `ContentBlock` types and aliases.""" + + +KNOWN_BLOCK_TYPES = { + # Text output + "text", + "reasoning", + # Tools + "tool_call", + "invalid_tool_call", + "tool_call_chunk", + # Multimodal data + "image", + "audio", + "file", + "text-plain", + "video", + # Server-side tool calls + "server_tool_call", + "server_tool_call_chunk", + "server_tool_result", + # Catch-all + "non_standard", + # citation and non_standard_annotation intentionally omitted +} +"""These are block types known to `langchain-core >= 1.0.0`. + +If a block has a type not in this set, it is considered to be provider-specific. +""" + + +def _get_data_content_block_types() -> tuple[str, ...]: + """Get type literals from DataContentBlock union members dynamically. + + Example: ("image", "video", "audio", "text-plain", "file") + + Note that old style multimodal blocks type literals with new style blocks. + Specifically, "image", "audio", and "file". + + See the docstring of `_normalize_messages` in `language_models._utils` for details. + """ + data_block_types = [] + + for block_type in get_args(DataContentBlock): + hints = get_type_hints(block_type) + if "type" in hints: + type_annotation = hints["type"] + if hasattr(type_annotation, "__args__"): + # This is a Literal type, get the literal value + literal_value = type_annotation.__args__[0] + data_block_types.append(literal_value) + + return tuple(data_block_types) + + +def is_data_content_block(block: dict[str, Any]) -> bool: + """Check if the provided content block is a data content block. + + Returns True for both v0 (old-style) and v1 (new-style) multimodal data blocks. + + Args: + block: The content block to check. + + Returns: + `True` if the content block is a data content block, `False` otherwise. + """ + if block.get("type") not in _get_data_content_block_types(): + return False + + if any(key in block for key in ("url", "base64", "file_id", "text")): + # Type is valid and at least one data field is present + # (Accepts old-style image and audio URLContentBlock) + + # 'text' is checked to support v0 PlainTextContentBlock types + # We must guard against new style TextContentBlock which also has 'text' `type` + # by ensuring the presence of `source_type` + if block["type"] == "text" and "source_type" not in block: # noqa: SIM103 # This is more readable + return False + + return True + + if "source_type" in block: + # Old-style content blocks had possible types of 'image', 'audio', and 'file' + # which is not captured in the prior check + source_type = block["source_type"] + if (source_type == "url" and "url" in block) or ( + source_type == "base64" and "data" in block + ): + return True + if (source_type == "id" and "id" in block) or ( + source_type == "text" and "url" in block + ): + return True + + return False + + +def create_text_block( + text: str, + *, + id: str | None = None, + annotations: list[Annotation] | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> TextContentBlock: + """Create a `TextContentBlock`. + + Args: + text: The text content of the block. + id: Content block identifier. + + Generated automatically if not provided. + annotations: `Citation`s and other annotations for the text. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `TextContentBlock`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = TextContentBlock( + type="text", + text=text, + id=ensure_id(id), + ) + if annotations is not None: + block["annotations"] = annotations + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_image_block( + *, + url: str | None = None, + base64: str | None = None, + file_id: str | None = None, + mime_type: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> ImageContentBlock: + """Create an `ImageContentBlock`. + + Args: + url: URL of the image. + base64: Base64-encoded image data. + file_id: ID of the image file from a file storage system. + mime_type: MIME type of the image. + + Required for base64 data. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `ImageContentBlock`. + + Raises: + ValueError: If no image source is provided or if `base64` is used without + `mime_type`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + if not any([url, base64, file_id]): + msg = "Must provide one of: url, base64, or file_id" + raise ValueError(msg) + + block = ImageContentBlock(type="image", id=ensure_id(id)) + + if url is not None: + block["url"] = url + if base64 is not None: + block["base64"] = base64 + if file_id is not None: + block["file_id"] = file_id + if mime_type is not None: + block["mime_type"] = mime_type + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_video_block( + *, + url: str | None = None, + base64: str | None = None, + file_id: str | None = None, + mime_type: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> VideoContentBlock: + """Create a `VideoContentBlock`. + + Args: + url: URL of the video. + base64: Base64-encoded video data. + file_id: ID of the video file from a file storage system. + mime_type: MIME type of the video. + + Required for base64 data. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `VideoContentBlock`. + + Raises: + ValueError: If no video source is provided or if `base64` is used without + `mime_type`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + if not any([url, base64, file_id]): + msg = "Must provide one of: url, base64, or file_id" + raise ValueError(msg) + + if base64 and not mime_type: + msg = "mime_type is required when using base64 data" + raise ValueError(msg) + + block = VideoContentBlock(type="video", id=ensure_id(id)) + + if url is not None: + block["url"] = url + if base64 is not None: + block["base64"] = base64 + if file_id is not None: + block["file_id"] = file_id + if mime_type is not None: + block["mime_type"] = mime_type + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_audio_block( + *, + url: str | None = None, + base64: str | None = None, + file_id: str | None = None, + mime_type: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> AudioContentBlock: + """Create an `AudioContentBlock`. + + Args: + url: URL of the audio. + base64: Base64-encoded audio data. + file_id: ID of the audio file from a file storage system. + mime_type: MIME type of the audio. + + Required for base64 data. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `AudioContentBlock`. + + Raises: + ValueError: If no audio source is provided or if `base64` is used without + `mime_type`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + if not any([url, base64, file_id]): + msg = "Must provide one of: url, base64, or file_id" + raise ValueError(msg) + + if base64 and not mime_type: + msg = "mime_type is required when using base64 data" + raise ValueError(msg) + + block = AudioContentBlock(type="audio", id=ensure_id(id)) + + if url is not None: + block["url"] = url + if base64 is not None: + block["base64"] = base64 + if file_id is not None: + block["file_id"] = file_id + if mime_type is not None: + block["mime_type"] = mime_type + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_file_block( + *, + url: str | None = None, + base64: str | None = None, + file_id: str | None = None, + mime_type: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> FileContentBlock: + """Create a `FileContentBlock`. + + Args: + url: URL of the file. + base64: Base64-encoded file data. + file_id: ID of the file from a file storage system. + mime_type: MIME type of the file. + + Required for base64 data. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `FileContentBlock`. + + Raises: + ValueError: If no file source is provided or if `base64` is used without + `mime_type`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + if not any([url, base64, file_id]): + msg = "Must provide one of: url, base64, or file_id" + raise ValueError(msg) + + if base64 and not mime_type: + msg = "mime_type is required when using base64 data" + raise ValueError(msg) + + block = FileContentBlock(type="file", id=ensure_id(id)) + + if url is not None: + block["url"] = url + if base64 is not None: + block["base64"] = base64 + if file_id is not None: + block["file_id"] = file_id + if mime_type is not None: + block["mime_type"] = mime_type + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_plaintext_block( + text: str | None = None, + url: str | None = None, + base64: str | None = None, + file_id: str | None = None, + title: str | None = None, + context: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> PlainTextContentBlock: + """Create a `PlainTextContentBlock`. + + Args: + text: The plaintext content. + url: URL of the plaintext file. + base64: Base64-encoded plaintext data. + file_id: ID of the plaintext file from a file storage system. + title: Title of the text data. + context: Context or description of the text content. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `PlainTextContentBlock`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = PlainTextContentBlock( + type="text-plain", + mime_type="text/plain", + id=ensure_id(id), + ) + + if text is not None: + block["text"] = text + if url is not None: + block["url"] = url + if base64 is not None: + block["base64"] = base64 + if file_id is not None: + block["file_id"] = file_id + if title is not None: + block["title"] = title + if context is not None: + block["context"] = context + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_tool_call( + name: str, + args: dict[str, Any], + *, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> ToolCall: + """Create a `ToolCall`. + + Args: + name: The name of the tool to be called. + args: The arguments to the tool call. + id: An identifier for the tool call. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `ToolCall`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = ToolCall( + type="tool_call", + name=name, + args=args, + id=ensure_id(id), + ) + + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_reasoning_block( + reasoning: str | None = None, + id: str | None = None, + index: int | str | None = None, + **kwargs: Any, +) -> ReasoningContentBlock: + """Create a `ReasoningContentBlock`. + + Args: + reasoning: The reasoning text or thought summary. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `ReasoningContentBlock`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = ReasoningContentBlock( + type="reasoning", + reasoning=reasoning or "", + id=ensure_id(id), + ) + + if index is not None: + block["index"] = index + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_citation( + *, + url: str | None = None, + title: str | None = None, + start_index: int | None = None, + end_index: int | None = None, + cited_text: str | None = None, + id: str | None = None, + **kwargs: Any, +) -> Citation: + """Create a `Citation`. + + Args: + url: URL of the document source. + title: Source document title. + start_index: Start index in the response text where citation applies. + end_index: End index in the response text where citation applies. + cited_text: Excerpt of source text being cited. + id: Content block identifier. + + Generated automatically if not provided. + + Returns: + A properly formatted `Citation`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = Citation(type="citation", id=ensure_id(id)) + + if url is not None: + block["url"] = url + if title is not None: + block["title"] = title + if start_index is not None: + block["start_index"] = start_index + if end_index is not None: + block["end_index"] = end_index + if cited_text is not None: + block["cited_text"] = cited_text + + extras = {k: v for k, v in kwargs.items() if v is not None} + if extras: + block["extras"] = extras + + return block + + +def create_non_standard_block( + value: dict[str, Any], + *, + id: str | None = None, + index: int | str | None = None, +) -> NonStandardContentBlock: + """Create a `NonStandardContentBlock`. + + Args: + value: Provider-specific content data. + id: Content block identifier. + + Generated automatically if not provided. + index: Index of block in aggregate response. + + Used during streaming. + + Returns: + A properly formatted `NonStandardContentBlock`. + + !!! note + + The `id` is generated automatically if not provided, using a UUID4 format + prefixed with `'lc_'` to indicate it is a LangChain-generated ID. + """ + block = NonStandardContentBlock( + type="non_standard", + value=value, + id=ensure_id(id), + ) + + if index is not None: + block["index"] = index + + return block diff --git a/libs/core/langchain_core/messages/function.py b/libs/core/langchain_core/messages/function.py new file mode 100644 index 0000000..ee0dad3 --- /dev/null +++ b/libs/core/langchain_core/messages/function.py @@ -0,0 +1,62 @@ +"""Function Message.""" + +from typing import Any, Literal + +from typing_extensions import override + +from langchain_core.messages.base import ( + BaseMessage, + BaseMessageChunk, + merge_content, +) +from langchain_core.utils._merge import merge_dicts + + +class FunctionMessage(BaseMessage): + """Message for passing the result of executing a tool back to a model. + + `FunctionMessage` are an older version of the `ToolMessage` schema, and + do not contain the `tool_call_id` field. + + The `tool_call_id` field is used to associate the tool call request with the + tool call response. Useful in situations where a chat model is able + to request multiple tool calls in parallel. + + """ + + name: str + """The name of the function that was executed.""" + + type: Literal["function"] = "function" + """The type of the message (used for serialization).""" + + +class FunctionMessageChunk(FunctionMessage, BaseMessageChunk): + """Function Message chunk.""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["FunctionMessageChunk"] = "FunctionMessageChunk" # type: ignore[assignment] + """The type of the message (used for serialization).""" + + @override + def __add__(self, other: Any) -> BaseMessageChunk: # type: ignore[override] + if isinstance(other, FunctionMessageChunk): + if self.name != other.name: + msg = "Cannot concatenate FunctionMessageChunks with different names." + raise ValueError(msg) + + return self.__class__( + name=self.name, + content=merge_content(self.content, other.content), + additional_kwargs=merge_dicts( + self.additional_kwargs, other.additional_kwargs + ), + response_metadata=merge_dicts( + self.response_metadata, other.response_metadata + ), + id=self.id, + ) + + return super().__add__(other) diff --git a/libs/core/langchain_core/messages/human.py b/libs/core/langchain_core/messages/human.py new file mode 100644 index 0000000..c9a1d27 --- /dev/null +++ b/libs/core/langchain_core/messages/human.py @@ -0,0 +1,70 @@ +"""Human message.""" + +from typing import Any, Literal, cast, overload + +from langchain_core.messages import content as types +from langchain_core.messages.base import BaseMessage, BaseMessageChunk + + +class HumanMessage(BaseMessage): + """Message from the user. + + A `HumanMessage` is a message that is passed in from a user to the model. + + Example: + ```python + from langchain_core.messages import HumanMessage, SystemMessage + + messages = [ + SystemMessage(content="You are a helpful assistant! Your name is Bob."), + HumanMessage(content="What is your name?"), + ] + + # Instantiate a chat model and invoke it with the messages + model = ... + print(model.invoke(messages)) + ``` + """ + + type: Literal["human"] = "human" + """The type of the message (used for serialization).""" + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]], + **kwargs: Any, + ) -> None: ... + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: ... + + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: + """Specify `content` as positional arg or `content_blocks` for typing.""" + if content_blocks is not None: + super().__init__( + content=cast("list[str | dict[Any, Any]]", content_blocks), + **kwargs, + ) + else: + super().__init__(content=content, **kwargs) + + +class HumanMessageChunk(HumanMessage, BaseMessageChunk): + """Human Message chunk.""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["HumanMessageChunk"] = "HumanMessageChunk" # type: ignore[assignment] + """The type of the message (used for serialization).""" diff --git a/libs/core/langchain_core/messages/modifier.py b/libs/core/langchain_core/messages/modifier.py new file mode 100644 index 0000000..2175be4 --- /dev/null +++ b/libs/core/langchain_core/messages/modifier.py @@ -0,0 +1,33 @@ +"""Message responsible for deleting other messages.""" + +from typing import Any, Literal + +from langchain_core.messages.base import BaseMessage + + +class RemoveMessage(BaseMessage): + """Message responsible for deleting other messages.""" + + type: Literal["remove"] = "remove" + """The type of the message (used for serialization).""" + + def __init__( + self, + id: str, + **kwargs: Any, + ) -> None: + """Create a RemoveMessage. + + Args: + id: The ID of the message to remove. + **kwargs: Additional fields to pass to the message. + + Raises: + ValueError: If the 'content' field is passed in kwargs. + + """ + if kwargs.pop("content", None): + msg = "RemoveMessage does not support 'content' field." + raise ValueError(msg) + + super().__init__("", id=id, **kwargs) diff --git a/libs/core/langchain_core/messages/system.py b/libs/core/langchain_core/messages/system.py new file mode 100644 index 0000000..3ace120 --- /dev/null +++ b/libs/core/langchain_core/messages/system.py @@ -0,0 +1,70 @@ +"""System message.""" + +from typing import Any, Literal, cast, overload + +from langchain_core.messages import content as types +from langchain_core.messages.base import BaseMessage, BaseMessageChunk + + +class SystemMessage(BaseMessage): + """Message for priming AI behavior. + + The system message is usually passed in as the first of a sequence + of input messages. + + Example: + ```python + from langchain_core.messages import HumanMessage, SystemMessage + + messages = [ + SystemMessage(content="You are a helpful assistant! Your name is Bob."), + HumanMessage(content="What is your name?"), + ] + + # Define a chat model and invoke it with the messages + print(model.invoke(messages)) + ``` + """ + + type: Literal["system"] = "system" + """The type of the message (used for serialization).""" + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]], + **kwargs: Any, + ) -> None: ... + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: ... + + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: + """Specify `content` as positional arg or `content_blocks` for typing.""" + if content_blocks is not None: + super().__init__( + content=cast("list[str | dict[Any, Any]]", content_blocks), + **kwargs, + ) + else: + super().__init__(content=content, **kwargs) + + +class SystemMessageChunk(SystemMessage, BaseMessageChunk): + """System Message chunk.""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["SystemMessageChunk"] = "SystemMessageChunk" # type: ignore[assignment] + """The type of the message (used for serialization).""" diff --git a/libs/core/langchain_core/messages/tool.py b/libs/core/langchain_core/messages/tool.py new file mode 100644 index 0000000..7219942 --- /dev/null +++ b/libs/core/langchain_core/messages/tool.py @@ -0,0 +1,418 @@ +"""Messages for tools.""" + +import json +from typing import Any, Literal, cast, overload +from uuid import UUID + +from pydantic import Field, model_validator +from typing_extensions import NotRequired, TypedDict, override + +from langchain_core.messages import content as types +from langchain_core.messages.base import BaseMessage, BaseMessageChunk, merge_content +from langchain_core.messages.content import InvalidToolCall +from langchain_core.utils._merge import merge_dicts, merge_obj + + +class ToolOutputMixin: + """Mixin for objects that tools can return directly. + + If a custom BaseTool is invoked with a `ToolCall` and the output of custom code is + not an instance of `ToolOutputMixin`, the output will automatically be coerced to + a string and wrapped in a `ToolMessage`. + + """ + + +class ToolMessage(BaseMessage, ToolOutputMixin): + """Message for passing the result of executing a tool back to a model. + + `ToolMessage` objects contain the result of a tool invocation. Typically, the result + is encoded inside the `content` field. + + `tool_call_id` is used to associate the tool call request with the tool call + response. Useful in situations where a chat model is able to request multiple tool + calls in parallel. + + Example: + A `ToolMessage` representing a result of `42` from a tool call with id + + ```python + from langchain_core.messages import ToolMessage + + ToolMessage(content="42", tool_call_id="call_Jja7J89XsjrOLA5r!MEOW!SL") + ``` + + Example: + A `ToolMessage` where only part of the tool output is sent to the model + and the full output is passed in to artifact. + + ```python + from langchain_core.messages import ToolMessage + + tool_output = { + "stdout": "From the graph we can see that the correlation between " + "x and y is ...", + "stderr": None, + "artifacts": {"type": "image", "base64_data": "/9j/4gIcSU..."}, + } + + ToolMessage( + content=tool_output["stdout"], + artifact=tool_output, + tool_call_id="call_Jja7J89XsjrOLA5r!MEOW!SL", + ) + ``` + """ + + tool_call_id: str + """Tool call that this message is responding to.""" + + type: Literal["tool"] = "tool" + """The type of the message (used for serialization).""" + + artifact: Any = None + """Artifact of the Tool execution which is not meant to be sent to the model. + + Should only be specified if it is different from the message content, e.g. if only + a subset of the full tool output is being passed as message content but the full + output is needed in other parts of the code. + """ + + status: Literal["success", "error"] = "success" + """Status of the tool invocation.""" + + additional_kwargs: dict[Any, Any] = Field(default_factory=dict, repr=False) + """Currently inherited from `BaseMessage`, but not used.""" + + response_metadata: dict[Any, Any] = Field(default_factory=dict, repr=False) + """Currently inherited from `BaseMessage`, but not used.""" + + @model_validator(mode="before") + @classmethod + def coerce_args(cls, values: dict[str, Any]) -> dict[str, Any]: + """Coerce the model arguments to the correct types. + + Args: + values: The model arguments. + + """ + content = values["content"] + if isinstance(content, tuple): + content = list(content) + + if not isinstance(content, (str, list)): + try: + values["content"] = str(content) + except ValueError as e: + msg = ( + "ToolMessage content should be a string or a list of string/dicts. " + f"Received:\n\n{content=}\n\n which could not be coerced into a " + "string." + ) + raise ValueError(msg) from e + elif isinstance(content, list): + values["content"] = [] + for i, x in enumerate(content): + if not isinstance(x, (str, dict)): + try: + values["content"].append(str(x)) + except ValueError as e: + msg = ( + "ToolMessage content should be a string or a list of " + "string/dicts. Received a list but " + f"element ToolMessage.content[{i}] is not a dict and could " + f"not be coerced to a string.:\n\n{x}" + ) + raise ValueError(msg) from e + else: + values["content"].append(x) + + tool_call_id = values["tool_call_id"] + if isinstance(tool_call_id, (UUID, int, float)): + values["tool_call_id"] = str(tool_call_id) + return values + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]], + **kwargs: Any, + ) -> None: ... + + @overload + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: ... + + def __init__( + self, + content: str | list[str | dict[Any, Any]] | None = None, + content_blocks: list[types.ContentBlock] | None = None, + **kwargs: Any, + ) -> None: + """Initialize a `ToolMessage`. + + Specify `content` as positional arg or `content_blocks` for typing. + + Args: + content: The contents of the message. + content_blocks: Typed standard content. + **kwargs: Additional fields. + """ + if content_blocks is not None: + super().__init__( + content=cast("list[str | dict[Any, Any]]", content_blocks), + **kwargs, + ) + else: + super().__init__(content=content, **kwargs) + + +class ToolMessageChunk(ToolMessage, BaseMessageChunk): + """Tool Message chunk.""" + + # Ignoring mypy re-assignment here since we're overriding the value + # to make sure that the chunk variant can be discriminated from the + # non-chunk variant. + type: Literal["ToolMessageChunk"] = "ToolMessageChunk" # type: ignore[assignment] + + @override + def __add__(self, other: Any) -> BaseMessageChunk: # type: ignore[override] + if isinstance(other, ToolMessageChunk): + if self.tool_call_id != other.tool_call_id: + msg = "Cannot concatenate ToolMessageChunks with different names." + raise ValueError(msg) + + return self.__class__( + tool_call_id=self.tool_call_id, + content=merge_content(self.content, other.content), + artifact=merge_obj(self.artifact, other.artifact), + additional_kwargs=merge_dicts( + self.additional_kwargs, other.additional_kwargs + ), + response_metadata=merge_dicts( + self.response_metadata, other.response_metadata + ), + id=self.id, + status=_merge_status(self.status, other.status), + ) + + return super().__add__(other) + + +class ToolCall(TypedDict): + """Represents an AI's request to call a tool. + + Example: + ```python + {"name": "foo", "args": {"a": 1}, "id": "123"} + ``` + + This represents a request to call the tool named `'foo'` with arguments + `{"a": 1}` and an identifier of `'123'`. + + !!! note "Factory function" + + `tool_call` may also be used as a factory to create a `ToolCall`. Benefits + include: + + * Required arguments strictly validated at creation time + """ + + name: str + """The name of the tool to be called.""" + + args: dict[str, Any] + """The arguments to the tool call as a dictionary.""" + + id: str | None + """An identifier associated with the tool call. + + An identifier is needed to associate a tool call request with a tool + call result in events when multiple concurrent tool calls are made. + """ + + type: NotRequired[Literal["tool_call"]] + """Used for discrimination.""" + + +def tool_call( + *, + name: str, + args: dict[str, Any], + id: str | None, +) -> ToolCall: + """Create a tool call. + + Args: + name: The name of the tool to be called. + args: The arguments to the tool call as a dictionary. + id: An identifier associated with the tool call. + + Returns: + The created tool call. + """ + return ToolCall(name=name, args=args, id=id, type="tool_call") + + +class ToolCallChunk(TypedDict): + """A chunk of a tool call (yielded when streaming). + + When merging `ToolCallChunk` objects (e.g., via `AIMessageChunk.__add__`), all + string attributes are concatenated. Chunks are only merged if their values of + `index` are equal and not `None`. + + Example: + ```python + left_chunks = [ToolCallChunk(name="foo", args='{"a":', index=0)] + right_chunks = [ToolCallChunk(name=None, args="1}", index=0)] + + ( + AIMessageChunk(content="", tool_call_chunks=left_chunks) + + AIMessageChunk(content="", tool_call_chunks=right_chunks) + ).tool_call_chunks == [ToolCallChunk(name="foo", args='{"a":1}', index=0)] + ``` + """ + + name: str | None + """The name of the tool to be called.""" + + args: str | None + """The arguments to the tool call as a JSON-parseable string.""" + + id: str | None + """An identifier associated with the tool call. + + An identifier is needed to associate a tool call request with a tool + call result in events when multiple concurrent tool calls are made. + """ + + index: int | None + """The index of the tool call in a sequence. + + Used for merging chunks. + """ + + type: NotRequired[Literal["tool_call_chunk"]] + """Used for discrimination.""" + + +def tool_call_chunk( + *, + name: str | None = None, + args: str | None = None, + id: str | None = None, + index: int | None = None, +) -> ToolCallChunk: + """Create a tool call chunk. + + Args: + name: The name of the tool to be called. + args: The arguments to the tool call as a JSON string. + id: An identifier associated with the tool call. + index: The index of the tool call in a sequence. + + Returns: + The created tool call chunk. + """ + return ToolCallChunk( + name=name, args=args, id=id, index=index, type="tool_call_chunk" + ) + + +def invalid_tool_call( + *, + name: str | None = None, + args: str | None = None, + id: str | None = None, + error: str | None = None, +) -> InvalidToolCall: + """Create an invalid tool call. + + Args: + name: The name of the tool to be called. + args: The arguments to the tool call as a JSON string. + id: An identifier associated with the tool call. + error: An error message associated with the tool call. + + Returns: + The created invalid tool call. + """ + return InvalidToolCall( + name=name, args=args, id=id, error=error, type="invalid_tool_call" + ) + + +def default_tool_parser( + raw_tool_calls: list[dict[str, Any]], +) -> tuple[list[ToolCall], list[InvalidToolCall]]: + """Best-effort parsing of tools. + + Args: + raw_tool_calls: List of raw tool call dicts to parse. + + Returns: + A list of tool calls and invalid tool calls. + """ + tool_calls = [] + invalid_tool_calls = [] + for raw_tool_call in raw_tool_calls: + if "function" not in raw_tool_call: + continue + function_name = raw_tool_call["function"]["name"] + try: + function_args = json.loads(raw_tool_call["function"]["arguments"]) + parsed = tool_call( + name=function_name or "", + args=function_args or {}, + id=raw_tool_call.get("id"), + ) + tool_calls.append(parsed) + except json.JSONDecodeError: + invalid_tool_calls.append( + invalid_tool_call( + name=function_name, + args=raw_tool_call["function"]["arguments"], + id=raw_tool_call.get("id"), + error=None, + ) + ) + return tool_calls, invalid_tool_calls + + +def default_tool_chunk_parser( + raw_tool_calls: list[dict[str, Any]], +) -> list[ToolCallChunk]: + """Best-effort parsing of tool chunks. + + Args: + raw_tool_calls: List of raw tool call dicts to parse. + + Returns: + List of parsed ToolCallChunk objects. + """ + tool_call_chunks = [] + for tool_call in raw_tool_calls: + if "function" not in tool_call: + function_args = None + function_name = None + else: + function_args = tool_call["function"]["arguments"] + function_name = tool_call["function"]["name"] + parsed = tool_call_chunk( + name=function_name, + args=function_args, + id=tool_call.get("id"), + index=tool_call.get("index"), + ) + tool_call_chunks.append(parsed) + return tool_call_chunks + + +def _merge_status( + left: Literal["success", "error"], right: Literal["success", "error"] +) -> Literal["success", "error"]: + return "error" if "error" in {left, right} else "success" diff --git a/libs/core/langchain_core/messages/utils.py b/libs/core/langchain_core/messages/utils.py new file mode 100644 index 0000000..2b1a1ba --- /dev/null +++ b/libs/core/langchain_core/messages/utils.py @@ -0,0 +1,2406 @@ +"""Module contains utility functions for working with messages. + +Some examples of what you can do with these functions include: + +* Convert messages to strings (serialization) +* Convert messages from dicts to Message objects (deserialization) +* Filter messages from a list of messages based on name, type or id etc. +""" + +from __future__ import annotations + +import base64 +import inspect +import json +import logging +import math +from collections.abc import Callable, Iterable, Sequence +from functools import partial, wraps +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Concatenate, + Literal, + ParamSpec, + Protocol, + TypeVar, + cast, + overload, +) +from xml.sax.saxutils import escape, quoteattr + +from pydantic import Discriminator, Field, Tag + +from langchain_core.exceptions import ErrorCode, create_message +from langchain_core.messages.ai import AIMessage, AIMessageChunk +from langchain_core.messages.base import BaseMessage, BaseMessageChunk +from langchain_core.messages.block_translators.openai import ( + convert_to_openai_data_block, +) +from langchain_core.messages.chat import ChatMessage, ChatMessageChunk +from langchain_core.messages.content import ( + is_data_content_block, +) +from langchain_core.messages.function import FunctionMessage, FunctionMessageChunk +from langchain_core.messages.human import HumanMessage, HumanMessageChunk +from langchain_core.messages.modifier import RemoveMessage +from langchain_core.messages.system import SystemMessage, SystemMessageChunk +from langchain_core.messages.tool import ToolCall, ToolMessage, ToolMessageChunk +from langchain_core.utils.function_calling import convert_to_openai_tool + +if TYPE_CHECKING: + from langchain_core.language_models import BaseLanguageModel + from langchain_core.prompt_values import PromptValue + from langchain_core.runnables.base import Runnable + from langchain_core.tools import BaseTool + +try: + from langchain_text_splitters import TextSplitter + + _HAS_LANGCHAIN_TEXT_SPLITTERS = True +except ImportError: + _HAS_LANGCHAIN_TEXT_SPLITTERS = False + +logger = logging.getLogger(__name__) + + +def _get_type(v: Any) -> str: + """Get the type associated with the object for serialization purposes.""" + if isinstance(v, dict) and "type" in v: + result = v["type"] + elif hasattr(v, "type"): + result = v.type + else: + msg = ( + f"Expected either a dictionary with a 'type' key or an object " + f"with a 'type' attribute. Instead got type {type(v)}." + ) + raise TypeError(msg) + if not isinstance(result, str): + msg = f"Expected 'type' to be a str, got {type(result).__name__}" + raise TypeError(msg) + return result + + +AnyMessage = Annotated[ + Annotated[AIMessage, Tag(tag="ai")] + | Annotated[HumanMessage, Tag(tag="human")] + | Annotated[ChatMessage, Tag(tag="chat")] + | Annotated[SystemMessage, Tag(tag="system")] + | Annotated[FunctionMessage, Tag(tag="function")] + | Annotated[ToolMessage, Tag(tag="tool")] + | Annotated[AIMessageChunk, Tag(tag="AIMessageChunk")] + | Annotated[HumanMessageChunk, Tag(tag="HumanMessageChunk")] + | Annotated[ChatMessageChunk, Tag(tag="ChatMessageChunk")] + | Annotated[SystemMessageChunk, Tag(tag="SystemMessageChunk")] + | Annotated[FunctionMessageChunk, Tag(tag="FunctionMessageChunk")] + | Annotated[ToolMessageChunk, Tag(tag="ToolMessageChunk")], + Field(discriminator=Discriminator(_get_type)), +] +"""A type representing any defined `Message` or `MessageChunk` type.""" + + +def _has_base64_data(block: dict[str, Any]) -> bool: + """Check if a content block contains base64 encoded data. + + Args: + block: A content block dictionary. + + Returns: + Whether the block contains base64 data. + """ + # Check for explicit base64 field (standard content blocks) + if block.get("base64"): + return True + + # Check for data: URL in url field + url = block.get("url", "") + if isinstance(url, str) and url.startswith("data:"): + return True + + # Check for OpenAI-style image_url with data: URL + image_url = block.get("image_url", {}) + if isinstance(image_url, dict): + url = image_url.get("url", "") + if isinstance(url, str) and url.startswith("data:"): + return True + + return False + + +_XML_CONTENT_BLOCK_MAX_LEN = 500 + + +def _truncate(text: str, max_len: int = _XML_CONTENT_BLOCK_MAX_LEN) -> str: + """Truncate text to `max_len` characters, adding ellipsis if truncated.""" + if len(text) <= max_len: + return text + return text[:max_len] + "..." + + +def _format_content_block_xml(block: dict[str, Any]) -> str | None: + """Format a content block as XML. + + Args: + block: A LangChain content block. + + Returns: + XML string representation of the block, or `None` if the block should be + skipped. + + Note: + Plain text document content, server tool call arguments, and server tool + result outputs are truncated to 500 characters. + """ + block_type = block.get("type", "") + + # Skip blocks with base64 encoded data + if _has_base64_data(block): + return None + + # Text blocks + if block_type == "text": + text = block.get("text", "") + return escape(text) if text else None + + # Reasoning blocks + if block_type == "reasoning": + reasoning = block.get("reasoning", "") + if reasoning: + return f"{escape(reasoning)}" + return None + + # Image blocks (URL only, base64 already filtered) + if block_type == "image": + url = block.get("url") + file_id = block.get("file_id") + if url: + return f"" + if file_id: + return f"" + return None + + # OpenAI-style image_url blocks + if block_type == "image_url": + image_url = block.get("image_url", {}) + if isinstance(image_url, dict): + url = image_url.get("url", "") + if url and not url.startswith("data:"): + return f"" + return None + + # Audio blocks (URL only) + if block_type == "audio": + url = block.get("url") + file_id = block.get("file_id") + if url: + return f"