chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:58:32 +08:00
commit 7406cfee43
3019 changed files with 494026 additions and 0 deletions
+49
View File
@@ -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 <https://github.com/langchain-ai/langchain>.
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/&lt;YOUR_USERNAME&gt;/&lt;YOUR_CLONED_REPO_NAME&gt;
```
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 <kbd>F1</kbd> 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.
+58
View File
@@ -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"
}
+13
View File
@@ -0,0 +1,13 @@
version: '3'
services:
langchain:
build:
dockerfile: libs/langchain/dev.Dockerfile
context: ..
networks:
- langchain-network
networks:
langchain-network:
driver: bridge
+34
View File
@@ -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
+52
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
+3
View File
@@ -0,0 +1,3 @@
/.github/ @ccurme @eyurtsev @mdrxy
/libs/core/ @eyurtsev
/libs/partners/ @ccurme @mdrxy
+153
View File
@@ -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
+15
View File
@@ -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
+155
View File
@@ -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:
- ...
+49
View File
@@ -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
+120
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
Fixes #
---
<!-- Keep the `Fixes #xx` keyword at the very top and update the issue number — this auto-closes the issue on merge. Replace this comment with a 1-2 sentence description of your change. No `# Summary` header; the description is the summary. -->
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
<!-- Required for net new features or behavior-changing bugfixes. State the user-visible change in release-note-ready language. Omit this section for chores, refactors, or test-only changes. -->
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)
<!-- If you'd like a shoutout on release, add your socials below -->
Twitter: @
LinkedIn: https://linkedin.com/in/
+39
View File
@@ -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 }}
+119
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#161F34"/>
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M45.7427 45.7412L36.8098 54.6742C32.106 59.3779 32.106 67.0042 36.8098 71.708C41.5135 76.4118 49.1398 76.4118 53.8436 71.708L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#161F34"/>
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M45.7427 45.7411L36.8098 54.6741C32.106 59.3779 32.106 67.0042 36.8098 71.7079C41.5135 76.4117 49.1398 76.4117 53.8436 71.7079L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="#161F34"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+398
View File
@@ -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}")
+118
View File
@@ -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))
@@ -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."
)
+215
View File
@@ -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.0.0 (can be anywhere in constraint string)
for x in range(1, 10):
spec_string = re.sub(
rf"\^{x}\.(\d+)\.(\d+)", rf">={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+1.0.0 (can be anywhere in constraint string)
for x in range(1, 10):
constraint_string = re.sub(
rf"\^{x}\.0\.(\d+)", rf">={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()]))
+84
View File
@@ -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$" }
]
}
+278
View File
@@ -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 };
+66
View File
@@ -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}"
)
+756
View File
@@ -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) <linux@rodrigosilva.com>
#
# 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 <http://www.gnu.org/licenses/gpl.html>
#
# 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 <pathspec>
# - 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)
@@ -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'
+81
View File
@@ -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
@@ -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
+914
View File
@@ -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<<EOF'
echo $PREAMBLE
echo
git log --format="%s" "$PREV_TAG"..HEAD -- $WORKING_DIR
echo EOF
} >> "$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'}}
+85
View File
@@ -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'
+73
View File
@@ -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'
+66
View File
@@ -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'
+116
View File
@@ -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
});
}
+146
View File
@@ -0,0 +1,146 @@
# Block PRs whose head ref is `main` (or `master`) from a fork. This topology
# (`<fork>: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 = '<!-- block-fork-main -->';
// 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.`);
+205
View File
@@ -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"
+43
View File
@@ -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"
+235
View File
@@ -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
+73
View File
@@ -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/<group>/<pkg>/`)
# 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
+288
View File
@@ -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 <pkg>==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:
# `<canonical-name>\t<name>\t<new-version>`. `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
+55
View File
@@ -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
@@ -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 34: 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}`),
]);
+85
View File
@@ -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 }}
+408
View File
@@ -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
+214
View File
@@ -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}`);
+131
View File
@@ -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.`);
+128
View File
@@ -0,0 +1,128 @@
# PR title linting.
#
# FORMAT (Conventional Commits 1.0.0):
#
# <type>[optional scope]: <description>
# [optional body]
# [optional footer(s)]
#
# Examples:
# feat(core): add multitenant 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 multitenant 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
+175
View File
@@ -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 = '<!-- pr-trailer-lint -->';
// 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:.*<noreply@anthropic\.com>/;
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: ... <noreply@anthropic.com>` 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',
);
@@ -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 }}
@@ -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;
}
+196
View File
@@ -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 = '<!-- require-issue-link -->';
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}`);
}
}
+468
View File
@@ -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 = '<!-- require-issue-link -->';
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 = '<!-- require-issue-link -->';
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 = '<!-- require-issue-link -->';
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 #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` 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);
+205
View File
@@ -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.`);
+168
View File
@@ -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/
+14
View File
@@ -0,0 +1,14 @@
{
"MD013": false,
"MD024": {
"siblings_only": true
},
"MD025": false,
"MD033": false,
"MD034": false,
"MD036": false,
"MD041": false,
"MD046": {
"style": "fenced"
}
}
+12
View File
@@ -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"
}
}
}
+215
View File
@@ -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
+19
View File
@@ -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",
],
}
+78
View File
@@ -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"
}
]
}
+364
View File
@@ -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 <name>` / `--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 `<github-username>/<scope>/<short-description>`:
- `<github-username>` — the author's GitHub login (e.g. `mdrxy`).
- `<scope>` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.).
- `<short-description>` — 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
---
<rest of description>
```
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_<partner>/_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(<partner>): <version>` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/<partner>-<version>`.
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=<partner> -f release-version=<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 <run-id> --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)
+8
View File
@@ -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"
+364
View File
@@ -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 <name>` / `--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 `<github-username>/<scope>/<short-description>`:
- `<github-username>` — the author's GitHub login (e.g. `mdrxy`).
- `<scope>` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.).
- `<short-description>` — 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
---
<rest of description>
```
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_<partner>/_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(<partner>): <version>` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/<partner>-<version>`.
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=<partner> -f release-version=<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 <run-id> --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)
+21
View File
@@ -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.
+80
View File
@@ -0,0 +1,80 @@
<div align="center">
<a href="https://docs.langchain.com/oss/python/langchain/overview">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/images/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset=".github/images/logo-light.svg">
<img alt="LangChain Logo" src=".github/images/logo-dark.svg" width="50%">
</picture>
</a>
</div>
<div align="center">
<h3>The agent engineering platform.</h3>
</div>
<div align="center">
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langchain" alt="PyPI - License"></a>
<a href="https://pypistats.org/packages/langchain" target="_blank"><img src="https://img.shields.io/pepy/dt/langchain" alt="PyPI - Downloads"></a>
<a href="https://pypi.org/project/langchain/#history" target="_blank"><img src="https://img.shields.io/pypi/v/langchain?label=%20" alt="Version"></a>
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
</div>
<br>
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
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`langchain-ai/langchain`
- 原始仓库:https://github.com/langchain-ai/langchain
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+20
View File
@@ -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
+35
View File
@@ -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).
+89
View File
@@ -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=<test_file> - run all tests in file'
@echo 'test_watch - run unit tests in watch mode'
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
jinja2>=3,<4
+20
View File
@@ -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()
+87
View File
@@ -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__)
@@ -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 "<property>"
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,
)
@@ -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 == "<lambda>":
_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
+23
View File
@@ -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
+50
View File
@@ -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
+41
View File
@@ -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
@@ -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",
]
@@ -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}")
@@ -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)
@@ -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)
]
@@ -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]
)
+258
View File
@@ -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,
)
+272
View File
@@ -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()
@@ -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__)
File diff suppressed because it is too large Load Diff
+267
View File
@@ -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", ["<unknown>"])[-1])
or "<unknown>"
)
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")
File diff suppressed because it is too large Load Diff
@@ -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", ["<unknown>"])[-1])
else:
name = "<unknown>"
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")
@@ -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.
"""
+119
View File
@@ -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)
+246
View File
@@ -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()
+26
View File
@@ -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())
+19
View File
@@ -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."""
@@ -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.
"""
@@ -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__)
@@ -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))
@@ -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"]
@@ -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 "<root>"
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)
@@ -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__)
+347
View File
@@ -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}'"

Some files were not shown because too many files have changed in this diff Show More