Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c5b8fc37 | ||
|
|
44ee0199fd | ||
|
|
1de61f6fce | ||
|
|
f33db6cec4 | ||
|
|
c72107177b | ||
|
|
f520a38d30 | ||
|
|
d0278f520c | ||
|
|
506539ac9d | ||
|
|
d6c6516f16 | ||
|
|
6f5d6d9993 | ||
|
|
8dcd058404 | ||
|
|
e3050b3a3e | ||
|
|
9e767afad7 | ||
|
|
62b35277ec | ||
|
|
931419909b | ||
|
|
e849c869cc | ||
|
|
5a580ae5ec | ||
|
|
47a0e09513 | ||
|
|
f37486efe2 | ||
|
|
fa61be9fbc | ||
|
|
08097a78bd | ||
|
|
43f610e9a6 | ||
|
|
aa1ddee67e | ||
|
|
12b46e8a69 | ||
|
|
d90f69105a | ||
|
|
fbd3b67183 | ||
|
|
6d8be543e7 | ||
|
|
1c1772f7ec | ||
|
|
ce239c784a | ||
|
|
c14c978824 | ||
|
|
ebb2823be0 | ||
|
|
25f0224f97 | ||
|
|
fdf9b0ad48 | ||
|
|
00ecccfd81 | ||
|
|
7d5452d039 | ||
|
|
2d8de54e80 | ||
|
|
32abc62bc1 | ||
|
|
e36cf4111d | ||
|
|
53a2c2bdcd | ||
|
|
2cae9337a7 | ||
|
|
b056e17b38 | ||
|
|
2855caa3eb | ||
|
|
140566e662 | ||
|
|
b7f57b1375 | ||
|
|
121c5863db | ||
|
|
1709cd3fb5 | ||
|
|
d4a1fe5a03 | ||
|
|
06e1e4ed12 | ||
|
|
1e62b175ba | ||
|
|
f89fe49f99 | ||
|
|
2e1971baf3 | ||
|
|
948027aef2 | ||
|
|
6954e63671 | ||
|
|
7f0bfdc139 | ||
|
|
0496128e6b | ||
|
|
d79b1a61e8 | ||
|
|
e227f6ce83 | ||
|
|
8a1a11fde5 | ||
|
|
c47fd171c6 | ||
|
|
1e07a9ac97 | ||
|
|
d9cc227e75 | ||
|
|
6b45a281c1 | ||
|
|
532bc71c11 | ||
|
|
dabd75f1f9 | ||
|
|
ef88b805a7 | ||
|
|
7e8a68f0f0 | ||
|
|
e20bd15580 | ||
|
|
51c57cc819 | ||
|
|
09a6450eed | ||
|
|
77fba7a571 | ||
|
|
60232eadc4 | ||
|
|
83e4e6c4c2 | ||
|
|
a6f0c665af | ||
|
|
f1a2e19144 | ||
|
|
b28e9d0a87 | ||
|
|
b3230cf6d1 | ||
|
|
1a6c3114f3 | ||
|
|
4f1bf4fa7a | ||
|
|
22fa673872 | ||
|
|
3d85f2296c | ||
|
|
5ad9cfa030 | ||
|
|
39281c866d | ||
|
|
4c3a38d324 | ||
|
|
8fc3f204ea | ||
|
|
2df2f41dc7 | ||
|
|
30e647abce | ||
|
|
b85c9961d7 | ||
|
|
1c97bddc14 | ||
|
|
97b3d1b9ac | ||
|
|
8b70da6a0f | ||
|
|
7904fdc928 | ||
|
|
ba56ba1b2a | ||
|
|
12f2a480cd | ||
|
|
532cb0a691 | ||
|
|
23e18e8c1b | ||
|
|
6d160a2865 | ||
|
|
e0b4eb6454 | ||
|
|
608ff41e78 | ||
|
|
9280e3411b | ||
|
|
36fa8e0097 | ||
|
|
f63217794d | ||
|
|
bcb11640a5 | ||
|
|
b6c3a0d0fd | ||
|
|
8b14b6a9f0 | ||
|
|
8c63cc1778 | ||
|
|
6756e91ffc | ||
|
|
3dd1e67977 | ||
|
|
fc5d919aee | ||
|
|
0369300160 | ||
|
|
245cf83b20 | ||
|
|
9bd351b80f | ||
|
|
83c3f86159 | ||
|
|
3a41a2addc | ||
|
|
3eca363c23 | ||
|
|
2f1e864570 | ||
|
|
79a1ce6804 | ||
|
|
deb99a9acb | ||
|
|
5f7dcbb07a | ||
|
|
9615580a66 | ||
|
|
d45eb0f9f2 | ||
|
|
721945b5ce |
@@ -89,6 +89,8 @@ jobs:
|
||||
--check-links-ignore "https://x.com/.*" \
|
||||
--check-links-ignore "https://github\.com/.*" \
|
||||
--check-links-ignore "http://localhost:8123/.*" \
|
||||
--check-links-ignore "http://localhost:2024.*" \
|
||||
--check-links-ignore "http://127.0.0.1:.*" \
|
||||
--check-links-ignore "/.*\.(ipynb|html)$" \
|
||||
--check-links-ignore "https://python\.langchain\.com/.*" \
|
||||
--check-links-ignore "https://openai\.com/.*" \
|
||||
@@ -106,6 +108,8 @@ jobs:
|
||||
poetry run pytest -v \
|
||||
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
|
||||
--check-links-ignore "http://localhost:8123/.*" \
|
||||
--check-links-ignore "http://localhost:2024.*" \
|
||||
--check-links-ignore "http://127.0.0.1:.*" \
|
||||
--check-links-ignore "https://x.com/.*" \
|
||||
--check-links-ignore "https://github\.com/.*" \
|
||||
--check-links-ignore "/.*\.(ipynb|html)$" \
|
||||
|
||||
@@ -13,7 +13,7 @@ serve-clean-docs: clean-docs
|
||||
poetry run python -m mkdocs serve -c -f docs/mkdocs.yml --strict -w ./libs/langgraph
|
||||
|
||||
serve-docs: build-typedoc
|
||||
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./libs/langgraph -w ./libs/checkpoint --dirty
|
||||
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./libs/langgraph -w ./libs/checkpoint -w ./libs/sdk-py --dirty
|
||||
|
||||
clean-docs:
|
||||
find ./docs/docs -name "*.ipynb" -type f -delete
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from mkdocs.structure.pages import Page
|
||||
@@ -8,6 +9,7 @@ from notebook_convert import convert_notebook
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig()
|
||||
logger.setLevel(logging.INFO)
|
||||
DISABLED = os.getenv("DISABLE_NOTEBOOK_CONVERT") in ("1", "true", "True")
|
||||
|
||||
|
||||
class NotebookFile(File):
|
||||
@@ -16,6 +18,8 @@ class NotebookFile(File):
|
||||
|
||||
|
||||
def on_files(files: Files, **kwargs: Dict[str, Any]):
|
||||
if DISABLED:
|
||||
return files
|
||||
new_files = Files([])
|
||||
for file in files:
|
||||
if file.src_path.endswith(".ipynb"):
|
||||
@@ -32,6 +36,8 @@ def on_files(files: Files, **kwargs: Dict[str, Any]):
|
||||
|
||||
|
||||
def on_page_markdown(markdown: str, page: Page, **kwargs: Dict[str, Any]):
|
||||
if DISABLED:
|
||||
return markdown
|
||||
if page.file.src_path.endswith(".ipynb"):
|
||||
logger.info("Processing Jupyter notebook: %s", page.file.src_path)
|
||||
body = convert_notebook(page.file.abs_src_path)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
eNrtVglQFFcahqirmzVg1JTsVlxeCEIi08PMcA5oIuBBgsgxgBwq++h+w7T0dHe63wADBa4YU5sYtTpbaxITE1dhYEcisGp0PapEF42uG1fNriGEaKWwTGI2Wdcj5RX3dc8Mt1clVlmVnaqp6n7vP773f///+qtrLEeSzAq8fzPLYyRBGpMX+bW6Rgm94EAyftFlR9gmMPWZGZacjQ6J7Zxiw1iUEyIjocjqIY9tkiCytJ4W7JHlxkg7kmVYiuT6EoFxfuJ/sDrEDiuLsVCGeDkkARgNpmgdCPFZkZWi6hBJ4BB5CnHISAohu7RAoPBYXaqwQRwug6eiQASIeRpMBUZTSM1CNYLAIE61oDnoYBAVRcVQssDzCFMcxAS4Gkh2yhjZVasCwQGghADkAWQYVj0kQJUikrAOOMkerW7IZQDbELA7OMyKHEvDfmbAKkjAhjgRVLDYNshGD5K4CuiUASOo0SQgCpLmK1hJZI52cJ5QJYhEQVoSG+QZwWrVqzCxIHDeUvDQrpUCS5CXrUgilSsemKvYg0f1Y5BMS6yorqo+SQT/IOykxnwfdC0Zy4sOXCzTNmSHxKs6RCT8kYCsxkZ1jYrHKWoghJLFiMYhNTULaxptCDKkU1bX2wQZK21DuG+BNI1ETCGeFhiWL1XeK61iRR1gkFXlw02r3GjNpbjLEBIpyLHlyOXxUlqh2As6cjHhsdnbA5SKZei2W20VSjudsj3JhyMy00lalQcGfVS03tRaSckYsjxHeo30BIHkErX9Xf03REiXkTiUdwwUl8d5c38bQVYa0iGdYRkQEkq0TWmAkj02ekv/dcnBY9aOlMaUzKHpvJt96aL0RqPe3DYgsOzkaaXBCjkZtfUWudfFbTKYoihDLGUwbvZViUN8KbYpG6NjYpokJItkhNEyFwmJHXJdPWEEHfmg0Tt0GzLSfGx+5jepfiZhR9mT40A6YIwDMxENSPxoYIpKMJgTYsxgTnpOc4o3Tc6wZLTleJuVmuUjv5G2OfgyxLhThqW9M6TvWBLJz7F2FlPeG4eQpb4q9dEGg6Ez7LaWEmlillcz1keZzeY7xCWVQVjZqp6PMpooY1yO95SxhcPn0WaF8lxeXlQuFRXBNfWO9n3YfD5hd+FzC4Tmws7w4bwFBx4CsSFeyxZxZ/s+iF6f8LvxuTVEMJz7oPJ5EoXexrJ/4TzW4LbWt8Tj9jJPsYyymzwXG4xpudiJ5+enxtBp5eVJ8vNZJaa8THFjOQsVt1FvBKWCUMqhlpTZVAokVyRl0UZIaZxZMC8p/bmU5nwqWygRSC/lQNJzvMAjlwVJZDQVN80JDoZcdhJyEffspAJla7w1CppjYs00MpoYZI6jZs3PbvUNU++w1Ks3pfa9XEpGViJLHf5NwSvG+Gm/EeR/8yaTZc/oMoz7PqLx2j5XQ+74wnbdeftbB4JOZCZNKMzqCRt9/qu3l9XksZuuf/f0oalNlw9d7r4grRZWu5KXp1dOPf712e0dN/759/cL/r322ZMrOkvl96svx4Q91bWmauLy9e+OmXDxw8ujJtf9fEOBof1ALpMFWUezTUp4M6OODnuzh33mb8enHRyZdjBZ11T7beCG5MjpNfETa8OXvrQ+rGvf4d8vWvZR2Iwzsc1rdvdwDxe/Gvja3jMvb8ss26xb+tHIne5Xgw7+Y3Hkf76Z+Mze63sKTX5jK3b89sKiJ97dF7h96SP+E3JBwFpbx9byX5xt/SR5TQaEO0J3TGNnXNhk2LO1J/DDhObRXUsYy1dxQZW49kirMXv1o1fbXB9PaWlft2XW2BsVAYlfnGpL3bLr2rkWvra6qPbIhV1vdYcvbqntmBz2wc5fVi6SK0OLfxexqnvFsT2TemIfdtecnB4a9Ndty8MnPx7cFbByxlFlR2jO4++lGwIi8q5lbRy3PSygyfqlrj379ZjHGhqPfROk/PrqO/jSkqOznEWH/tx87vuHVDpG+K3zb7pUS55/JKE04tv7IZR0oM8HyjJLPgg8HuhIomJUqYWYizCwI4/IKZEQVNUQq2qaCj5hAb+AN+rBbFaSiWJ6LpzjNAXDecRMr6QSIVFKXgzEBwCgPYPpIF6NYNKDeUIFqECAR4gBWPDpFSeIByVOglkPLCxPI5LBPqxW0zLfi1DTjqDC8igtr7LRjqyVp3eFaLBiUlfV6p5EmHZ/+5QTy/hiOchlk1uQnirE5OTQZpMpN6tqJhLT5juqiH4dQMxQMosGwSJXHMk+iHCLg0gnWbY6OFI9H1IJMT5BqR6m+K7wqLjlYuIsSMRakx0E4g8R2cMzM1BpD2V3EHO+3fsrrn1Zbi+re5H+X1D/pAR1vdFgiPvxFLXRkGAw/CQUtfmBVtQaDw+2ovZCfGAUtQfPMIo6JbMs32qxWWOxBZqTLRXx2ZBOi76/ijqOjrUamXtT1H59ippZlZ62f8a4l24eNoX/YWXoJvbEqMjaT6dVuVPzY0/E7ly3MnHLx+P/e2pKR93n7GnnZ9+h2V8ktraetRflXbrKXsmPO/pYd3Bw/tr90g39mF/1xJ9+O9m6fV3SK/Bnq/5S+kLoehicOPY37aNfT9Txr+w8nfpi9hvHP81rMK5vnzM+1P9awjtzRgXKN7Bu99eZ9iopyfx55Fx36bbCphWzF/7pcFPitsIFi/ZfdC2dEmBPPqjsfSj75PPzRj5bdz7qSefINPPIR65c3DH5j41PpEWcH/Fy+79cx06vOwXPTUj48sltSyac6QzH3UsmbbkedmDuiTg/j1KtvOI3z+Lv5/c/Bn21mg==
|
||||
@@ -0,0 +1 @@
|
||||
eNrtmHlUU1cex1Gs2qKWUpdpa/EZQRRIyL5QdaBhUwxLAFnU4bwkN8kjL++lb2ERmVGsS8Ut2jIq1o6ytYgK1dalFeS4tmqVakctM8o4dhxlXFBH61bnvpAICm5z8BzOkfyTvHt/y/f+fvfe9znJL88EFI2RRI9KjGAAheoZ+EAvyy+nwAcsoJkPy6yAMZOGkrjYhMRilsJO+poZxkYHBwWhNkyAEoyZIm2YXqAnrUGZoiAroGnUBOgSHWnI+bnHnVyeFc1OZ0gLIGheMCISiqWBCM9lBUcm5/IoEgfwF4+lAcWDs3oSSiEYbijLjDJ+NDJKggQgstGIPyIS8/KmchFIA8A5Cz2OsgbAl/BlfJokCMDwcZSBwrlADEnizhwEanXkQA0GbsYAaD2F2bjFcqOhBgONMFkkQrBWHSyIgLPBCBvLpNN6M7Ci0CiXZ4MrBRSDOXTn8loGmRybIzBXPhPUnwc9dR3PcFNcWTEKGDhVMILDmFuPy5jUZQA947BtFW1lcQaz4TkdKNe0TEFNXUz/1LxyM0ANUMziEjNJM/bqdvtlI6rXAxvDB4SeNGCEyb7eNA2zBSIGYOR6WKHn+unYkPYKCwA2PopjmaCsxctehdrguvUoNx+UAXtf6dw3fE5L++kKbnvx4a4jGPuWUJeOoLgcuL0JRCiQSAXiqmw+zaAYgcP9CfcRlFRmc8x/03bChuotMA7feXTsZS3OG9rakLS9VIPqYxMeColSerO9FKWscummtuMUSzCYFdjL1XHt0zknW9NJBCKRQFX9UGA6h9DbS40oToPqB0V+4FIhFoolfKGcLxRtcFUJB4SJMduLZULF5xSgbfDYg1llMCTD0vklsCPg4P5y50FdGxvt6uYptyElYbA79h2JLAhERAokDOgRGF+KiCXBImGwUIxEahIr1c40iR02ozqRQgnaCBsS7mp+ud7MEhZgqFB32PaTvNZlUTA/jlkxhu+8pWCzuEd7iVQoFJ4c+URLCh4HjOAylkhUKtVT4sLKAMa+mVsfXyTmixSJzlUK0zrO4zh1/JYLz6mqjFMFdfk/1b5Vm8tn5DP4PEahOO2kX0feJMu0k1iqdGQLeLp9q0Snj9+z+DxeItKR+yPla0nk8wTLtoVrsUaeaP1YPRXOzvMxg/1b+DtdKJJqU1IUjCxNp9alEtMkBmNaDBYHijMx1F4hEogQE0macLBRHcFXo/Cy5Sc4jpC9PCw1JlQzXl2ZwteSOhLupUQU7jmCJEBZAqDg0bRX6HGSNcDLjgJl0F0bmmrfrDRKUJVMqVDopToDUCn44cnaKtdhenBYSrib0vGOnQmPLAWH9vT4w7CCvm6Oj7shThO9K8TzXsCBKWO28m9V/XhJ8/qgQaFjouomnCD2Srd+qpFs+qr0bG20NqygfN/B3GvNp9644NUP3+q/9Hqzsbrgzq3r/5pWveKXrIaLF9UZAUeQub1zB4kbXgtVvzP9dNVBWYFvj/5FcWv7uP/QkHJm1DBNxNB5cRGpS6eWji+c2XtI1JJeBxuaTV801gwbtnPnvdk3MxbWxM/1HNSnVu3btHKQhzyDVaJnV/r7WhIzTRYy2funSB8/FvEOwsafTznUVBgesJiq+z718tot+f7Jf87Q/eVo7bAPGwv279g4fEHNovClNWcbjkeuGHc4+bP+eeCnmBux6Tvm/8doSy+811cWv3NoXf53ssqdE7dfmuoebZ4/mN8nuFbkuaBh3lnPfuGXZbuHp9Bj1gf4TOeJlq/7buKsTcTC2Zb6G7/Np8Ov7tpiPnB/yryrWyRFmX9XwELev+/u9qdjc3pd6+Hm1klI5L7iRSARRwsuH5SmMXiNE8zDjjAqA7IdIcb74ThiBrgNySFZhCbxTIAwZoxGaAbYEF1OyzdLw20FxwGCZqIYjupwgBhZooUQBVOIKYRIgERgFM0EIjjgNOlRXM9yr27EIS6Y1wYMHLkdOh+MQDpLhwvkrB7BM8cF9wBSJE4kkXEAghlcviw8m1rJB5OiGWVchM5iI7LEpkwmW5MFICI+VJH2VZz8iAx4I0CQeqTSShdDcirTnykxJ5BOBxRFUtDa8TqGWrqBtRtYu4G1s4BVJRZ1LrDKXgpglXR5YJV1fWCVdTFglXUErBIa02barBmoKXSCLSxGqdVOAhHZLxZYVVLUoFM+H7AGtwKrg7UMcXUxu4T97n2yf0jIhPhl9V+cnTOjaTeq8+qblGr5er701a9GyJPuZR3rmf/RMlB77ubdMSENM2YuykzaUNjExp9aP6DguGbUNss7+Hx1r1enL1BWL9l/ydPnep3GPfqY1+TDyBqP/sHCuPeK3j44ONUef8VbdXRbjXbbX5E1A1bXS5af+0fkx83C9Yf3zKgZuO7K8NnqcSPqffdZmci63IFjtVTgDwc8qse+Vtp/wdVk317JviuJIndNXgX13xPjzgtG1qb+/m8ZWvvRQ2MDljRaFZG3M5ffmD6xn9HNo+zjqleKrytropAB1RfqtVfKYyQhv64+crzvXr9zPiW3wo2//rhO+e0aP+Geq2nvW5vGTmIXFzY0rkuI+G3WTXPR/ZSou1lawb7mN1tgNOQcfWZH58HoKz27YfRlg9Hn645YgMSQWa6aurjO0YgWmcgo5WiuSyLx/1Hhtpz4cJmVzjKLxO3rPAlLE1ttLJGcGKcW0yIbw4aGM6JOqrNK/oRCPyZzN/V3U3839b9Q6i8RiZXizsV++UuB/bIuj/3yro/98i6G/fKOsD8+Ki4hCotOAtMiLCajQpqaILaI0ReL/agIAIPqebB/9+02f1Mvio3eFTJwd+33Yr/7OHLy69GC7CuN9UUKe5XPYak0r+hSbsGcI5cF/MEDY92vFb1lrDtS908kL2FSk/DyufOqC6vuXExgqFXj0r0CmdX/XsWzH3kzUDv0cITnW2HuAYEDqovf8L74OypyT8WJgV4rxnskb2eTgvsUyL/0CrjbQL2bdDv4eNCZ5u0jlkVd/XzkH5cXfbJNUTSZNvmbvA+pFx79ZrZ51cqwUN6uWVstUr+9r3sGG88UNqoWF4fXTLnh0Xv1l/K3b+7umX58coF883sZP4eFzt1/6HRYzi8fvX/zsz2Vte8mfxpz2vkXsnf11uUaSO3/Ax5DzIs=
|
||||
@@ -1 +1 @@
|
||||
eNqdVg1wFOUZDhKYULRR2sIwlPJ5lkZqdnN3e0kuiQeGuwAH+SGXSAgBz73d7+422dtv2d275JIeyt/IqIMcWNtRamtIchIjIUDFgqgU8KeIHQXUMAWrQsGCgFqQWsb03b07uAxMO+3O3O3u973/z/O+365IRLCiCkQa0SdIGlZYToMXNb4ioeClYaxqq3pCWAsSvmt+TV39prAiDP48qGmyWlpQwMoCTWQssQLNkVBBxFLABVmtAJ5lERtmunyEjx7L3txhCmFVZQNYNZWipg4TR8CXpMGLqZGEEatgxKIAlsC/iDSFjWAR4TYZKxrSwCTiWAkpGAyHsMSnBXiITpBYwxG6G9MBGnEkLGmKgNV8xAmacccaN41Gbj+KgiMJYx6pMuYEv8AhVQgENRVjQQpct560l49YtQXlZUh4WT4iqETJQ36ioCAW5eFWg0SDmG5uxtj7DwaCkA/CEgkHgkiQYDtkqCONgEFVJnrOBCqBUVjFSj4samFFQnl+QRLUYB6NqjGgiMCvoUZAUkFQbUlTkS+KJDaEaVM+MilExHrN1aiq4ZAplo+GQSGgVlaSAAiCVBLCrWAFw5ISgqAM7xyrCD4fZqVMY3pIptgSWAkRHov6UkDWKBvRhSR4tcBdZgFZEYqgESJ6OXjWmeBnRRXDrqopmA1lLEB0AD4LWeouzHSxvmZoBonA6WsdJi0qG/79YcngrO7u2rMuoKetC3iMEqrYFIulzKRY+L9bSGUCHQFNMywK4mvGnGYIKEQnroCTAkpaN1MaEgZK6dJAYk4R5JRLUzkKhkMG2Vme9YkYpdXTBCCKEADSi8hoTtCikYtgFUlES/IQ5Hx6N/kNqbQ6jRoEUdR3krUGQR/LtWTSitb5YAoQjRihYimsI9JkukkP6IEP47S+4PUC7b1ek86E/5pnPTiVcJuWZKkehs6JfEgPpewgwX8ttDxVT1eJQqMAoTHW66MSMYJ5GlWFVU3Pi0jwS6qkGhxyjLAilIlOIq8PNEHBvJGVkgGpkfOSWCy2JJYIQuEB2ye6gkTV4luGj7h+luMwcBtLHOEhsfiLgXZBzodJ5BdZDfdCL0nYoE+8twVjmWJFIYJ7klrxrawsiwJntHZBs0qkvlTvUXq5btzu1VOnjALFd9RAEOXugvlRmMUSstCFxbR1axulaqwgiTBbKZGFeHpkY3935oYMOIMRKjXn4z1J5S2ZMkSNd1exXE3dMJOswgXj3dD/RbbtmesKDFkhhOMJ5/wb3aU2r7tjaIuFLhkYZliNSly82+j2ncOUsaZEKWOKx58zb0nXR8RSQAvGuyyWEub5NHIre0BPC6srugAM/M5bidQR01kzL43iiawJXS4AJr6nAfP5yGJBLswhq9lqg7/SQmupxYxmV9X3OVN+6m+Kw0C9wkqqH7CoSOOe4IJhqQXzvc6bIr5HRxzS0eOHMUnBUUZUTKWiivctpDzJw5Vyu7Yn6UURJcBKQrvhNr5ZRxMOU0HakdqGqaKbBOdUSIVKWC3MltRWutK9kJiZspgps2VXGwXTE4tCSIDqGf+p4xyAtpjhevlGCY20YDj5n2fMyevVTBEFhyAa3f01Q10lcL1yc6G0LasuY7ebdw0XU3FGQJuKQurLN+6nTHSa1b62tDAl8PHBn8KLl7PbrPaiEsz4OF8RZv08z9nNXLEVFu0lZhv+gz56OLCiYycTRaNUzMHXixaND+aH2Da9rxyMpZApgkzL4IDjxDCP68I+F9FzUMuQrGCRsHy/cxblZLkgpuoMusUTrsbq8iq3s7cOgnQS0iLg9cdGjPR6Ob/XF3LcHy30Y7o4upC45xPR7Y8yfMjaUOj08PWN3ipW4ISZsuYtItKcCspSzDAlhWaGAdhoM22hLZTXU9/SRker5gTaIpEo42FxhJltxQGzfwHd4poznzS2+pVFvrZWWVjqdVr85c3VDXNbl9Y4q3xig1jR7olwxd55rMW1aE5rmMzy+6JewVUO2bBa0FFQpn9VwQxUHamGoKAhKL0dLKXWdDuUId6ogYMePv3K0Bz48KuRxGgZqtOLieEOR0mdoGFHNczfwSehBuGIwDui9c7KULPPrTJuz8JKvrJi5oJ5tda57vm2WrNYos1uVapqZtYV2r3l5owiMDbgbqoORWab3WDh9dD/z6heWkhl9jdVIye/cBMSUSXB7++pwwq0ULyXE0mYh0Gu4B7A3FPeGN9h95vthb5CG8OXlNiY4iKqosGzNW3t2jTo0k+BBCsCxyJcfHuQcZhKbTbGVIZCrMNeZDObje/g5T3J4/DALfumPJaTZVwjH6+vIpOKb99ztqEy+sDEbfzsl6Twp86m13M+/fH7C/b6ntp4vHtfwbp9d/d+MvSA3PDBn9aO1XLH+k6808Hsmzj93axVC3wjZ15+6/3RzND9x7+YIh09frQZn4r1zCD9i9t2Xj19+eAjX0+q/WXOx/euO3n6eebzkHn/sd13PBVft3nrs/0NL5w9eKA56sj64B7r1L6x97528etTvqjFu/jQhAvfX77nw/XrP3r9Yybr4Tc/8Xq0R5etuS2/N/BY95Nb7xooKR2x5kzVQE6lO2/Wm51P3/f55A33NO4W3xnz+olnFnVaNly84nh2Y2L6c5ZLkwtbp/TPGDp1ZMqh9p1vXfjysz1HZkTy2u+6NP3WMb8ffJt7qPriptjXc2+9z77DeS5v7t7drn7fiOw36CZl8Vfjn2i6I/fZpoHBR//s2Nvx+C4hZ/KZ7Byq9I39i/74UTAYWzV51tiymeen3VI66jbWf9xfV70toY3+5sS47Cnb9uY8Oq7lkalfZB9zb/jR3gvB2nl9D3esyD3RnJhaesV1Z/Gsg+8tW+lYVHkUqSsHyk8dnviXznN7dx6Yekf2yvXjrrzU/Wu0NFK9+3zJr7736tmfdXLrBuW1tvWjnGu/WZ099eyFpwunPph78idXv7t1WQt7gbGbRk1bd+TFp253t7e8vdk5rXuiu9WR19l55q7f5dPf/bV2+Z2j51089M/jBU21oYrTg7nHt7QovVt2fup+5eXuxGDT6f68wxM2HxpziM3PvZz4bOOkj1efeeYfzWLFC7+pjk0ec9g83TSQuyny3sQfupYNmEwuMvLDc6/F71l18IBy8geHR9ec+fZq1raALa/27P2XRntfXFObW6Ytbmo8X1n13OFvfQuO/TZr+4beh979G3V+QPli15BzxgiP50FPeE3Xjl80fnly/OZ75aFTkXNfrds651/7+bMLdq/1rWlv41yT/h6Jhd2HcMfA+O2JwdeArUNDI7NQ2+oT4qisrH8DKyiG7A==
|
||||
eNqNVXtcE1cWlqWtSl3LVn9dXXEdIgICkwfvZOsDIiJQHguIGnk4mdwkI5OZODNJoZQtAkVXtHZQaquIAiFoVB4FgVasski3FqWsu3aXan3UWtTqSqXqQgV6E4LKSrX5JzP3fud83z3nO3dyq4yAYQmacjhIUBxgMJyDLyyfW8WAdQbAcvlmHeC0tMoUF5uQWGFgiO55Wo7TszKRCNMTQozitAytJ3AhTutERolIB1gW0wDWpKRVmV85nM8S6LCMNI5OBxQrkCESsa+/DyIYRcGV1VkChiYBfBIYWMAI4C5OQykUZ10ikNcxisIQDY2wtA68rgUMgEuMDiEohNMCBMcYQqkEGCXITrEmplWAtAbiJGZQAdQPDUBZmqIAh5IYB89jzc9mshzQWVGraAOCwYyQAFDw9CTCMZgRkAjI0AOGgwwYBykohAHwfDpAqUYBKpiLoDBbtRBPINQIEZw2UBxDANYHwQnO9g84fL4QiVAjmZCIAkCFaGkORj9MN5LAB8HYdMTDtpeGqYwESzMeiJpmEC0g9UKrZo6mSXu5KExnKxdUQrFqwMDqpo0JteKhPpwh9Nb0VmwIzD9CbceMzU5QegOXxuJaoMMgPEugh02FBSBsLcrKtgrI1NtYaeVagHOC7OyU7CotwFTQPhcmOJu0NMvxdU9YogbDcaDnUEDhtIqgNPwhzRuE3geWT23thwW39sbmOd6SDoAexUjCCMwjUXwtpteTBG4rkmgt7ONBuzVQq5onty1WB6HQWBTHN4WM6hDFZUIHU4hY6CcVimszUJbDCIqEFoSegJLMetv+kcc39BieDvOg9ungzSPB1Y9jaJavjMbw2IQxKTEG1/KV0KGB/vWPrzPQHIQO8FXyuCfp7JuP6PyEEolQWjcmMZtJ4XylGiNZUPewyA9DLL5iXz9UHIiKJU1jUgOOyURt3uTLxNWjBSQBpeG0fIV/UOA+BrB66EKQZ4ZhnIHNNcFmgVOfVdnHtDw26lGrf29aAhvHH000AB9EEoQsATgCqf0RXz+Z2FcmCUDCoxMPyu00ieP2qS7Rbl00bNQXVbjWQKUDlUU+riO6BY9OzEB+ktARHGq/o2Afra+8yV8sFne7PxXJQIcTlJXR5CeVSp+RF1YGcHyD9XyoxBeVBCWOnFIcrBifxzZI6Mh1Z1dltqqCuryeiX+kbTTG/VfEjK9QEqDo9hgvmjZwT0isDLaxeT8b/0iiPcbj18T8skRkvPD/K98IkdtTkI8XbgSNPBX9i3os9s6jhIpvgc9pYkkkCFcsiVwR5ifBl+sjqdAYX41B6V9hJDDeIhFK4JeJ1pCgRr4UlWPw/kQTbCPEVy1ZFRMSHSE/uBKNp5U09FIiBj1H0RQwJwAGTi1vwUnaoIL3IAPMMDw+ZBXfEKz2w4IxsQTHlMogXI2hYSvia0eH6eGwmKyXqO0Lux6OLAOX2h3L5xROmmD7OXLLW6lZQc6DxfNFMTnFa7xjtt2I/xd1pjnMpWLyJXnKpPpvbwq9hR9dnnI4actQwieuaUnvenu/XFTSoOh8tTOLVt8LWVXxnqXHaHjDOHhlUdPwO4MDn1y/SH48dPbWrejhoaOW7eTEz/98YjO6P7L90p0XVY0+18KWW14qKFF43RLP3Llg14I963pLVY3BKSV7dgtfscTLMlxFa/v/3TZ3X1d2jGhRlMGg3Ot+TT+jEXGQMY6kPLe1bGJ+1ObG6skLGtyCv7k38X9d/2hN7xDHkcte9Hf928a6DXycbFNtyBZlXo+0pWxZDp8jj118f2fJXNGaje0D870u1uRp0j2Ftx/sXrSrraas6cLaH3s9frvfmHSxf+aDy23XOrIDsJfPue3Yw4VP6ayeTBXtSG75u8tQNeHpu5f1+/5wFBn0HB+85LLT0umbjrf9+NLR9VrDRJk0gmeGaD5u79INqdPyv/uh2imu8OuVdQ0d5/dGzbsa+H3tvM31M4qCxIrri/fN8VQWgHdvrQmQNb29U+V2p/eb/ptX+77Yk9q2rb2f/OGfPbE7YuudCqUP3lmgSGlu+XShm/D8IXFwgzqpItUxck7GtU2dHzgeRs+4FhbWmD5cuDr3T6cvfJdKOCZfakSSE2Yd6vig5p4xwPV9p4ETSbH5Hm9yi+87hkWlTrvdHzXl7blaTdb0gQKn4JN9d7jYwXaHuxq2+fjULJ/3d6zriO+adEU1a4dgZdH9L5u2nmB6T4ck3NH+dGGg8Tf3XKtPFveUSDOH29tL+gOldXlza2927TrzHreo2Xl/rnvBH2+cWN28VP1afy/+oVuvcHJy9MzK/5z/rGTD7Qx3Y/6xK3f7XRaamku1KeVn12/96WhbtLvC7OmPzG6dUe7nMSWgzsVLf03xHB6l5NNFoWWzBCF/WFmxS1bVdbL4v7NuFJaFf6nzVO4KOXblgm5Z53YQP3CgquL5K5JPxbknT5nmuk9qa3hhrcrX+c2pHXkRXwUm5d0N5VfOKJ3eQk/Fdr8WFe/w19JFgzP/ss7Fqza6QCZJmJ11XDhJutEkv7gt6Mi87e2hx77e38E1lHs157ZsdivvKVBiAgembJrLzi1XnT1OR3TJfHNkF39n9s5JOKc+1TLj7Ftvrdg0b8qBVze2HqgU0ElF5SuOOJq3zv+iWDHwbXJlfL+7z5ztd+VFxfkBmUkdffX1kdd9Tkb2ZIO8tNnhJQtLw0P77nYPfy5CQ16RBEoOHHoeDuPwsOOEcxF9lpoXJkz4Gdjtv6Q=
|
||||
@@ -5,7 +5,7 @@ LangGraph Cloud is available within <a href="https://www.langchain.com/langsmith
|
||||
## Prerequisites
|
||||
|
||||
1. LangGraph Cloud applications are deployed from GitHub repositories. Configure and upload a LangGraph Cloud application to a GitHub repository in order to deploy it to LangGraph Cloud.
|
||||
1. [Verify that the LangGraph API runs locally](test_locally.md). If the API does not build and run successfully (i.e. `langgraph up`), deploying to LangGraph Cloud will fail as well.
|
||||
1. [Verify that the LangGraph API runs locally](test_locally.md). If the API does not run successfully (i.e. `langgraph dev`), deploying to LangGraph Cloud will fail as well.
|
||||
|
||||
## Create New Deployment
|
||||
|
||||
@@ -15,7 +15,7 @@ Starting from the <a href="https://smith.langchain.com/" target="_blank">LangSmi
|
||||
1. In the top-right corner, select `+ New Deployment` to create a new deployment.
|
||||
1. In the `Create New Deployment` panel, fill out the required fields.
|
||||
1. `Deployment details`
|
||||
1. Select `Import from GitHub` and follow the GitHub OAuth workflow to install and authorize LangChain's `hosted-langserve` GitHub app to access the selected repositories. After installation is complete, return to the `Create New Deployment` panel and select the GitHub repository to deploy from the dropdown menu.
|
||||
1. Select `Import from GitHub` and follow the GitHub OAuth workflow to install and authorize LangChain's `hosted-langserve` GitHub app to access the selected repositories. After installation is complete, return to the `Create New Deployment` panel and select the GitHub repository to deploy from the dropdown menu. **Note**: The GitHub user installing LangChain's `hosted-langserve` GitHub app must be an [owner](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/roles-in-an-organization#organization-owners) of the organization or account.
|
||||
1. Specify a name for the deployment.
|
||||
1. Specify the desired `Git Branch`. A deployment is linked to a branch. When a new revision is created, code for the linked branch will be deployed. The branch can be updated later in the [Deployment Settings](#deployment-settings).
|
||||
1. Specify the full path to the [LangGraph API config file](../reference/cli.md#configuration-file) including the file name. For example, if the file `langgraph.json` is in the root of the repository, simply specify `langgraph.json`.
|
||||
|
||||
|
After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 642 KiB |
@@ -6,17 +6,11 @@ Testing locally ensures that there are no errors or conflicts with Python depend
|
||||
|
||||
## Setup
|
||||
|
||||
Install the proper packages:
|
||||
Install the LangGraph CLI package:
|
||||
|
||||
|
||||
=== "pip"
|
||||
```bash
|
||||
pip install -U langgraph-cli
|
||||
```
|
||||
=== "Homebrew (macOS only)"
|
||||
```bash
|
||||
brew install langgraph-cli
|
||||
```
|
||||
```bash
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
Ensure you have an API key, which you can create from the [LangSmith UI](https://smith.langchain.com) (Settings > API Keys). This is required to authenticate that you have LangGraph Cloud access. After you have saved the key to a safe place, place the following line in your `.env` file:
|
||||
|
||||
@@ -29,16 +23,26 @@ LANGSMITH_API_KEY = *********
|
||||
Once you have installed the CLI, you can run the following command to start the API server for local testing:
|
||||
|
||||
```shell
|
||||
langgraph up
|
||||
langgraph dev
|
||||
```
|
||||
|
||||
This will start up the LangGraph API server locally. If this runs successfully, you should see something like:
|
||||
|
||||
```shell
|
||||
Ready!
|
||||
- API: http://localhost:8123
|
||||
2024-06-26 19:20:41,056:INFO:uvicorn.access 127.0.0.1:44138 - "GET /ok HTTP/1.1" 200
|
||||
```
|
||||
> Ready!
|
||||
>
|
||||
> - API: [http://localhost:2024](http://localhost:2024/)
|
||||
>
|
||||
> - Docs: http://localhost:2024/docs
|
||||
>
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
|
||||
!!! note "In-Memory Mode"
|
||||
|
||||
The `langgraph dev` command starts LangGraph Server in an in-memory mode. This mode is suitable for development and testing purposes. For production use, you should deploy LangGraph Server with access to a persistent storage backend.
|
||||
|
||||
If you want to test your application with a persistent storage backend, you can use the `langgraph up` command instead of `langgraph dev`. You will
|
||||
need to have `docker` installed on your machine to use this command.
|
||||
|
||||
|
||||
### Interact with the server
|
||||
|
||||
@@ -53,7 +57,7 @@ You can either initialize by passing authentication or by setting an environment
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# only pass the url argument to get_client() if you changed the default port when calling langgraph up
|
||||
# only pass the url argument to get_client() if you changed the default port when calling langgraph dev
|
||||
client = get_client(url=<DEPLOYMENT_URL>,api_key=<LANGSMITH_API_KEY>)
|
||||
# Using the graph deployed with the name "agent"
|
||||
assistant_id = "agent"
|
||||
@@ -65,7 +69,7 @@ You can either initialize by passing authentication or by setting an environment
|
||||
```js
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
// only set the apiUrl if you changed the default port when calling langgraph up
|
||||
// only set the apiUrl if you changed the default port when calling langgraph dev
|
||||
const client = new Client({ apiUrl: <DEPLOYMENT_URL>, apiKey: <LANGSMITH_API_KEY> });
|
||||
// Using the graph deployed with the name "agent"
|
||||
const assistantId = "agent";
|
||||
@@ -91,7 +95,7 @@ If you have a `LANGSMITH_API_KEY` set in your environment, you do not need to ex
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# only pass the url argument to get_client() if you changed the default port when calling langgraph up
|
||||
# only pass the url argument to get_client() if you changed the default port when calling langgraph dev
|
||||
client = get_client()
|
||||
# Using the graph deployed with the name "agent"
|
||||
assistant_id = "agent"
|
||||
@@ -103,7 +107,7 @@ If you have a `LANGSMITH_API_KEY` set in your environment, you do not need to ex
|
||||
```js
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
// only set the apiUrl if you changed the default port when calling langgraph up
|
||||
// only set the apiUrl if you changed the default port when calling langgraph dev
|
||||
const client = new Client();
|
||||
// Using the graph deployed with the name "agent"
|
||||
const assistantId = "agent";
|
||||
|
||||
@@ -83,7 +83,7 @@ Now, let's import our required packages and instantiate our client, assistant, a
|
||||
|
||||
## Create runs
|
||||
|
||||
Now we can start our two runs and join the second on euntil it has completed:
|
||||
Now we can start our two runs and join the second one until it has completed:
|
||||
|
||||
=== "Python"
|
||||
|
||||
|
||||
@@ -7,17 +7,21 @@
|
||||
|
||||
Make sure you have setup your app correctly, by creating a compiled graph, a `.env` file with any environment variables, and a `langgraph.json` config file that points to your environment file and compiled graph. See [here](https://langchain-ai.github.io/langgraph/cloud/deployment/setup/) for more detailed instructions.
|
||||
|
||||
After you have your app setup, head into the directory with your `langgraph.json` file and call `langgraph up -c langgraph.json --watch` to start the API server in watch mode which means it will restart on code changes, which is ideal for local testing. If the API server start correctly you should see logs that look something like this:
|
||||
After you have your app setup, head into the directory with your `langgraph.json` file and call `langgraph dev` to start the API server in watch mode which means it will restart on code changes, which is ideal for local testing. If the API server start correctly you should see logs that look something like this:
|
||||
|
||||
Ready!
|
||||
- API: http://localhost:8123
|
||||
2024-06-26 19:20:41,056:INFO:uvicorn.access 127.0.0.1:44138 - "GET /ok HTTP/1.1" 200
|
||||
> Ready!
|
||||
>
|
||||
> - API: [http://localhost:2024](http://localhost:2024/)
|
||||
>
|
||||
> - Docs: http://localhost:2024/docs
|
||||
>
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
|
||||
Read this [reference](https://langchain-ai.github.io/langgraph/cloud/reference/cli/#up) to learn about all the options for starting the API server.
|
||||
|
||||
## Access Studio
|
||||
|
||||
Once you have successfully started the API server, you can access the studio by going to the following URL: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123` (see warning above if using Safari).
|
||||
Once you have successfully started the API server, you can access the studio by going to the following URL: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024` (see warning above if using Safari).
|
||||
|
||||
If everything is working correctly you should see the studio show up looking something like this (with your graph diagram on the left hand side):
|
||||
|
||||
|
||||
@@ -1,462 +1,272 @@
|
||||
# LangGraph Cloud Quick Start
|
||||
# Quickstart: Deploy on LangGraph Cloud
|
||||
|
||||
In this tutorial you will build and deploy a simple chatbot agent that can look things up on the internet. You will be using [LangGraph Cloud](../concepts/langgraph_cloud.md), [LangGraph Studio](../concepts/langgraph_studio.md) to visualize and test it out, and [LangGraph SDK](./reference/sdk/python_sdk_ref.md) to interact with the deployed agent.
|
||||
!!! note "Prerequisites"
|
||||
|
||||
If you want to learn how to build an agent like this from scratch, take a look at the [LangGraph Quick Start tutorial](../tutorials/introduction.ipynb).
|
||||
Before you begin, ensure you have the following:
|
||||
|
||||
## Set up requirements
|
||||
- [GitHub account](https://github.com/)
|
||||
- [LangSmith account](https://smith.langchain.com/)
|
||||
|
||||
This tutorial will use:
|
||||
## Create a repository on GitHub
|
||||
|
||||
- Anthropic for the LLM - sign up and get an API key [here](https://console.anthropic.com/).
|
||||
- Tavily for the search engine - sign up and get an API key [here](https://app.tavily.com/).
|
||||
- LangSmith for hosting - sign up and get an API key [here](https://smith.langchain.com/).
|
||||
To deploy a LangGraph application to **LangGraph Cloud**, your application code must reside in a GitHub repository. Both public and private repositories are supported.
|
||||
|
||||
## Create and configure your app
|
||||
You can deploy any [LangGraph Application](../concepts/application_structure.md) to LangGraph Cloud.
|
||||
|
||||
First, let's set create all of the necessary files for our LangGraph application.
|
||||
For this guide, we'll use the pre-built Python [**ReAct Agent**](https://github.com/langchain-ai/react-agent) template.
|
||||
|
||||
1. __Create application directory and files__
|
||||
??? note "Get Required API Keys for the ReAct Agent template"
|
||||
|
||||
Create a new application `my-app` with the following file structure:
|
||||
This **ReAct Agent** application requires an API key from [Anthropic](https://console.anthropic.com/) and [Tavily](https://app.tavily.com/). You can get these API keys by signing up on their respective websites.
|
||||
|
||||
```shell
|
||||
mkdir my-app
|
||||
```
|
||||
**Alternative**: If you'd prefer a scaffold application that doesn't require API keys, use the [**New LangGraph Project**](https://github.com/langchain-ai/new-langgraph-project) template instead of the **ReAct Agent** template.
|
||||
|
||||
=== "Python"
|
||||
|
||||
my-app/
|
||||
|-- agent.py # code for your LangGraph agent
|
||||
|-- requirements.txt # Python packages required for your graph
|
||||
|-- langgraph.json # configuration file for LangGraph
|
||||
|-- .env # environment files with API keys
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
my-app/
|
||||
|-- agent.ts # code for your LangGraph agent
|
||||
|-- package.json # Javascript packages required for your graph
|
||||
|-- langgraph.json # configuration file for LangGraph
|
||||
|-- .env # environment files with API keys
|
||||
|
||||
|
||||
1. __Define your graph__
|
||||
|
||||
=== "Python"
|
||||
The `agent.py` file should contain code with your graph.
|
||||
|
||||
=== "Javascript"
|
||||
The `agent.ts` file should contain code with your graph.
|
||||
|
||||
The following code example is a simple chatbot agent (similar to the one in the [previous tutorial](../tutorials/introduction.ipynb)). Specifically, it uses [create_react_agent][langgraph.prebuilt.chat_agent_executor.create_react_agent], a prebuilt [ReAct](../concepts/agentic_concepts.md#react-implementation)-style agent.
|
||||
|
||||
The `agent` file needs to have a variable with a [CompiledGraph][langgraph.graph.graph.CompiledGraph] (in this case the `graph` variable).
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python
|
||||
# agent.py
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
model = ChatAnthropic(model="claude-3-5-sonnet-20240620")
|
||||
|
||||
tools = [TavilySearchResults(max_results=2)]
|
||||
|
||||
# compiled graph
|
||||
graph = create_react_agent(model, tools)
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```ts
|
||||
// agent.ts
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import { TavilySearchResults } from "@langchain/community/tools/tavily_search";
|
||||
import { createReactAgent } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
const model = new ChatAnthropic({
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
});
|
||||
|
||||
const tools = [
|
||||
new TavilySearchResults({ maxResults: 3, }),
|
||||
];
|
||||
|
||||
// compiled graph
|
||||
export const graph = createReactAgent({ llm: model, tools });
|
||||
```
|
||||
|
||||
1. __Specify dependencies__
|
||||
|
||||
=== "Python"
|
||||
You should add dependencies for your graph(s) to `requirements.txt`.
|
||||
|
||||
=== "Javascript"
|
||||
You should add dependencies for your graph(s) to `package.json`.
|
||||
|
||||
In this case we only require four packages for our graph to run:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python
|
||||
langgraph
|
||||
langchain_anthropic
|
||||
tavily-python
|
||||
langchain_community
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```js
|
||||
{
|
||||
"name": "my-app",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"dependencies": {
|
||||
"@langchain/community": "^0.3.11",
|
||||
"@langchain/core": "^0.3.16",
|
||||
"@langchain/langgraph": "0.2.18",
|
||||
"@langchain/anthropic": "^0.3.7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. __Create LangGraph configuration file__
|
||||
|
||||
The [`langgraph.json`][langgraph.json] file is a configuration file that describes what graph(s) you are going to deploy. In this case we only have one graph: the compiled `graph` object from `agent.py` / `agent.ts`.
|
||||
|
||||
=== "Python"
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```json
|
||||
{
|
||||
"node_version": "20",
|
||||
"dockerfile_lines": [],
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/agent.ts:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
```
|
||||
|
||||
Learn more about the LangGraph CLI configuration file [here](./reference/cli.md#configuration-file).
|
||||
|
||||
1. __Specify environment variables__
|
||||
|
||||
The `.env` file should have any environment variables needed to run your graph. This will only be used for local testing, so if you are not testing locally you can skip this step.
|
||||
|
||||
!!! warning
|
||||
The `.env` file should NOT be included with the rest of source code in your Github repository. When creating a deployment using LangGraph Cloud, you will be able to specify the environment variables manually.
|
||||
|
||||
For this graph, we need two environment variables:
|
||||
|
||||
```shell
|
||||
ANTHROPIC_API_KEY=...
|
||||
TAVILY_API_KEY=...
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Learn more about different application structure options [here](../how-tos/index.md#application-structure).
|
||||
|
||||
Now that we have set everything up on our local file system, we are ready to test our graph locally.
|
||||
|
||||
## Test the app locally
|
||||
|
||||
To test the LangGraph app before deploying it using LangGraph Cloud, you can start the [LangGraph server](../concepts/langgraph_server.md) locally or use [LangGraph Studio](../concepts/langgraph_studio.md).
|
||||
|
||||
## Using local server
|
||||
|
||||
You can test your app by running [LangGraph server](../concepts/langgraph_server.md) locally. This is useful to make sure you have configured our [CLI configuration file][langgraph.json] correctly and can interact with your graph.
|
||||
|
||||
To run the server locally, you need to first install the LangGraph CLI:
|
||||
|
||||
```shell
|
||||
pip install langgraph-cli
|
||||
```
|
||||
|
||||
You can then test our API server locally. In order to run the server locally, you will need to add your `LANGSMITH_API_KEY` to the `.env` file.
|
||||
|
||||
```shell
|
||||
langgraph up
|
||||
```
|
||||
|
||||
This will start up the LangGraph API server locally. If this runs successfully, you should see something like:
|
||||
|
||||
```shell
|
||||
Ready!
|
||||
- API: http://localhost:8123
|
||||
```
|
||||
|
||||
First, let's verify that the server is running correctly by calling `/ok` endpoint:
|
||||
|
||||
```shell
|
||||
curl --request GET --url http://localhost:8123/ok
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
{"ok": "true"}
|
||||
```
|
||||
|
||||
Now we're ready to test the app with the real inputs!
|
||||
|
||||
```shell
|
||||
curl --request POST \
|
||||
--url http://localhost:8123/runs/stream \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"assistant_id": "agent",
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the weather in NYC?"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stream_mode": "updates"
|
||||
}'
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
data: {
|
||||
"agent": {
|
||||
"messages": [
|
||||
{
|
||||
"content": "The search results from Tavily provide the current weather conditions in New York City, including temperature, wind speed, precipitation, humidity, and cloud cover. According to the results, as of 3:00pm on October 30th, 2024, it is overcast in NYC with a temperature of around 66°F (19°C), light winds from the southwest around 8 mph (13 km/h), and 66% humidity.\n\nSo in summary, the current weather in NYC is overcast with mild temperatures in the mid 60sF and light winds, based on the search results. Let me know if you need any other details!",
|
||||
"type": "ai",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can see that our agent responds with the up-to-date search results!
|
||||
|
||||
### Using LangGraph Studio Desktop
|
||||
|
||||
You can also test your app locally with [LangGraph Studio](../concepts/langgraph_studio.md). LangGraph Studio offers a new way to develop LLM applications by providing a specialized agent IDE that enables visualization, interaction, and debugging of complex agentic applications.
|
||||
|
||||
With visual graphs and the ability to edit state, you can better understand agent workflows and iterate faster. LangGraph Studio integrates with LangSmith allowing you to collaborate with teammates to debug failure modes.
|
||||
|
||||
LangGraph Studio is available as a [desktop app](https://studio.langchain.com/) for MacOS users. Once you have installed the app, you can select `my-app` directory, which will automatically start the server locally and load the graph in the UI.
|
||||
|
||||
To interact with your chatbot agent in LangGraph Studio, you can add a new message in the `Input` section and press `Submit`.
|
||||
|
||||

|
||||
1. Go to the [ReAct Agent](https://github.com/langchain-ai/react-agent) repository.
|
||||
2. Fork the repository to your GitHub account by clicking the `Fork` button in the top right corner.
|
||||
|
||||
## Deploy to LangGraph Cloud
|
||||
|
||||
Once you've tested your graph locally and verified that it works as expected, you can deploy it to the LangGraph Cloud.
|
||||
??? note "1. Log in to [LangSmith](https://smith.langchain.com/)"
|
||||
|
||||
First, you'll need to turn the `my-app` directory into a GitHub repo and [push it to GitHub](https://docs.github.com/en/migrations/importing-source-code/using-the-command-line-to-import-source-code/adding-locally-hosted-code-to-github).
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/01_login.png)
|
||||
<figcaption>
|
||||
Go to [LangSmith](https://smith.langchain.com/) and log in. If you don't have an account, you can sign up for free.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
Once you have created your GitHub repository with a Python file containing your compiled graph as well as a `langgraph.json` with the configuration, you can head over to [LangSmith](https://smith.langchain.com/) and click on the graph icon (`LangGraph Cloud`) on the bottom of the left navbar. This will open the LangGraph deployments page. On this page, click the `+ New Deployment` button in the top right corner.
|
||||
|
||||

|
||||
??? note "2. Click on <em>LangGraph Platform</em> (the left sidebar)"
|
||||
|
||||
**_If you have not deployed to LangGraph Cloud before:_** there will be a button that shows up saying `Import from GitHub`. You’ll need to follow that flow to connect LangGraph Cloud to GitHub.
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/02_langgraph_platform.png)
|
||||
<figcaption>
|
||||
Select **LangGraph Platform** from the left sidebar.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
**_Once you have set up your GitHub connection:_** the new deployment page will look as follows:
|
||||
??? note "3. Click on + New Deployment (top right corner)"
|
||||
|
||||

|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/03_deployments_page.png)
|
||||
<figcaption>
|
||||
Click on **+ New Deployment** to create a new deployment. This button is located in the top right corner.
|
||||
It'll open a new modal where you can fill out the required fields.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
To deploy your application, you should do the following:
|
||||
??? note "4. Click on Import from GitHub (first time users)"
|
||||
|
||||
1. Select your GitHub username or organization from the selector
|
||||
1. Search for your repo to deploy in the search bar and select it
|
||||
1. Choose a name for your deployment
|
||||
1. In the `Git Branch` field, you can specify either the branch for the code you want to deploy, or the exact commit SHA.
|
||||
1. In the `LangGraph API config file` field, enter the path to your `langgraph.json` file (which in this case is just `langgraph.json`)
|
||||
1. If your application needs environment variables, add those in the `Environment Variables` section. They will be propagated to the underlying server so your code can access them. In this case, we will need `ANTHROPIC_API_KEY` and `TAVILY_API_KEY`.
|
||||
<figure markdown="1">
|
||||
[](deployment/img/04_create_new_deployment.png)
|
||||
<figcaption>
|
||||
Click on **Import from GitHub** and follow the instructions to connect your GitHub account. This step is needed for **first-time users** or to add private repositories that haven't been connected before.</figcaption>
|
||||
</figure>
|
||||
|
||||
Hit `Submit` and your application will start deploying!
|
||||
??? note "5. Select the repository, configure ENV vars etc"
|
||||
|
||||
After your deployment is complete, your deployments page should look as follows:
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/05_configure_deployment.png)
|
||||
<figcaption>
|
||||
Select the <strong>repository</strong>, add env variables and secrets, and set other configuration options.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||

|
||||
- **Repository**: Select the repository you forked earlier (or any other repository you want to deploy).
|
||||
- Set the secrets and environment variables required by your application. For the **ReAct Agent** template, you need to set the following secrets:
|
||||
- **ANTHROPIC_API_KEY**: Get an API key from [Anthropic](https://console.anthropic.com/).
|
||||
- **TAVILY_API_KEY**: Get an API key on the [Tavily website](https://app.tavily.com/).
|
||||
|
||||
## Interact with your deployment
|
||||
??? note "6. Click Submit to Deploy!"
|
||||
|
||||
### Using LangGraph Studio (Cloud)
|
||||
|
||||
On the deployment page for your application,, you should see a button in the top right corner that says `LangGraph Studio`. Clicking on this button will take you to the web version of LangGraph Studio. This is the same UI that you interacted with when [testing the app locally](#using-langgraph-studio-recommended), but instead of using a local LangGraph server, it uses the one from your LangGraph Cloud deployment.
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/05_configure_deployment.png)
|
||||
<figcaption>
|
||||
Please note that this step may ~15 minutes to complete. You can check the status of your deployment in the **Deployments** view.
|
||||
Click the <strong>Submit</strong> button at the top right corner to deploy your application.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||

|
||||
|
||||
### Using LangGraph SDK
|
||||
## Lagraph Studio Web UI
|
||||
|
||||
You can also interact with your deployed LangGraph application programmatically, using [LangGraph SDK](./reference/sdk/python_sdk_ref.md).
|
||||
Once your application is deployed, you can test it in **LangGraph Studio**.
|
||||
|
||||
First, make sure you have the SDK installed:
|
||||
??? note "1. Click on an existing deployment"
|
||||
|
||||
=== "Python"
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/07_deployments_page.png)
|
||||
<figcaption>
|
||||
Click on the deployment you just created to view more details.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
```shell
|
||||
pip install langgraph_sdk
|
||||
```
|
||||
??? note "2. Click on LangGraph Studio"
|
||||
|
||||
=== "Javascript"
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:300px"}](deployment/img/08_deployment_view.png)
|
||||
<figcaption>
|
||||
Click on the <strong>LangGraph Studio</strong> button to open LangGraph Studio.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
```shell
|
||||
yarn add @langchain/langgraph-sdk
|
||||
```
|
||||
<figure markdown="1">
|
||||
[{: style="max-height:400px"}](deployment/img/09_langgraph_studio.png)
|
||||
<figcaption>
|
||||
Sample graph run in LangGraph Studio.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
Before using, you need to get the URL of your LangGraph deployment. You can find this in the `Deployment` view. Click the URL to copy it to the clipboard.
|
||||
## Test the API
|
||||
|
||||
You also need to make sure you have set up your API key properly so you can authenticate with LangGraph Cloud.
|
||||
!!! note
|
||||
|
||||
The API calls below are for the **ReAct Agent** template. If you're deploying a different application, you may need to adjust the API calls accordingly.
|
||||
|
||||
Before using, you need to get the `URL` of your LangGraph deployment. You can find this in the `Deployment` view. Click the `URL` to copy it to the clipboard.
|
||||
|
||||
You also need to make sure you have set up your API key properly, so you can authenticate with LangGraph Cloud.
|
||||
|
||||
```shell
|
||||
export LANGSMITH_API_KEY=...
|
||||
```
|
||||
|
||||
The first thing to do when using the SDK is to setup our client, access our assistant, and create a thread to execute a run on:
|
||||
=== "Python SDK (Async)"
|
||||
|
||||
=== "Python"
|
||||
**Install the LangGraph Python SDK**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
```shell
|
||||
pip install langgraph-sdk
|
||||
```
|
||||
|
||||
client = get_client(url=<DEPLOYMENT_URL>)
|
||||
# get default assistant
|
||||
assistants = await client.assistants.search(metadata={"created_by": "system"})
|
||||
assistant = assistants[0]
|
||||
# create thread
|
||||
thread = await client.threads.create()
|
||||
print(thread)
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```js
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
|
||||
// get default assistant
|
||||
const assistants = await client.assistants.search({ metadata: {"created_by": "system"} })
|
||||
const assistant = assistants[0];
|
||||
// create thread
|
||||
const thread = await client.threads.create();
|
||||
console.log(thread)
|
||||
```
|
||||
|
||||
=== "CURL"
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url <DEPLOYMENT_URL>/assistants/search \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"metadata": {"created_by": "system"}
|
||||
}' &&
|
||||
curl --request POST \
|
||||
--url <DEPLOYMENT_URL>/threads \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{}'
|
||||
```
|
||||
|
||||
We can then execute a run on the thread:
|
||||
|
||||
=== "Python"
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```python
|
||||
input = {
|
||||
"messages": [{"role": "user", "content": "What is the weather in NYC?"}]
|
||||
}
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url="your-deployment-url", api_key="your-langsmith-api-key")
|
||||
|
||||
async for chunk in client.runs.stream(
|
||||
thread["thread_id"],
|
||||
assistant["assistant_id"],
|
||||
input=input,
|
||||
None, # Threadless run
|
||||
"agent", # Name of assistant. Defined in langgraph.json.
|
||||
input={
|
||||
"messages": [{
|
||||
"role": "human",
|
||||
"content": "What is LangGraph?",
|
||||
}],
|
||||
},
|
||||
stream_mode="updates",
|
||||
):
|
||||
if chunk.data:
|
||||
print(chunk.data)
|
||||
print(f"Receiving new event of type: {chunk.event}...")
|
||||
print(chunk.data)
|
||||
print("\n\n")
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
=== "Python SDK (Sync)"
|
||||
|
||||
**Install the LangGraph Python SDK**
|
||||
|
||||
```shell
|
||||
pip install langgraph-sdk
|
||||
```
|
||||
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_sync_client
|
||||
|
||||
client = get_sync_client(url="your-deployment-url", api_key="your-langsmith-api-key")
|
||||
|
||||
for chunk in client.runs.stream(
|
||||
None, # Threadless run
|
||||
"agent", # Name of assistant. Defined in langgraph.json.
|
||||
input={
|
||||
"messages": [{
|
||||
"role": "human",
|
||||
"content": "What is LangGraph?",
|
||||
}],
|
||||
},
|
||||
stream_mode="updates",
|
||||
):
|
||||
print(f"Receiving new event of type: {chunk.event}...")
|
||||
print(chunk.data)
|
||||
print("\n\n")
|
||||
```
|
||||
|
||||
=== "Javascript SDK"
|
||||
|
||||
**Install the LangGraph JS SDK**
|
||||
|
||||
```shell
|
||||
npm install @langchain/langgraph-sdk
|
||||
```
|
||||
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```js
|
||||
const input = { "messages": [{ "role": "user", "content": "What is the weather in NYC?" }] };
|
||||
const { Client } = await import("@langchain/langgraph-sdk");
|
||||
|
||||
const client = new Client({ apiUrl: "your-deployment-url", apiKey: "your-langsmith-api-key" });
|
||||
|
||||
const streamResponse = client.runs.stream(
|
||||
thread["thread_id"],
|
||||
assistant["assistant_id"],
|
||||
{
|
||||
input,
|
||||
streamMode: "updates"
|
||||
}
|
||||
null, // Threadless run
|
||||
"agent", // Assistant ID
|
||||
{
|
||||
input: {
|
||||
"messages": [
|
||||
{ "role": "user", "content": "What is LangGraph?"}
|
||||
]
|
||||
},
|
||||
streamMode: "messages",
|
||||
}
|
||||
);
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
if (chunk.data) {
|
||||
console.log(chunk.data);
|
||||
}
|
||||
console.log(`Receiving new event of type: ${chunk.event}...`);
|
||||
console.log(JSON.stringify(chunk.data));
|
||||
console.log("\n\n");
|
||||
}
|
||||
```
|
||||
|
||||
=== "CURL"
|
||||
=== "Rest API"
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"assistant_id": <ASSISTANT_ID>,
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the weather in NYC?"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stream_mode": "updates"
|
||||
}'
|
||||
curl -s --request POST \
|
||||
--url <DEPLOYMENT_URL> \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{
|
||||
\"assistant_id\": \"agent\",
|
||||
\"input\": {
|
||||
\"messages\": [
|
||||
{
|
||||
\"role\": \"human\",
|
||||
\"content\": \"What is LangGraph?\"
|
||||
}
|
||||
]
|
||||
},
|
||||
\"stream_mode\": \"updates\"
|
||||
}"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
data: {
|
||||
"agent": {
|
||||
"messages": [
|
||||
{
|
||||
"content": "The search results from Tavily provide the current weather conditions in New York City, including temperature, wind speed, precipitation, humidity, and cloud cover. According to the results, as of 3:00pm on October 30th, 2024, it is overcast in NYC with a temperature of around 66°F (19°C), light winds from the southwest around 8 mph (13 km/h), and 66% humidity.\n\nSo in summary, the current weather in NYC is overcast with mild temperatures in the mid 60sF and light winds, based on the search results. Let me know if you need any other details!",
|
||||
"type": "ai",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next steps
|
||||
## Next Steps
|
||||
|
||||
Congratulations! If you've worked your way through this tutorial you are well on your way to becoming a LangGraph Cloud expert. Here are some other resources to check out to help you out on the path to expertise:
|
||||
|
||||
* [LangGraph How-to guides](../how-tos/index.md)
|
||||
* [LangGraph Tutorials](../tutorials/index.md)
|
||||
### LangGraph Framework
|
||||
|
||||
- **[LangGraph Tutorial](../tutorials/introduction.ipynb)**: Get started with LangGraph framework.
|
||||
- **[LangGraph Concepts](../concepts/index.md)**: Learn the foundational concepts of LangGraph.
|
||||
- **[LangGraph How-to Guides](../how-tos/index.md)**: Guides for common tasks with LangGraph.
|
||||
|
||||
### 📚 Learn More about LangGraph Platform
|
||||
|
||||
Expand your knowledge with these resources:
|
||||
|
||||
- **[LangGraph Platform Concepts](../concepts/index.md#langgraph-platform)**: Understand the foundational concepts of the LangGraph Platform.
|
||||
- **[LangGraph Platform How-to Guides](../how-tos/index.md#langgraph-platform)**: Discover step-by-step guides to build and deploy applications.
|
||||
- **[Launch Local LangGraph Server](../tutorials/langgraph-platform/local-server.md)**: This quick start guide shows how to start a LangGraph Server locally for the **ReAct Agent** template. The steps are similar for other templates.
|
||||
|
||||
|
||||
|
||||
@@ -21,14 +21,15 @@ The LangGraph command line interface includes commands to build and run a LangGr
|
||||
|
||||
[](){#langgraph.json}
|
||||
|
||||
## Configuration File
|
||||
## Configuration File {#configuration-file}
|
||||
|
||||
The LangGraph CLI requires a JSON configuration file with the following keys:
|
||||
|
||||
| Key | Description |
|
||||
| Key | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dependencies` | **Required**. Array of dependencies for LangGraph Cloud API server. Dependencies can be one of the following: (1) `"."`, which will look for local Python packages, (2) `pyproject.toml`, `setup.py` or `requirements.txt` in the app directory `"./local_package"`, or (3) a package name. |
|
||||
| `graphs` | **Required**. Mapping from graph ID to path where the compiled graph or a function that makes a graph is defined. Example: <ul><li>`./your_package/your_file.py:variable`, where `variable` is an instance of `langgraph.graph.state.CompiledStateGraph`</li><li>`./your_package/your_file.py:make_graph`, where `make_graph` is a function that takes a config dictionary (`langchain_core.runnables.RunnableConfig`) and creates an instance of `langgraph.graph.state.StateGraph` / `langgraph.graph.state.CompiledStateGraph`.</li></ul> |
|
||||
| `auth` | _(Added in v0.0.11)_ Auth configuration containing the path to your authentication handler. Example: `./your_package/auth.py:auth`, where `auth` is an instance of `langgraph_sdk.Auth`. See [authentication guide](../../concepts/auth.md) for details. |
|
||||
| `env` | Path to `.env` file or a mapping from environment variable to its value. |
|
||||
| `store` | Configuration for adding semantic search to the BaseStore. Contains the following fields: <ul><li>`index`: Configuration for semantic search indexing with fields:<ul><li>`embed`: Embedding provider (e.g., "openai:text-embedding-3-small") or path to custom embedding function</li><li>`dims`: Dimension size of the embedding model. Used to initialize the vector table.</li><li>`fields` (optional): List of fields to index. Defaults to `["$"]`, meaningto index entire documents. Can be specific fields like `["text", "summary", "some.value"]`</li></ul></li></ul> |
|
||||
| `python_version` | `3.11` or `3.12`. Defaults to `3.11`. |
|
||||
@@ -120,6 +121,35 @@ def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
return [[0.1, 0.2, ...] for _ in texts] # dims-dimensional vectors
|
||||
```
|
||||
|
||||
#### Adding custom authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"chat": "./chat/graph.py:graph"
|
||||
},
|
||||
"auth": {
|
||||
"path": "./auth.py:auth",
|
||||
"openapi": {
|
||||
"securitySchemes": {
|
||||
"apiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{"apiKeyAuth": []}
|
||||
]
|
||||
},
|
||||
"disable_studio_auth": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [authentication conceptual guide](../../concepts/auth.md) for details, and the [setting up custom authentication](../../tutorials/auth/getting_started.md) guide for a practical walk through of the process.
|
||||
|
||||
## Commands
|
||||
|
||||
The base command for the LangGraph CLI is `langgraph`.
|
||||
|
||||
@@ -6,3 +6,12 @@
|
||||
|
||||
::: langgraph_sdk.schema
|
||||
handler: python
|
||||
|
||||
::: langgraph_sdk.auth
|
||||
handler: python
|
||||
|
||||
::: langgraph_sdk.auth.types
|
||||
handler: python
|
||||
|
||||
::: langgraph_sdk.auth.exceptions
|
||||
handler: python
|
||||
@@ -1,26 +1,26 @@
|
||||
# Agent architectures
|
||||
|
||||
Many LLM applications implement a particular control flow of steps before and / or after LLM calls. As an example, [RAG](https://github.com/langchain-ai/rag-from-scratch) performs retrieval of relevant documents to a question, and passes those documents to an LLM in order to ground the model's response.
|
||||
Many LLM applications implement a particular control flow of steps before and / or after LLM calls. As an example, [RAG](https://github.com/langchain-ai/rag-from-scratch) performs retrieval of documents relevant to a user question, and passes those documents to an LLM in order to ground the model's response in the provided document context.
|
||||
|
||||
Instead of hard-coding a fixed control flow, we sometimes want LLM systems that can pick its own control flow to solve more complex problems! This is one definition of an [agent](https://blog.langchain.dev/what-is-an-agent/): *an agent is a system that uses an LLM to decide the control flow of an application.* There are many ways that an LLM can control application:
|
||||
Instead of hard-coding a fixed control flow, we sometimes want LLM systems that can pick their own control flow to solve more complex problems! This is one definition of an [agent](https://blog.langchain.dev/what-is-an-agent/): *an agent is a system that uses an LLM to decide the control flow of an application.* There are many ways that an LLM can control application:
|
||||
|
||||
- An LLM can route between two potential paths
|
||||
- An LLM can decide which of many tools to call
|
||||
- An LLM can decide whether the generated answer is sufficient or more work is needed
|
||||
|
||||
As a result, there are many different types of [agent architectures](https://blog.langchain.dev/what-is-a-cognitive-architecture/), which given an LLM varying levels of control.
|
||||
As a result, there are many different types of [agent architectures](https://blog.langchain.dev/what-is-a-cognitive-architecture/), which give an LLM varying levels of control.
|
||||
|
||||

|
||||
|
||||
## Router
|
||||
|
||||
A router allows an LLM to select a single step from a specified set of options. This is an agent architecture that exhibits a relatively limited level of control because the LLM usually governs a single decision and can return a narrow set of outputs. Routers typically employ a few different concepts to achieve this.
|
||||
A router allows an LLM to select a single step from a specified set of options. This is an agent architecture that exhibits a relatively limited level of control because the LLM usually focuses on making a single decision and produces a specific output from limited set of pre-defined options. Routers typically employ a few different concepts to achieve this.
|
||||
|
||||
### Structured Output
|
||||
|
||||
Structured outputs with LLMs work by providing a specific format or schema that the LLM should follow in its response. This is similar to tool calling, but more general. While tool calling typically involves selecting and using predefined functions, structured outputs can be used for any type of formatted response. Common methods to achieve structured outputs include:
|
||||
|
||||
1. Prompt engineering: Instructing the LLM to respond in a specific format.
|
||||
1. Prompt engineering: Instructing the LLM to respond in a specific format via the system prompt.
|
||||
2. Output parsers: Using post-processing to extract structured data from LLM responses.
|
||||
3. Tool calling: Leveraging built-in tool calling capabilities of some LLMs to generate structured outputs.
|
||||
|
||||
@@ -30,7 +30,7 @@ Structured outputs are crucial for routing as they ensure the LLM's decision can
|
||||
|
||||
While a router allows an LLM to make a single decision, more complex agent architectures expand the LLM's control in two key ways:
|
||||
|
||||
1. Multi-step decision making: The LLM can control a sequence of decisions rather than just one.
|
||||
1. Multi-step decision making: The LLM can make a series of decisions, one after another, instead of just one.
|
||||
2. Tool access: The LLM can choose from and use a variety of tools to accomplish tasks.
|
||||
|
||||
[ReAct](https://arxiv.org/abs/2210.03629) is a popular general purpose agent architecture that combines these expansions, integrating three core concepts.
|
||||
@@ -39,13 +39,13 @@ While a router allows an LLM to make a single decision, more complex agent archi
|
||||
2. `Memory`: Enabling the agent to retain and use information from previous steps.
|
||||
3. `Planning`: Empowering the LLM to create and follow multi-step plans to achieve goals.
|
||||
|
||||
This architecture allows for more complex and flexible agent behaviors, going beyond simple routing to enable dynamic problem-solving across multiple steps. You can use it with [`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent].
|
||||
This architecture allows for more complex and flexible agent behaviors, going beyond simple routing to enable dynamic problem-solving with multiple steps. You can use it with [`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent].
|
||||
|
||||
### Tool calling
|
||||
|
||||
Tools are useful whenever you want an agent to interact with external systems. External systems (e.g., APIs) often require a particular input schema or payload, rather than natural language. When we bind an API, for example, as a tool we given the model awareness of the required input schema. The model will choose to call a tool based upon the natural language input from the user and it will return an output that adheres to the tool's schema.
|
||||
Tools are useful whenever you want an agent to interact with external systems. External systems (e.g., APIs) often require a particular input schema or payload, rather than natural language. When we bind an API, for example, as a tool, we give the model awareness of the required input schema. The model will choose to call a tool based upon the natural language input from the user and it will return an output that adheres to the tool's required schema.
|
||||
|
||||
[Many LLM providers support tool calling](https://python.langchain.com/v0.1/docs/integrations/chat/) and [tool calling interface](https://blog.langchain.dev/improving-core-tool-interfaces-and-docs-in-langchain/) in LangChain is simple: you can simply pass any Python `function` into `ChatModel.bind_tools(function)`.
|
||||
[Many LLM providers support tool calling](https://python.langchain.com/docs/integrations/chat/) and [tool calling interface](https://blog.langchain.dev/improving-core-tool-interfaces-and-docs-in-langchain/) in LangChain is simple: you can simply pass any Python `function` into `ChatModel.bind_tools(function)`.
|
||||
|
||||

|
||||
|
||||
@@ -67,11 +67,11 @@ Effective memory management enhances an agent's ability to maintain context, lea
|
||||
|
||||
### Planning
|
||||
|
||||
In the ReAct architecture, an LLM is called repeatedly in a while-loop. At each step the agent decides which tools to call, and what the inputs to those tools should be. Those tools are then executed, and the outputs are fed back into the LLM as observations. The while-loop terminates when the agent decides it is not worth calling any more tools.
|
||||
In the ReAct architecture, an LLM is called repeatedly in a while-loop. At each step the agent decides which tools to call, and what the inputs to those tools should be. Those tools are then executed, and the outputs are fed back into the LLM as observations. The while-loop terminates when the agent decides it has enough information to solve the user request and it is not worth calling any more tools.
|
||||
|
||||
### ReAct implementation
|
||||
|
||||
There are several differences between this paper and the pre-built [`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent] implementation:
|
||||
There are several differences between [this](https://arxiv.org/abs/2210.03629) paper and the pre-built [`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent] implementation:
|
||||
|
||||
- First, we use [tool-calling](#tool-calling) to have LLMs call tools, whereas the paper used prompting + parsing of raw output. This is because tool calling did not exist when the paper was written, but is generally better and more reliable.
|
||||
- Second, we use messages to prompt the LLM, whereas the paper used string formatting. This is because at the time of writing, LLMs didn't even expose a message-based interface, whereas now that's the only interface they expose.
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
# Authentication & Access Control
|
||||
|
||||
LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes.
|
||||
|
||||
!!! note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Authentication vs Authorization
|
||||
|
||||
While often used interchangeably, these terms represent distinct security concepts:
|
||||
|
||||
- [**Authentication**](#authentication) ("AuthN") verifies _who_ you are. This runs as middleware for every request.
|
||||
- [**Authorization**](#authorization) ("AuthZ") determines _what you can do_. This validates the user's privileges and roles on a per-resource basis.
|
||||
|
||||
In LangGraph Platform, authentication is handled by your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler, and authorization is handled by your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers.
|
||||
|
||||
## Default Security Models
|
||||
|
||||
LangGraph Platform provides different security defaults:
|
||||
|
||||
### LangGraph Cloud
|
||||
|
||||
- Uses LangSmith API keys by default
|
||||
- Requires valid API key in `x-api-key` header
|
||||
- Can be customized with your auth handler
|
||||
|
||||
### Self-Hosted
|
||||
|
||||
- No default authentication
|
||||
- Complete flexibility to implement your security model
|
||||
- You control all aspects of authentication and authorization
|
||||
|
||||
## System Architecture
|
||||
|
||||
A typical authentication setup involves three main components:
|
||||
|
||||
1. **Authentication Provider** (Identity Provider/IdP)
|
||||
|
||||
* A dedicated service that manages user identities and credentials
|
||||
* Handles user registration, login, password resets, etc.
|
||||
* Issues tokens (JWT, session tokens, etc.) after successful authentication
|
||||
* Examples: Auth0, Supabase Auth, Okta, or your own auth server
|
||||
|
||||
2. **LangGraph Backend** (Resource Server)
|
||||
|
||||
* Your LangGraph application that contains business logic and protected resources
|
||||
* Validates tokens with the auth provider
|
||||
* Enforces access control based on user identity and permissions
|
||||
* Doesn't store user credentials directly
|
||||
|
||||
3. **Client Application** (Frontend)
|
||||
|
||||
* Web app, mobile app, or API client
|
||||
* Collects time-sensitive user credentials and sends to auth provider
|
||||
* Receives tokens from auth provider
|
||||
* Includes these tokens in requests to LangGraph backend
|
||||
|
||||
Here's how these components typically interact:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client App
|
||||
participant Auth as Auth Provider
|
||||
participant LG as LangGraph Backend
|
||||
|
||||
Client->>Auth: 1. Login (username/password)
|
||||
Auth-->>Client: 2. Return token
|
||||
Client->>LG: 3. Request with token
|
||||
Note over LG: 4. Validate token (@auth.authenticate)
|
||||
LG-->>Auth: 5. Fetch user info
|
||||
Auth-->>LG: 6. Confirm validity
|
||||
Note over LG: 7. Apply access control (@auth.on.*)
|
||||
LG-->>Client: 8. Return resources
|
||||
```
|
||||
|
||||
Your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler in LangGraph handles steps 4-6, while your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers implement step 7.
|
||||
|
||||
## Authentication
|
||||
|
||||
Authentication in LangGraph runs as middleware on every request. Your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler receives request information and should:
|
||||
|
||||
1. Validate the credentials
|
||||
2. Return [user info](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.MinimalUserDict) containing the user's identity and user information if valid
|
||||
3. Raise an [HTTP exception](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.exceptions.HTTPException) or AssertionError if invalid
|
||||
|
||||
```python
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
auth = Auth()
|
||||
|
||||
@auth.authenticate
|
||||
async def authenticate(headers: dict) -> Auth.types.MinimalUserDict:
|
||||
# Validate credentials (e.g., API key, JWT token)
|
||||
api_key = headers.get("x-api-key")
|
||||
if not api_key or not is_valid_key(api_key):
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid API key"
|
||||
)
|
||||
|
||||
# Return user info - only identity and is_authenticated are required
|
||||
# Add any additional fields you need for authorization
|
||||
return {
|
||||
"identity": "user-123", # Required: unique user identifier
|
||||
"is_authenticated": True, # Optional: assumed True by default
|
||||
"permissions": ["read", "write"] # Optional: for permission-based auth
|
||||
# You can add more custom fields if you want to implement other auth patterns
|
||||
"role": "admin",
|
||||
"org_id": "org-456"
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The returned user information is available:
|
||||
|
||||
- To your authorization handlers via [`ctx.user`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AuthContext)
|
||||
- In your application via `config["configuration"]["langgraph_auth_user"]`
|
||||
|
||||
??? tip "Supported Parameters"
|
||||
|
||||
The [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler can accept any of the following parameters by name:
|
||||
|
||||
* request (Request): The raw ASGI request object
|
||||
* body (dict): The parsed request body
|
||||
* path (str): The request path, e.g., "/threads/abcd-1234-abcd-1234/runs/abcd-1234-abcd-1234/stream"
|
||||
* method (str): The HTTP method, e.g., "GET"
|
||||
* path_params (dict[str, str]): URL path parameters, e.g., {"thread_id": "abcd-1234-abcd-1234", "run_id": "abcd-1234-abcd-1234"}
|
||||
* query_params (dict[str, str]): URL query parameters, e.g., {"stream": "true"}
|
||||
* headers (dict[bytes, bytes]): Request headers
|
||||
* authorization (str | None): The Authorization header value (e.g., "Bearer <token>")
|
||||
|
||||
In many of our tutorials, we will just show the "authorization" parameter to be concise, but you can opt to accept more information as needed
|
||||
to implement your custom authentication scheme.
|
||||
|
||||
## Authorization
|
||||
|
||||
After authentication, LangGraph calls your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers to control access to specific resources (e.g., threads, assistants, crons). These handlers can:
|
||||
|
||||
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](##supported-actions) for the list of types the value can take for each action.
|
||||
2. Filter resources by metadata during search/list or read operations by returning a [filter dictionary](#filter-operations).
|
||||
3. Raise an HTTP exception if access is denied.
|
||||
|
||||
If you want to just implement simple user-scoped access control, you can use a single [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handler for all resources and actions. If you want to have different control depending on the resource and action, you can use [resource-specific handlers](#resource-specific-handlers). See the [Supported Resources](#supported-resources) section for a full list of the resources that support access control.
|
||||
|
||||
```python
|
||||
@auth.on
|
||||
async def add_owner(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: dict # The payload being sent to this access method
|
||||
) -> dict: # Returns a filter dict that restricts access to resources
|
||||
"""Authorize all access to threads, runs, crons, and assistants.
|
||||
|
||||
This handler does two things:
|
||||
- Adds a value to resource metadata (to persist with the resource so it can be filtered later)
|
||||
- Returns a filter (to restrict access to existing resources)
|
||||
|
||||
Args:
|
||||
ctx: Authentication context containing user info, permissions, the path, and
|
||||
value: The request payload sent to the endpoint. For creation
|
||||
operations, this contains the resource parameters. For read
|
||||
operations, this contains the resource being accessed.
|
||||
|
||||
Returns:
|
||||
A filter dictionary that LangGraph uses to restrict access to resources.
|
||||
See [Filter Operations](#filter-operations) for supported operators.
|
||||
"""
|
||||
# Create filter to restrict access to just this user's resources
|
||||
filters = {"owner": ctx.user.identity}
|
||||
|
||||
# Get or create the metadata dictionary in the payload
|
||||
# This is where we store persistent info about the resource
|
||||
metadata = value.setdefault("metadata", {})
|
||||
|
||||
# Add owner to metadata - if this is a create or update operation,
|
||||
# this information will be saved with the resource
|
||||
# So we can filter by it later in read operations
|
||||
metadata.update(filters)
|
||||
|
||||
# Return filters to restrict access
|
||||
# These filters are applied to ALL operations (create, read, update, search, etc.)
|
||||
# to ensure users can only access their own resources
|
||||
return filters
|
||||
```
|
||||
|
||||
### Resource-Specific Handlers {#resource-specific-handlers}
|
||||
|
||||
You can register handlers for specific resources and actions by chaining the resource and action names together with the [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) decorator.
|
||||
When a request is made, the most specific handler that matches that resource and action is called. Below is an example of how to register handlers for specific resources and actions. For the following setup:
|
||||
|
||||
1. Authenticated users are able to create threads, read thread, create runs on threads
|
||||
2. Only users with the "assistants:create" permission are allowed to create new assistants
|
||||
3. All other endpoints (e.g., e.g., delete assistant, crons, store) are disabled for all users.
|
||||
|
||||
!!! tip "Supported Handlers"
|
||||
|
||||
For a full list of supported resources and actions, see the [Supported Resources](#supported-resources) section below.
|
||||
|
||||
```python
|
||||
# Generic / global handler catches calls that aren't handled by more specific handlers
|
||||
@auth.on
|
||||
async def reject_unhandled_requests(ctx: Auth.types.AuthContext, value: Any) -> False:
|
||||
print(f"Request to {ctx.path} by {ctx.user.identity}")
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden"
|
||||
)
|
||||
|
||||
# Matches the "thread" resource and all actions - create, read, update, delete, search
|
||||
# Since this is **more specific** than the generic @auth.on handler, it will take precedence
|
||||
# over the generic handler for all actions on the "threads" resource
|
||||
@auth.on.threads
|
||||
async def on_thread_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.threads.create.value
|
||||
):
|
||||
if "write" not in ctx.permissions:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="User lacks the required permissions."
|
||||
)
|
||||
# Setting metadata on the thread being created
|
||||
# will ensure that the resource contains an "owner" field
|
||||
# Then any time a user tries to access this thread or runs within the thread,
|
||||
# we can filter by owner
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
# Thread creation. This will match only on thread create actions
|
||||
# Since this is **more specific** than both the generic @auth.on handler and the @auth.on.threads handler,
|
||||
# it will take precedence for any "create" actions on the "threads" resources
|
||||
@auth.on.threads.create
|
||||
async def on_thread_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.threads.create.value
|
||||
):
|
||||
# Setting metadata on the thread being created
|
||||
# will ensure that the resource contains an "owner" field
|
||||
# Then any time a user tries to access this thread or runs within the thread,
|
||||
# we can filter by owner
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
# Reading a thread. Since this is also more specific than the generic @auth.on handler, and the @auth.on.threads handler,
|
||||
# it will take precedence for any "read" actions on the "threads" resource
|
||||
@auth.on.threads.read
|
||||
async def on_thread_read(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.threads.read.value
|
||||
):
|
||||
# Since we are reading (and not creating) a thread,
|
||||
# we don't need to set metadata. We just need to
|
||||
# return a filter to ensure users can only see their own threads
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
# Run creation, streaming, updates, etc.
|
||||
# This takes precedenceover the generic @auth.on handler and the @auth.on.threads handler
|
||||
@auth.on.threads.create_run
|
||||
async def on_run_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.threads.create_run.value
|
||||
):
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
# Inherit thread's access control
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
# Assistant creation
|
||||
@auth.on.assistants.create
|
||||
async def on_assistant_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.assistants.create.value
|
||||
):
|
||||
if "assistants:create" not in ctx.permissions:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="User lacks the required permissions."
|
||||
)
|
||||
```
|
||||
|
||||
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action. Requests to create, update,
|
||||
|
||||
### Filter Operations {#filter-operations}
|
||||
|
||||
Authorization handlers can return `None`, a boolean, or a filter dictionary.
|
||||
- `None` and `True` mean "authorize access to all underling resources"
|
||||
- `False` means "deny access to all underling resources (raises a 403 exception)"
|
||||
- A metadata filter dictionary will restrict access to resources
|
||||
|
||||
A filter dictionary is a dictionary with keys that match the resource metadata. It supports three operators:
|
||||
|
||||
- The default value is a shorthand for exact match, or "$eq", below. For example, `{"owner": user_id}` will include only resources with metadata containing `{"owner": user_id}`
|
||||
- `$eq`: Exact match (e.g., `{"owner": {"$eq": user_id}}`) - this is equivalent to the shorthand above, `{"owner": user_id}`
|
||||
- `$contains`: List membership (e.g., `{"allowed_users": {"$contains": user_id}}`) The value here must be an element of the list. The metadata in the stored resource must be a list/container type.
|
||||
|
||||
A dictionary with multiple keys is treated using a logical `AND` filter. For example, `{"owner": org_id, "allowed_users": {"$contains": user_id}}` will only match resources with metadata whose "owner" is `org_id` and whose "allowed_users" list contains `user_id`.
|
||||
See the reference [here](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.FilterType) for more information.
|
||||
|
||||
## Common Access Patterns
|
||||
|
||||
Here are some typical authorization patterns:
|
||||
|
||||
### Single-Owner Resources
|
||||
|
||||
This common pattern lets you scope all threads, assistants, crons, and runs to a single user. It's useful for common single-user use cases like regular chatbot-style apps.
|
||||
|
||||
```python
|
||||
@auth.on
|
||||
async def owner_only(ctx: Auth.types.AuthContext, value: dict):
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
return {"owner": ctx.user.identity}
|
||||
```
|
||||
|
||||
### Permission-based Access
|
||||
|
||||
This pattern lets you control access based on **permissions**. It's useful if you want certain roles to have broader or more restricted access to resources.
|
||||
|
||||
```python
|
||||
# In your auth handler:
|
||||
@auth.authenticate
|
||||
async def authenticate(headers: dict) -> Auth.types.MinimalUserDict:
|
||||
...
|
||||
return {
|
||||
"identity": "user-123",
|
||||
"is_authenticated": True,
|
||||
"permissions": ["threads:write", "threads:read"] # Define permissions in auth
|
||||
}
|
||||
|
||||
def _default(ctx: Auth.types.AuthContext, value: dict):
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
@auth.on.threads.create
|
||||
async def create_thread(ctx: Auth.types.AuthContext, value: dict):
|
||||
if "threads:write" not in ctx.permissions:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="Unauthorized"
|
||||
)
|
||||
return _default(ctx, value)
|
||||
|
||||
|
||||
@auth.on.threads.read
|
||||
async def rbac_create(ctx: Auth.types.AuthContext, value: dict):
|
||||
if "threads:read" not in ctx.permissions and "threads:write" not in ctx.permissions:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="Unauthorized"
|
||||
)
|
||||
return _default(ctx, value)
|
||||
```
|
||||
|
||||
## Supported Resources
|
||||
|
||||
LangGraph provides three levels of authorization handlers, from most general to most specific:
|
||||
|
||||
1. **Global Handler** (`@auth.on`): Matches all resources and actions
|
||||
2. **Resource Handler** (e.g., `@auth.on.threads`, `@auth.on.assistants`, `@auth.on.crons`): Matches all actions for a specific resource
|
||||
3. **Action Handler** (e.g., `@auth.on.threads.create`, `@auth.on.threads.read`): Matches a specific action on a specific resource
|
||||
|
||||
The most specific matching handler will be used. For example, `@auth.on.threads.create` takes precedence over `@auth.on.threads` for thread creation.
|
||||
If a more specific handler is registered, the more general handler will not be called for that resource and action.
|
||||
|
||||
???+ tip "Type Safety"
|
||||
Each handler has type hints available for its `value` parameter at `Auth.types.on.<resource>.<action>.value`. For example:
|
||||
```python
|
||||
@auth.on.threads.create
|
||||
async def on_thread_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.on.threads.create.value # Specific type for thread creation
|
||||
):
|
||||
...
|
||||
|
||||
@auth.on.threads
|
||||
async def on_threads(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.on.threads.value # Union type of all thread actions
|
||||
):
|
||||
...
|
||||
|
||||
@auth.on
|
||||
async def on_all(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: dict # Union type of all possible actions
|
||||
):
|
||||
...
|
||||
```
|
||||
More specific handlers provide better type hints since they handle fewer action types.
|
||||
|
||||
#### Supported actions and types {#supported-actions}
|
||||
Here are all the supported action handlers:
|
||||
|
||||
| Resource | Handler | Description | Value Type |
|
||||
|----------|---------|-------------|------------|
|
||||
| **Threads** | `@auth.on.threads.create` | Thread creation | [`ThreadsCreate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.ThreadsCreate) |
|
||||
| | `@auth.on.threads.read` | Thread retrieval | [`ThreadsRead`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.ThreadsRead) |
|
||||
| | `@auth.on.threads.update` | Thread updates | [`ThreadsUpdate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.ThreadsUpdate) |
|
||||
| | `@auth.on.threads.delete` | Thread deletion | [`ThreadsDelete`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.ThreadsDelete) |
|
||||
| | `@auth.on.threads.search` | Listing threads | [`ThreadsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.ThreadsSearch) |
|
||||
| | `@auth.on.threads.create_run` | Creating or updating a run | [`RunsCreate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.RunsCreate) |
|
||||
| **Assistants** | `@auth.on.assistants.create` | Assistant creation | [`AssistantsCreate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AssistantsCreate) |
|
||||
| | `@auth.on.assistants.read` | Assistant retrieval | [`AssistantsRead`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AssistantsRead) |
|
||||
| | `@auth.on.assistants.update` | Assistant updates | [`AssistantsUpdate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AssistantsUpdate) |
|
||||
| | `@auth.on.assistants.delete` | Assistant deletion | [`AssistantsDelete`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AssistantsDelete) |
|
||||
| | `@auth.on.assistants.search` | Listing assistants | [`AssistantsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AssistantsSearch) |
|
||||
| **Crons** | `@auth.on.crons.create` | Cron job creation | [`CronsCreate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsCreate) |
|
||||
| | `@auth.on.crons.read` | Cron job retrieval | [`CronsRead`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsRead) |
|
||||
| | `@auth.on.crons.update` | Cron job updates | [`CronsUpdate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsUpdate) |
|
||||
| | `@auth.on.crons.delete` | Cron job deletion | [`CronsDelete`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsDelete) |
|
||||
| | `@auth.on.crons.search` | Listing cron jobs | [`CronsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsSearch) |
|
||||
|
||||
???+ note "About Runs"
|
||||
Runs are scoped to their parent thread for access control. This means permissions are typically inherited from the thread, reflecting the conversational nature of the data model. All run operations (reading, listing) except creation are controlled by the thread's handlers.
|
||||
There is a specific `create_run` handler for creating new runs because it had more arguments that you can view in the handler.
|
||||
|
||||
|
||||
## Next Steps
|
||||
|
||||
For implementation details:
|
||||
|
||||
- Check out the introductory tutorial on [setting up authentication](../tutorials/auth/getting_started.md)
|
||||
- See the how-to guide on implementing a [custom auth handlers](../how-tos/auth/custom_auth.md)
|
||||
@@ -16,7 +16,7 @@ If you do not want to use LangGraph Platform, we describe the options we have im
|
||||
|
||||
## Reject
|
||||
|
||||
This is the simplest option, this just rejects any follow up runs and does not allow double texting.
|
||||
This is the simplest option, this just rejects any follow-up runs and does not allow double texting.
|
||||
See the [how-to guide](../cloud/how-tos/reject_concurrent.md) for configuring the reject double text option.
|
||||
|
||||
## Enqueue
|
||||
|
||||
@@ -22,21 +22,21 @@ Yes. LangGraph is an MIT-licensed open-source library and is free to use.
|
||||
|
||||
LangGraph is a stateful, orchestration framework that brings added control to agent workflows. LangGraph Platform is a service for deploying and scaling LangGraph applications, with an opinionated API for building agent UXs, plus an integrated developer studio.
|
||||
|
||||
| Features | LangGraph (open source) | LangGraph Platform |
|
||||
|----------|------------------------|-------------------|
|
||||
| Description | Stateful orchestration framework for agentic applications | Scalable infrastructure for deploying LangGraph applications |
|
||||
| SDKs | Python and JavaScript | Python and JavaScript |
|
||||
| HTTP APIs | None | Yes - useful for retrieving & updating state or long-term memory, or creating a configurable assistant |
|
||||
| Streaming | Basic | Dedicated mode for token-by-token messages |
|
||||
| Checkpointer | Community contributed | Supported out-of-the-box |
|
||||
| Persistence Layer | Self-managed | Managed Postgres with efficient storage |
|
||||
| Deployment | Self-managed | • Cloud SaaS <br> • Free self-hosted <br> • Enterprise (BYOC or paid self-hosted) |
|
||||
| Scalability | Self-managed | Auto-scaling of task queues and servers |
|
||||
| Fault-tolerance | Self-managed | Automated retries |
|
||||
| Concurrency Control | Simple threading | Supports double-texting |
|
||||
| Scheduling | None | Cron scheduling |
|
||||
| Monitoring | None | Integrated with LangSmith for observability |
|
||||
| IDE integration | LangGraph Studio for Desktop | LangGraph Studio for Desktop & Cloud |
|
||||
| Features | LangGraph (open source) | LangGraph Platform |
|
||||
|---------------------|-----------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
|
||||
| Description | Stateful orchestration framework for agentic applications | Scalable infrastructure for deploying LangGraph applications |
|
||||
| SDKs | Python and JavaScript | Python and JavaScript |
|
||||
| HTTP APIs | None | Yes - useful for retrieving & updating state or long-term memory, or creating a configurable assistant |
|
||||
| Streaming | Basic | Dedicated mode for token-by-token messages |
|
||||
| Checkpointer | Community contributed | Supported out-of-the-box |
|
||||
| Persistence Layer | Self-managed | Managed Postgres with efficient storage |
|
||||
| Deployment | Self-managed | • Cloud SaaS <br> • Free self-hosted <br> • Enterprise (BYOC or paid self-hosted) |
|
||||
| Scalability | Self-managed | Auto-scaling of task queues and servers |
|
||||
| Fault-tolerance | Self-managed | Automated retries |
|
||||
| Concurrency Control | Simple threading | Supports double-texting |
|
||||
| Scheduling | None | Cron scheduling |
|
||||
| Monitoring | None | Integrated with LangSmith for observability |
|
||||
| IDE integration | LangGraph Studio for Desktop | LangGraph Studio for Desktop & Cloud |
|
||||
|
||||
## What are my deployment options for LangGraph Platform?
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ The LangGraph Platform comprises several components that work together to suppor
|
||||
- [Web-hooks](./langgraph_server.md#webhooks): Webhooks allow your running LangGraph application to send data to external services on specific events.
|
||||
- [Cron Jobs](./langgraph_server.md#cron-jobs): Cron jobs are a way to schedule tasks to run at specific times in your LangGraph application.
|
||||
- [Double Texting](./double_texting.md): Double texting is a common issue in LLM applications where users may send multiple messages before the graph has finished running. This guide explains how to handle double texting with LangGraph Deploy.
|
||||
- [Authentication & Access Control](./auth.md): Learn about options for authentication and access control when deploying the LangGraph Platform.
|
||||
|
||||
### Deployment Options
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for c
|
||||
| **Deployment Type** | **CPU** | **Memory** | **Scaling** |
|
||||
|---------------------|---------|------------|---------------------|
|
||||
| Development | 1 CPU | 1 GB | Up to 1 container |
|
||||
| Production | 1 CPU | 2 GB | Up to 10 containers |
|
||||
| Production | 2 CPU | 2 GB | Up to 10 containers |
|
||||
|
||||
## Autoscaling
|
||||
`Production` type deployments automatically scale up to 10 containers. Scaling is based on the current request load for a single container. Specifically, the autoscaling implementation scales the deployment so that each container is processing about 10 concurrent requests. For example...
|
||||
|
||||
@@ -18,32 +18,7 @@ The LangGraph Platform offers a few different deployment options described in th
|
||||
|
||||
## Why Use LangGraph Platform?
|
||||
|
||||
LangGraph Platform is designed to make deploying agentic applications seamless and production-ready.
|
||||
|
||||
For simpler applications, deploying a LangGraph agent can be as straightforward as using your own server logic—for example, setting up a FastAPI endpoint and invoking LangGraph directly.
|
||||
|
||||
### Option 1: Deploying with Custom Server Logic
|
||||
|
||||
For basic LangGraph applications, you may choose to handle deployment using your custom server infrastructure. Setting up endpoints with frameworks like [FastAPI](https://fastapi.tiangolo.com/) allows you to quickly deploy and run LangGraph as you would any other Python application:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from your_agent_package import graph
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/foo")
|
||||
async def foo(...):
|
||||
return await graph.ainvoke({...})
|
||||
```
|
||||
|
||||
This approach works well for simple applications with straightforward needs and provides you with full control over the deployment setup. For example, you might use this for a single-assistant application that doesn’t require long-running sessions or persistent memory.
|
||||
|
||||
### Option 2: Leveraging LangGraph Platform for Complex Deployments
|
||||
|
||||
As your applications scale or add complex features, the deployment requirements often evolve. Running an application with more nodes, longer processing times, or a need for persistent memory can introduce challenges that quickly become time-consuming and difficult to manage manually. [LangGraph Platform](./langgraph_platform.md) is built to handle these challenges seamlessly, allowing you to focus on agent logic rather than server infrastructure.
|
||||
|
||||
Here are some common issues that arise in complex deployments, which LangGraph Platform addresses:
|
||||
**LangGraph Platform** handles common issues that arise when deploying LLM applications to production, allowing you to focus on agent logic instead of managing server infrastructure.
|
||||
|
||||
- **[Streaming Support](streaming.md)**: As agents grow more sophisticated, they often benefit from streaming both token outputs and intermediate states back to the user. Without this, users are left waiting for potentially long operations with no feedback. LangGraph Server provides [multiple streaming modes](streaming.md) optimized for various application needs.
|
||||
|
||||
|
||||
@@ -25,25 +25,28 @@ The key features of LangGraph Studio are:
|
||||
|
||||
## Types
|
||||
|
||||
### Desktop app
|
||||
### Development server with web UI
|
||||
|
||||
LangGraph Studio is available as a [desktop app](https://studio.langchain.com/) for MacOS users.
|
||||
You can [run a local in-memory development server](../tutorials/langgraph-platform/local-server.md) that can be used to connect a local LangGraph app with a web version of the studio.
|
||||
For example, if you start the local server with `langgraph dev` (running at `http://127.0.0.1:2024` by default), you can connect to the studio by navigating to:
|
||||
|
||||
While in Beta, LangGraph Studio is available for free to all [LangSmith](https://smith.langchain.com/) users on any plan tier.
|
||||
```
|
||||
https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
```
|
||||
|
||||
See [instructions here](../cloud/reference/cli.md#dev) for more information.
|
||||
|
||||
The web UI version of the studio will connect to your locally running server — your agent is still running locally and never leaves your device.
|
||||
|
||||
### Cloud studio
|
||||
|
||||
If you have deployed your LangGraph application on LangGraph Platform (Cloud), you can access the studio as part of that
|
||||
|
||||
### Development server
|
||||
### Desktop app
|
||||
|
||||
LangGraph CLI also contains a command for running an in-memory development server that can be used to connect a local LangGraph app with the studio.
|
||||
See [instructions here](../cloud/reference/cli.md#dev) for more information.
|
||||
LangGraph Studio is available as a [desktop app](https://studio.langchain.com/) for MacOS users.
|
||||
|
||||
The way this works is that it runs inside your local environment.
|
||||
It will spin up an in-memory, development server to deploy the graph.
|
||||
You can then connect to the studio via the Cloud hosted version of LangGraph Platform.
|
||||
To be clear, the web studio will connect to your locally running server - your agent is still running locally and never leaves your device.
|
||||
While in Beta, LangGraph Studio is available for free to all [LangSmith](https://smith.langchain.com/) users on any plan tier.
|
||||
|
||||
## Studio FAQs
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class State(MessagesState):
|
||||
|
||||
## Nodes
|
||||
|
||||
In LangGraph, nodes are typically python functions (sync or `async`) where the **first** positional argument is the [state](#state), and (optionally), the **second** positional argument is a "config", containing optional [configurable parameters](#configuration) (such as a `thread_id`).
|
||||
In LangGraph, nodes are typically python functions (sync or async) where the **first** positional argument is the [state](#state), and (optionally), the **second** positional argument is a "config", containing optional [configurable parameters](#configuration) (such as a `thread_id`).
|
||||
|
||||
Similar to `NetworkX`, you add these nodes to a graph using the [add_node][langgraph.graph.StateGraph.add_node] method:
|
||||
|
||||
|
||||
@@ -94,13 +94,13 @@ This is a special case of updating the graph state from tools where in addition
|
||||
!!! important
|
||||
|
||||
If you want to use tools that return `Command`, you can either use prebuilt [`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent] / [`ToolNode`][langgraph.prebuilt.tool_node.ToolNode] components, or implement your own tool-executing node that collects `Command` objects returned by the tools and returns a list of them, e.g.:
|
||||
|
||||
```python
|
||||
def call_tools(state):
|
||||
...
|
||||
commands = [tools_by_name[tool_call["name"]].invoke(tool_call) for tool_call in tool_calls]
|
||||
return commands
|
||||
```
|
||||
|
||||
```python
|
||||
def call_tools(state):
|
||||
...
|
||||
commands = [tools_by_name[tool_call["name"]].invoke(tool_call) for tool_call in tool_calls]
|
||||
return commands
|
||||
```
|
||||
|
||||
Let's now take a closer look at the different multi-agent architectures.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ You can create an application from a template using the LangGraph CLI.
|
||||
## Install the LangGraph CLI
|
||||
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]==0.1.58" python-dotenv
|
||||
pip install "langgraph-cli[inmem]" --upgrade
|
||||
```
|
||||
|
||||
## Available Templates
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# How to add custom authentication
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
|
||||
This guide assumes familiarity with the following concepts:
|
||||
|
||||
* [**Authentication & Access Control**](../../concepts/auth.md)
|
||||
* [**LangGraph Platform**](../../concepts/index.md#langgraph-platform)
|
||||
|
||||
For a more guided walkthrough, see [**setting up custom authentication**](../../tutorials/auth/getting_started.md) tutorial.
|
||||
|
||||
???+ note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
|
||||
|
||||
This guide shows how to add custom authentication to your LangGraph Platform application. This guide applies to both LangGraph Cloud, BYOC, and self-hosted deployments. It does not apply to isolated usage of the LangGraph open source library in your own custom server.
|
||||
|
||||
## 1. Implement authentication
|
||||
|
||||
Create `auth.py` file, with a basic JWT authentication handler:
|
||||
|
||||
```python
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
my_auth = Auth()
|
||||
|
||||
@my_auth.authenticate
|
||||
async def authenticate(authorization: str) -> str:
|
||||
token = authorization.split(" ", 1)[-1] # "Bearer <token>"
|
||||
try:
|
||||
# Verify token with your auth provider
|
||||
user_id = await verify_token(token)
|
||||
return user_id
|
||||
except Exception:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Optional: Add authorization rules
|
||||
@my_auth.on
|
||||
async def add_owner(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: dict,
|
||||
):
|
||||
"""Add owner to resource metadata and filter by owner."""
|
||||
filters = {"owner": ctx.user.identity}
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata.update(filters)
|
||||
return filters
|
||||
```
|
||||
|
||||
## 2. Update configuration
|
||||
|
||||
In your `langgraph.json`, add the path to your auth file:
|
||||
|
||||
```json hl_lines="7-9"
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
"path": "./auth.py:my_auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Connect from the client
|
||||
|
||||
Once you've set up authentication in your server, requests must include the the required authorization information based on your chosen scheme.
|
||||
Assuming you are using JWT token authentication, you could access your deployments using any of the following methods:
|
||||
|
||||
=== "Python Client"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
my_token = "your-token" # In practice, you would generate a signed token with your auth provider
|
||||
client = get_client(
|
||||
url="http://localhost:2024",
|
||||
headers={"Authorization": f"Bearer {my_token}"}
|
||||
)
|
||||
threads = await client.threads.search()
|
||||
```
|
||||
|
||||
=== "Python RemoteGraph"
|
||||
|
||||
```python
|
||||
from langgraph.pregel.remote import RemoteGraph
|
||||
|
||||
my_token = "your-token" # In practice, you would generate a signed token with your auth provider
|
||||
remote_graph = RemoteGraph(
|
||||
"agent",
|
||||
url="http://localhost:2024",
|
||||
headers={"Authorization": f"Bearer {my_token}"}
|
||||
)
|
||||
threads = await remote_graph.ainvoke(...)
|
||||
```
|
||||
|
||||
=== "JavaScript Client"
|
||||
|
||||
```javascript
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
const my_token = "your-token"; // In practice, you would generate a signed token with your auth provider
|
||||
const client = new Client({
|
||||
apiUrl: "http://localhost:2024",
|
||||
headers: { Authorization: `Bearer ${my_token}` },
|
||||
});
|
||||
const threads = await client.threads.search();
|
||||
```
|
||||
|
||||
=== "JavaScript RemoteGraph"
|
||||
|
||||
```javascript
|
||||
import { RemoteGraph } from "@langchain/langgraph/remote";
|
||||
|
||||
const my_token = "your-token"; // In practice, you would generate a signed token with your auth provider
|
||||
const remoteGraph = new RemoteGraph({
|
||||
graphId: "agent",
|
||||
url: "http://localhost:2024",
|
||||
headers: { Authorization: `Bearer ${my_token}` },
|
||||
});
|
||||
const threads = await remoteGraph.invoke(...);
|
||||
```
|
||||
|
||||
=== "CURL"
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer ${your-token}" http://localhost:2024/threads
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# How to document API authentication in OpenAPI
|
||||
|
||||
This guide shows how to customize the OpenAPI security schema for your LangGraph Platform API documentation. A well-documented security schema helps API consumers understand how to authenticate with your API and even enables automatic client generation. See the [Authentication & Access Control conceptual guide](../../concepts/auth.md) for more details about LangGraph's authentication system.
|
||||
|
||||
!!! note "Implementation vs Documentation"
|
||||
This guide only covers how to document your security requirements in OpenAPI. To implement the actual authentication logic, see [How to add custom authentication](./custom_auth.md).
|
||||
|
||||
This guide applies to all LangGraph Platform deployments (Cloud, BYOC, and self-hosted). It does not apply to usage of the LangGraph open source library if you are not using LangGraph Platform.
|
||||
|
||||
## Default Schema
|
||||
|
||||
The default security scheme varies by deployment type:
|
||||
|
||||
=== "LangGraph Cloud"
|
||||
|
||||
By default, LangGraph Cloud requires a LangSmith API key in the `x-api-key` header:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: x-api-key
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
```
|
||||
|
||||
When using one of the LangGraph SDK's, this can be inferred from environment variables.
|
||||
|
||||
=== "Self-hosted"
|
||||
|
||||
By default, self-hosted deployments have no security scheme. This means they are to be deployed only on a secured network or with authentication. To add custom authentication, see [How to add custom authentication](./custom_auth.md).
|
||||
|
||||
## Custom Security Schema
|
||||
|
||||
To customize the security schema in your OpenAPI documentation, add an `openapi` field to your `auth` configuration in `langgraph.json`. Remember that this only updates the API documentation - you must also implement the corresponding authentication logic as shown in [How to add custom authentication](./custom_auth.md).
|
||||
|
||||
Note that LangGraph Platform does not provide authentication endpoints - you'll need to handle user authentication in your client application and pass the resulting credentials to the LangGraph API.
|
||||
|
||||
=== "OAuth2 with Bearer Token"
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"path": "./auth.py:my_auth", // Implement auth logic here
|
||||
"openapi": {
|
||||
"securitySchemes": {
|
||||
"OAuth2": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"implicit": {
|
||||
"authorizationUrl": "https://your-auth-server.com/oauth/authorize",
|
||||
"scopes": {
|
||||
"me": "Read information about the current user",
|
||||
"threads": "Access to create and manage threads"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{"OAuth2": ["me", "threads"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "API Key"
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"path": "./auth.py:my_auth", // Implement auth logic here
|
||||
"openapi": {
|
||||
"securitySchemes": {
|
||||
"apiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{"apiKeyAuth": []}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
After updating your configuration:
|
||||
|
||||
1. Deploy your application
|
||||
2. Visit `/docs` to see the updated OpenAPI documentation
|
||||
3. Try out the endpoints using credentials from your authentication server (make sure you've implemented the authentication logic first)
|
||||
@@ -108,6 +108,7 @@ These how-to guides show common patterns for tool calling with LangGraph:
|
||||
|
||||
[Multi-agent systems](../concepts/multi_agent.md) are useful to break down complex LLM applications into multiple agents, each responsible for a different part of the application. These how-to guides show how to implement multi-agent systems in LangGraph:
|
||||
|
||||
- [How to implement handoffs between agents](agent-handoffs.ipynb)
|
||||
- [How to build a multi-agent network](multi-agent-network.ipynb)
|
||||
- [How to add multi-turn conversation in a multi-agent application](multi-agent-multi-turn-convo.ipynb)
|
||||
|
||||
@@ -179,6 +180,11 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r
|
||||
- [How to deploy to a self-hosted environment](./deploy-self-hosted.md)
|
||||
- [How to interact with the deployment using RemoteGraph](./use-remote-graph.md)
|
||||
|
||||
### Authentication & Access Control
|
||||
|
||||
- [How to add custom authentication](./auth/custom_auth.md)
|
||||
- [How to update the security schema of your OpenAPI spec](./auth/openapi_security.md)
|
||||
|
||||
### Assistants
|
||||
|
||||
[Assistants](../concepts/assistants.md) is a configured instance of a template.
|
||||
|
||||
@@ -6,8 +6,8 @@ This guide shows you how to connect your local agent to [LangGraph Studio](../co
|
||||
|
||||
There are two ways to connect your local agent to LangGraph Studio:
|
||||
|
||||
- [Development Server](../concepts/langgraph_studio.md#development-server-with-web-ui): Python package, all platforms, no Docker
|
||||
- [LangGraph Desktop](../concepts/langgraph_studio.md#desktop-app): Application, Mac only, requires Docker
|
||||
- [Development Server](../concepts/langgraph_studio.md#dev-server): Python package, all platforms, no Docker
|
||||
|
||||
In this guide we will cover how to use the development server as that is generally an easier and better experience.
|
||||
|
||||
@@ -22,8 +22,14 @@ See [this guide](../concepts/application_structure.md) for information on how to
|
||||
You will need to install [`langgraph-cli`](../cloud/reference/cli.md#langgraph-cli) (version `0.1.55` or higher).
|
||||
You will need to make sure to install the `inmem` extras.
|
||||
|
||||
???+ note "Minimum version"
|
||||
|
||||
The minimum version to use the `inmem` extra with `langgraph-cli` is `0.1.55`.
|
||||
Python 3.11 or higher is required.
|
||||
|
||||
|
||||
```shell
|
||||
pip install "langgraph-cli[inmem]==0.1.55"
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
## Run the development server
|
||||
|
||||
@@ -166,8 +166,6 @@
|
||||
"\n",
|
||||
"\n",
|
||||
"class State(AgentState):\n",
|
||||
" # user provided\n",
|
||||
" last_name: str\n",
|
||||
" # updated by the tool\n",
|
||||
" user_info: dict[str, Any]\n",
|
||||
"\n",
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Connecting an Authentication Provider (Part 3/3)
|
||||
|
||||
!!! note "This is part 3 of our authentication series:"
|
||||
|
||||
1. [Basic Authentication](getting_started.md) - Control who can access your bot
|
||||
2. [Resource Authorization](resource_auth.md) - Let users have private conversations
|
||||
3. Production Auth (you are here) - Add real user accounts and validate using OAuth2
|
||||
|
||||
In the [Making Conversations Private](resource_auth.md) tutorial, we added [resource authorization](../../concepts/auth.md#resource-authorization) to give users private conversations. However, we were still using hard-coded tokens for authentication, which is not secure. Now we'll replace those tokens with real user accounts using [OAuth2](../../concepts/auth.md#oauth2-authentication).
|
||||
|
||||
We'll keep the same [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object and [resource-level access control](../../concepts/auth.md#resource-level-access-control), but upgrade our authentication to use Supabase as our identity provider. While we use Supabase in this tutorial, the concepts apply to any OAuth2 provider. You'll learn how to:
|
||||
|
||||
1. Replace test tokens with real [JWT tokens](../../concepts/auth.md#jwt-tokens)
|
||||
2. Integrate with OAuth2 providers for secure user authentication
|
||||
3. Handle user sessions and metadata while maintaining our existing authorization logic
|
||||
|
||||
## Requirements
|
||||
|
||||
You will need to set up a Supabase project to use its authentication server for this tutorial. You can do so [here](https://supabase.com/dashboard).
|
||||
|
||||
## Background
|
||||
|
||||
OAuth2 involves three main roles:
|
||||
|
||||
1. **Authorization server**: The identity provider (e.g., Supabase, Auth0, Google) that handles user authentication and issues tokens
|
||||
2. **Application backend**: Your LangGraph application. This validates tokens and serves protected resources (conversation data)
|
||||
3. **Client application**: The web or mobile app where users interact with your service
|
||||
|
||||
A standard OAuth2 flow works something like this:
|
||||
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Client
|
||||
participant AuthServer
|
||||
participant LangGraph Backend
|
||||
|
||||
User->>Client: Initiate login
|
||||
User->>AuthServer: Enter credentials
|
||||
AuthServer->>Client: Send tokens
|
||||
Client->>LangGraph Backend: Request with token
|
||||
LangGraph Backend->>AuthServer: Validate token
|
||||
AuthServer->>LangGraph Backend: Token valid
|
||||
LangGraph Backend->>Client: Serve request (e.g., run agent or graph)
|
||||
```
|
||||
|
||||
In the following example, we'll use Supabase as our auth server. The LangGraph application will provide the backend for your app, and we will write test code for the client app.
|
||||
Let's get started!
|
||||
|
||||
## Setting Up Authentication Provider {#setup-auth-provider}
|
||||
|
||||
First, let's install the required dependencies. Start in your `custom-auth` directory and ensure you have the `langgraph-cli` installed:
|
||||
|
||||
```bash
|
||||
cd custom-auth
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
Next, we'll need to fech the URL of our auth server and the private key for authentication.
|
||||
Since we're using Supabase for this, we can do this in the Supabase dashboard:
|
||||
|
||||
1. In the left sidebar, click on t️⚙ Project Settings" and then click "API"
|
||||
2. Copy your project URL and add it to your `.env` file
|
||||
|
||||
```shell
|
||||
echo "SUPABASE_URL=your-project-url" >> .env
|
||||
```
|
||||
3. Next, copy your service role secret key and add it to your `.env` file
|
||||
```shell
|
||||
echo "SUPABASE_SERVICE_KEY=your-service-role-key" >> .env
|
||||
```
|
||||
4. Finally, copy your "anon public" key and note it down. This will be used later when we set up our client code.
|
||||
|
||||
```bash
|
||||
SUPABASE_URL=your-project-url
|
||||
SUPABASE_SERVICE_KEY=your-service-role-key
|
||||
```
|
||||
|
||||
## Implementing Token Validation
|
||||
|
||||
In the previous tutorials, we used the [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object to:
|
||||
|
||||
1. Validate hard-coded tokens in the [authentication tutorial](getting_started.md)
|
||||
2. Add resource ownership in the [authorization tutorial](resource_auth.md)
|
||||
|
||||
Now we'll upgrade our authentication to validate real JWT tokens from Supabase. The key changes will all be in the [`@auth.authenticate`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) decorated function:
|
||||
|
||||
1. Instead of checking against a hard-coded list of tokens, we'll make an HTTP request to Supabase to validate the token
|
||||
2. We'll extract real user information (ID, email) from the validated token
|
||||
|
||||
And we'll keep our existing resource authorization logic unchanged
|
||||
|
||||
Let's update `src/security/auth.py` to implement this:
|
||||
|
||||
```python hl_lines="8-9 20-30" title="src/security/auth.py"
|
||||
import os
|
||||
import httpx
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
auth = Auth()
|
||||
|
||||
# This is loaded from the `.env` file you created above
|
||||
SUPABASE_URL = os.environ["SUPABASE_URL"]
|
||||
SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
|
||||
|
||||
|
||||
@auth.authenticate
|
||||
async def get_current_user(authorization: str | None):
|
||||
"""Validate JWT tokens and extract user information."""
|
||||
assert authorization
|
||||
scheme, token = authorization.split()
|
||||
assert scheme.lower() == "bearer"
|
||||
|
||||
try:
|
||||
# Verify token with auth provider
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{SUPABASE_URL}/auth/v1/user",
|
||||
headers={
|
||||
"Authorization": authorization,
|
||||
"apiKey": SUPABASE_SERVICE_KEY,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user = response.json()
|
||||
return {
|
||||
"identity": user["id"], # Unique user identifier
|
||||
"email": user["email"],
|
||||
"is_authenticated": True,
|
||||
}
|
||||
except Exception as e:
|
||||
raise Auth.exceptions.HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
# ... the rest is the same as before
|
||||
|
||||
# Keep our resource authorization from the previous tutorial
|
||||
@auth.on
|
||||
async def add_owner(ctx, value):
|
||||
"""Make resources private to their creator using resource metadata."""
|
||||
filters = {"owner": ctx.user.identity}
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata.update(filters)
|
||||
return filters
|
||||
```
|
||||
|
||||
The most important change is that we're now validating tokens with a real authentication server. Our authentication handler has the private key for our Supabase project, which we can use to validate the user's token and extract their information.
|
||||
|
||||
Let's test this with a real user account!
|
||||
|
||||
## Testing Authentication Flow
|
||||
|
||||
Let's test out our new authentication flow. You can run the following code in a file or notebook. You will need to provide:
|
||||
|
||||
- A valid email address
|
||||
- A Supabase project URL (from [above](#setup-auth-provider))
|
||||
- A Supabase anon **public key** (also from [above](#setup-auth-provider))
|
||||
|
||||
```python
|
||||
import os
|
||||
import httpx
|
||||
from getpass import getpass
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
|
||||
# Get email from command line
|
||||
email = getpass("Enter your email: ")
|
||||
base_email = email.split("@")
|
||||
password = "secure-password" # CHANGEME
|
||||
email1 = f"{base_email[0]}+1@{base_email[1]}"
|
||||
email2 = f"{base_email[0]}+2@{base_email[1]}"
|
||||
|
||||
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
||||
if not SUPABASE_URL:
|
||||
SUPABASE_URL = getpass("Enter your Supabase project URL: ")
|
||||
|
||||
# This is your PUBLIC anon key (which is safe to use client-side)
|
||||
# Do NOT mistake this for the secret service role key
|
||||
SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY")
|
||||
if not SUPABASE_ANON_KEY:
|
||||
SUPABASE_ANON_KEY = getpass("Enter your public Supabase anon key: ")
|
||||
|
||||
|
||||
async def sign_up(email: str, password: str):
|
||||
"""Create a new user account."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{SUPABASE_URL}/auth/v1/signup",
|
||||
json={"email": email, "password": password},
|
||||
headers={"apiKey": SUPABASE_ANON_KEY},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
# Create two test users
|
||||
print(f"Creating test users: {email1} and {email2}")
|
||||
await sign_up(email1, password)
|
||||
await sign_up(email2, password)
|
||||
```
|
||||
|
||||
Then run the code.
|
||||
|
||||
!!! tip "About test emails"
|
||||
We'll create two test accounts by adding "+1" and "+2" to your email. For example, if you use "myemail@gmail.com", we'll create "myemail+1@gmail.com" and "myemail+2@gmail.com". All emails will be delivered to your original address.
|
||||
|
||||
⚠️ Before continuing: Check your email and click both confirmation links. Supabase will will reject `/login` requests until after you have confirmed your users' email.
|
||||
|
||||
Now let's test that users can only see their own data. Make sure the server is running (run `langgraph dev`) before proceeding. The following snippet requires the "anon public" key that you copied from the Supabase dashboard while [setting up the auth provider](#setup-auth-provider) previously.
|
||||
|
||||
```python
|
||||
async def login(email: str, password: str):
|
||||
"""Get an access token for an existing user."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{SUPABASE_URL}/auth/v1/token?grant_type=password",
|
||||
json={
|
||||
"email": email,
|
||||
"password": password
|
||||
},
|
||||
headers={
|
||||
"apikey": SUPABASE_ANON_KEY,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
# Log in as user 1
|
||||
user1_token = await login(email1, password)
|
||||
user1_client = get_client(
|
||||
url="http://localhost:2024", headers={"Authorization": f"Bearer {user1_token}"}
|
||||
)
|
||||
|
||||
# Create a thread as user 1
|
||||
thread = await user1_client.threads.create()
|
||||
print(f"✅ User 1 created thread: {thread['thread_id']}")
|
||||
|
||||
# Try to access without a token
|
||||
unauthenticated_client = get_client(url="http://localhost:2024")
|
||||
try:
|
||||
await unauthenticated_client.threads.create()
|
||||
print("❌ Unauthenticated access should fail!")
|
||||
except Exception as e:
|
||||
print("✅ Unauthenticated access blocked:", e)
|
||||
|
||||
# Try to access user 1's thread as user 2
|
||||
user2_token = await login(email2, password)
|
||||
user2_client = get_client(
|
||||
url="http://localhost:2024", headers={"Authorization": f"Bearer {user2_token}"}
|
||||
)
|
||||
|
||||
try:
|
||||
await user2_client.threads.get(thread["thread_id"])
|
||||
print("❌ User 2 shouldn't see User 1's thread!")
|
||||
except Exception as e:
|
||||
print("✅ User 2 blocked from User 1's thread:", e)
|
||||
```
|
||||
The output should look like this:
|
||||
|
||||
```shell
|
||||
✅ User 1 created thread: d6af3754-95df-4176-aa10-dbd8dca40f1a
|
||||
✅ Unauthenticated access blocked: Client error '403 Forbidden' for url 'http://localhost:2024/threads'
|
||||
✅ User 2 blocked from User 1's thread: Client error '404 Not Found' for url 'http://localhost:2024/threads/d6af3754-95df-4176-aa10-dbd8dca40f1a'
|
||||
```
|
||||
|
||||
Perfect! Our authentication and authorization are working together:
|
||||
1. Users must log in to access the bot
|
||||
2. Each user can only see their own threads
|
||||
|
||||
All our users are managed by the Supabase auth provider, so we don't need to implement any additional user management logic.
|
||||
|
||||
## Congratulations! 🎉
|
||||
|
||||
You've successfully built a production-ready authentication system for your LangGraph application! Let's review what you've accomplished:
|
||||
|
||||
1. Set up an authentication provider (Supabase in this case)
|
||||
2. Added real user accounts with email/password authentication
|
||||
3. Integrated JWT token validation into your LangGraph server
|
||||
4. Implemented proper authorization to ensure users can only access their own data
|
||||
5. Created a foundation that's ready to handle your next authentication challenge 🚀
|
||||
|
||||
This completes our authentication tutorial series. You now have the building blocks for a secure, production-ready LangGraph application.
|
||||
|
||||
## What's Next?
|
||||
|
||||
Now that you have production authentication, consider:
|
||||
|
||||
1. Building a web UI with your preferred framework (see the [Custom Auth](https://github.com/langchain-ai/custom-auth) template for an example)
|
||||
2. Learn more about the other aspects of authentication and authorization in the [conceptual guide on authentication](../../concepts/auth.md).
|
||||
3. Customize your handlers and setup further after reading the [reference docs](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth).
|
||||
@@ -0,0 +1,185 @@
|
||||
# Setting up Custom Authentication (Part 1/3)
|
||||
|
||||
!!! note "This is part 1 of our authentication series:"
|
||||
|
||||
1. Basic Authentication (you are here) - Control who can access your bot
|
||||
2. [Resource Authorization](resource_auth.md) - Let users have private conversations
|
||||
3. [Production Auth](add_auth_server.md) - Add real user accounts and validate using OAuth2
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
|
||||
This guide assumes basic familiarity with the following concepts:
|
||||
|
||||
* [**Authentication & Access Control**](../../concepts/auth.md)
|
||||
* [**LangGraph Platform**](../../concepts/index.md#langgraph-platform)
|
||||
|
||||
!!! note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
|
||||
|
||||
In this tutorial, we will build a chatbot that only lets specific users access it. We'll start with the LangGraph template and add token-based security step by step. By the end, you'll have a working chatbot that checks for valid tokens before allowing access.
|
||||
|
||||
## Setting up our project
|
||||
|
||||
First, let's create a new chatbot using the LangGraph starter template:
|
||||
|
||||
```bash
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
langgraph new --template=new-langgraph-project-python custom-auth
|
||||
cd custom-auth
|
||||
```
|
||||
|
||||
The template gives us a placeholder LangGraph app. Let's try it out by installing the local dependencies and running the development server.
|
||||
```shell
|
||||
pip install -e .
|
||||
langgraph dev
|
||||
```
|
||||
If everything works, the server should start and open the studio in your browser.
|
||||
|
||||
> - 🚀 API: http://127.0.0.1:2024
|
||||
> - 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
> - 📚 API Docs: http://127.0.0.1:2024/docs
|
||||
>
|
||||
> This in-memory server is designed for development and testing.
|
||||
> For production use, please use LangGraph Cloud.
|
||||
|
||||
The graph should run, and if you were to self-host this on the public internet, anyone could access it!
|
||||
|
||||

|
||||
|
||||
Now that we've seen the base LangGraph app, let's add authentication to it!
|
||||
|
||||
???+ tip "Placeholder token"
|
||||
|
||||
In part 1, we will start with a hard-coded token for illustration purposes.
|
||||
We will get to a "production-ready" authentication scheme in part 3, after mastering the basics.
|
||||
|
||||
|
||||
## Adding Authentication
|
||||
|
||||
The [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object lets you register an authentication function that the LangGraph platform will run on every request. This function receives each request and decides whether to accept or reject.
|
||||
|
||||
Create a new file `src/security/auth.py`. This is where our code will live to check if users are allowed to access our bot:
|
||||
|
||||
```python hl_lines="10 15-16" title="src/security/auth.py"
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
# This is our toy user database. Do not do this in production
|
||||
VALID_TOKENS = {
|
||||
"user1-token": {"id": "user1", "name": "Alice"},
|
||||
"user2-token": {"id": "user2", "name": "Bob"},
|
||||
}
|
||||
|
||||
# The "Auth" object is a container that LangGraph will use to mark our authentication function
|
||||
auth = Auth()
|
||||
|
||||
|
||||
# The `authenticate` decorator tells LangGraph to call this function as middleware
|
||||
# for every request. This will determine whether the request is allowed or not
|
||||
@auth.authenticate
|
||||
async def get_current_user(authorization: str | None) -> Auth.types.MinimalUserDict:
|
||||
"""Check if the user's token is valid."""
|
||||
assert authorization
|
||||
scheme, token = authorization.split()
|
||||
assert scheme.lower() == "bearer"
|
||||
# Check if token is valid
|
||||
if token not in VALID_TOKENS:
|
||||
raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
# Return user info if valid
|
||||
user_data = VALID_TOKENS[token]
|
||||
return {
|
||||
"identity": user_data["id"],
|
||||
}
|
||||
```
|
||||
|
||||
Notice that our [authentication](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler does two important things:
|
||||
|
||||
1. Checks if a valid token is provided in the request's [Authorization header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)
|
||||
2. Returns the user's [identity](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.MinimalUserDict)
|
||||
|
||||
Now tell LangGraph to use our authentication by adding the following to the [`langgraph.json`](../../cloud/reference/cli.md#configuration-file) configuration:
|
||||
|
||||
```json hl_lines="7-9" title="langgraph.json"
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
"path": "src/security/auth.py:auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Our "Secure" Bot
|
||||
|
||||
Let's start the server again to test everything out!
|
||||
|
||||
```bash
|
||||
langgraph dev --no-browser
|
||||
```
|
||||
|
||||
??? note "Custom auth in the studio"
|
||||
|
||||
If you didn't add the `--no-browser`, the studio UI will open in the browser. You may wonder, how is the studio able to still connect to our server? By default, we also permit access from the LangGraph studio, even when using custom auth. This makes it easier to develop and test your bot in the studio. You can remove this alternative authentication option by
|
||||
setting `disable_studio_auth: "true"` in your auth configuration:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"path": "src/security/auth.py:auth",
|
||||
"disable_studio_auth": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now let's try to chat with our bot. If we've implemented authentication correctly, we should only be able to access the bot if we provide a valid token in the request header. Users will still, however, be able to access each other's resources until we add [resource authorization handlers](../../concepts/auth.md#resource-authorization) in the next section of our tutorial.
|
||||
|
||||

|
||||
|
||||
Run the following code in a file or notebook:
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# Try without a token (should fail)
|
||||
client = get_client(url="http://localhost:2024")
|
||||
try:
|
||||
thread = await client.threads.create()
|
||||
print("❌ Should have failed without token!")
|
||||
except Exception as e:
|
||||
print("✅ Correctly blocked access:", e)
|
||||
|
||||
# Try with a valid token
|
||||
client = get_client(
|
||||
url="http://localhost:2024", headers={"Authorization": "Bearer user1-token"}
|
||||
)
|
||||
|
||||
# Create a thread and chat
|
||||
thread = await client.threads.create()
|
||||
print(f"✅ Created thread as Alice: {thread['thread_id']}")
|
||||
|
||||
response = await client.runs.create(
|
||||
thread_id=thread["thread_id"],
|
||||
assistant_id="agent",
|
||||
input={"messages": [{"role": "user", "content": "Hello!"}]},
|
||||
)
|
||||
print("✅ Bot responded:")
|
||||
print(response)
|
||||
```
|
||||
|
||||
You should see that:
|
||||
|
||||
1. Without a valid token, we can't access the bot
|
||||
2. With a valid token, we can create threads and chat
|
||||
|
||||
Congratulations! You've built a chatbot that only lets "authenticated" users access it. While this system doesn't (yet) implement a production-ready security scheme, we've learned the basic mechanics of how to control access to our bot. In the next tutorial, we'll learn how to give each user their own private conversations.
|
||||
|
||||
## What's Next?
|
||||
|
||||
Now that you can control who accesses your bot, you might want to:
|
||||
|
||||
1. Continue the tutorial by going to [Making Conversations Private (Part 2/3)](resource_auth.md) to learn about resource authorization.
|
||||
2. Read more about [authentication concepts](../../concepts/auth.md).
|
||||
3. Check out the [API reference](../../cloud/reference/sdk/python_sdk_ref.md) for more authentication details.
|
||||
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 293 KiB |
@@ -0,0 +1,333 @@
|
||||
# Making Conversations Private (Part 2/3)
|
||||
|
||||
!!! note "This is part 2 of our authentication series:"
|
||||
|
||||
1. [Basic Authentication](getting_started.md) - Control who can access your bot
|
||||
2. Resource Authorization (you are here) - Let users have private conversations
|
||||
3. [Production Auth](add_auth_server.md) - Add real user accounts and validate using OAuth2
|
||||
|
||||
In this tutorial, we will extend our chatbot to give each user their own private conversations. We'll add [resource-level access control](../../concepts/auth.md#resource-level-access-control) so users can only see their own threads.
|
||||
|
||||

|
||||
|
||||
???+ tip "Placeholder token"
|
||||
|
||||
As we did in [part 1](getting_started.md), for this section, we will use a hard-coded token for illustration purposes.
|
||||
We will get to a "production-ready" authentication scheme in part 3, after mastering the basics.
|
||||
|
||||
## Understanding Resource Authorization
|
||||
|
||||
In the last tutorial, we controlled who could access our bot. But right now, any authenticated user can see everyone else's conversations! Let's fix that by adding [resource authorization](../../concepts/auth.md#resource-authorization).
|
||||
|
||||
First, make sure you have completed the [Basic Authentication](getting_started.md) tutorial and that your secure bot can be run without errors:
|
||||
|
||||
```bash
|
||||
cd custom-auth
|
||||
pip install -e .
|
||||
langgraph dev --no-browser
|
||||
```
|
||||
|
||||
> - 🚀 API: http://127.0.0.1:2024
|
||||
> - 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
> - 📚 API Docs: http://127.0.0.1:2024/docs
|
||||
|
||||
## Adding Resource Authorization
|
||||
|
||||
Recall that in the last tutorial, the [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object let us register an [authentication function](../../concepts/auth.md#authentication), which the LangGraph platform uses to validate the bearer tokens in incoming requests. Now we'll use it to register an **authorization** handler.
|
||||
|
||||
Authorization handlers are functions that run **after** authentication succeeds. These handlers can add [metadata](../../concepts/auth.md#resource-metadata) to resources (like who owns them) and filter what each user can see.
|
||||
|
||||
Let's update our `src/security/auth.py` and add one authorization handler that is run on every request:
|
||||
|
||||
```python hl_lines="29-39" title="src/security/auth.py"
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
# Keep our test users from the previous tutorial
|
||||
VALID_TOKENS = {
|
||||
"user1-token": {"id": "user1", "name": "Alice"},
|
||||
"user2-token": {"id": "user2", "name": "Bob"},
|
||||
}
|
||||
|
||||
auth = Auth()
|
||||
|
||||
|
||||
@auth.authenticate
|
||||
async def get_current_user(authorization: str | None) -> Auth.types.MinimalUserDict:
|
||||
"""Our authentication handler from the previous tutorial."""
|
||||
assert authorization
|
||||
scheme, token = authorization.split()
|
||||
assert scheme.lower() == "bearer"
|
||||
|
||||
if token not in VALID_TOKENS:
|
||||
raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
user_data = VALID_TOKENS[token]
|
||||
return {
|
||||
"identity": user_data["id"],
|
||||
}
|
||||
|
||||
|
||||
@auth.on
|
||||
async def add_owner(
|
||||
ctx: Auth.types.AuthContext, # Contains info about the current user
|
||||
value: dict, # The resource being created/accessed
|
||||
):
|
||||
"""Make resources private to their creator."""
|
||||
# Examples:
|
||||
# ctx: AuthContext(
|
||||
# permissions=[],
|
||||
# user=ProxyUser(
|
||||
# identity='user1',
|
||||
# is_authenticated=True,
|
||||
# display_name='user1'
|
||||
# ),
|
||||
# resource='threads',
|
||||
# action='create_run'
|
||||
# )
|
||||
# value:
|
||||
# {
|
||||
# 'thread_id': UUID('1e1b2733-303f-4dcd-9620-02d370287d72'),
|
||||
# 'assistant_id': UUID('fe096781-5601-53d2-b2f6-0d3403f7e9ca'),
|
||||
# 'run_id': UUID('1efbe268-1627-66d4-aa8d-b956b0f02a41'),
|
||||
# 'status': 'pending',
|
||||
# 'metadata': {},
|
||||
# 'prevent_insert_if_inflight': True,
|
||||
# 'multitask_strategy': 'reject',
|
||||
# 'if_not_exists': 'reject',
|
||||
# 'after_seconds': 0,
|
||||
# 'kwargs': {
|
||||
# 'input': {'messages': [{'role': 'user', 'content': 'Hello!'}]},
|
||||
# 'command': None,
|
||||
# 'config': {
|
||||
# 'configurable': {
|
||||
# 'langgraph_auth_user': ... Your user object...
|
||||
# 'langgraph_auth_user_id': 'user1'
|
||||
# }
|
||||
# },
|
||||
# 'stream_mode': ['values'],
|
||||
# 'interrupt_before': None,
|
||||
# 'interrupt_after': None,
|
||||
# 'webhook': None,
|
||||
# 'feedback_keys': None,
|
||||
# 'temporary': False,
|
||||
# 'subgraphs': False
|
||||
# }
|
||||
# }
|
||||
|
||||
# Do 2 things:
|
||||
# 1. Add the user's ID to the resource's metadata. Each LangGraph resource has a `metadata` dict that persists with the resource.
|
||||
# this metadata is useful for filtering in read and update operations
|
||||
# 2. Return a filter that lets users only see their own resources
|
||||
filters = {"owner": ctx.user.identity}
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata.update(filters)
|
||||
|
||||
# Only let users see their own resources
|
||||
return filters
|
||||
```
|
||||
|
||||
The handler receives two parameters:
|
||||
|
||||
1. `ctx` ([AuthContext](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AuthContext)): contains info about the current `user`, the user's `permissions`, the `resource` ("threads", "crons", "assistants"), and the `action` being taken ("create", "read", "update", "delete", "search", "create_run")
|
||||
2. `value` (`dict`): data that is being created or accessed. The contents of this dict depend on the resource and action being accessed. See [adding scoped authorization handlers](#scoped-authorization) below for information on how to get more tightly scoped access control.
|
||||
|
||||
Notice that our simple handler does two things:
|
||||
|
||||
1. Adds the user's ID to the resource's metadata.
|
||||
2. Returns a metadata filter so users only see resources they own.
|
||||
|
||||
## Testing Private Conversations
|
||||
|
||||
Let's test our authorization. If we have set things up correctly, we should expect to see all ✅ messages. Be sure to have your development server running (run `langgraph dev`):
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# Create clients for both users
|
||||
alice = get_client(
|
||||
url="http://localhost:2024",
|
||||
headers={"Authorization": "Bearer user1-token"}
|
||||
)
|
||||
|
||||
bob = get_client(
|
||||
url="http://localhost:2024",
|
||||
headers={"Authorization": "Bearer user2-token"}
|
||||
)
|
||||
|
||||
# Alice creates an assistant
|
||||
alice_assistant = await alice.assistants.create()
|
||||
print(f"✅ Alice created assistant: {alice_assistant['assistant_id']}")
|
||||
|
||||
# Alice creates a thread and chats
|
||||
alice_thread = await alice.threads.create()
|
||||
print(f"✅ Alice created thread: {alice_thread['thread_id']}")
|
||||
|
||||
await alice.runs.create(
|
||||
thread_id=alice_thread["thread_id"],
|
||||
assistant_id="agent",
|
||||
input={"messages": [{"role": "user", "content": "Hi, this is Alice's private chat"}]}
|
||||
)
|
||||
|
||||
# Bob tries to access Alice's thread
|
||||
try:
|
||||
await bob.threads.get(alice_thread["thread_id"])
|
||||
print("❌ Bob shouldn't see Alice's thread!")
|
||||
except Exception as e:
|
||||
print("✅ Bob correctly denied access:", e)
|
||||
|
||||
# Bob creates his own thread
|
||||
bob_thread = await bob.threads.create()
|
||||
await bob.runs.create(
|
||||
thread_id=bob_thread["thread_id"],
|
||||
assistant_id="agent",
|
||||
input={"messages": [{"role": "user", "content": "Hi, this is Bob's private chat"}]}
|
||||
)
|
||||
print(f"✅ Bob created his own thread: {bob_thread['thread_id']}")
|
||||
|
||||
# List threads - each user only sees their own
|
||||
alice_threads = await alice.threads.search()
|
||||
bob_threads = await bob.threads.search()
|
||||
print(f"✅ Alice sees {len(alice_threads)} thread")
|
||||
print(f"✅ Bob sees {len(bob_threads)} thread")
|
||||
```
|
||||
|
||||
Run the test code and you should see output like this:
|
||||
|
||||
```bash
|
||||
✅ Alice created assistant: fc50fb08-78da-45a9-93cc-1d3928a3fc37
|
||||
✅ Alice created thread: 533179b7-05bc-4d48-b47a-a83cbdb5781d
|
||||
✅ Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/533179b7-05bc-4d48-b47a-a83cbdb5781d'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
|
||||
✅ Bob created his own thread: 437c36ed-dd45-4a1e-b484-28ba6eca8819
|
||||
✅ Alice sees 1 thread
|
||||
✅ Bob sees 1 thread
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
1. Each user can create and chat in their own threads
|
||||
2. Users can't see each other's threads
|
||||
3. Listing threads only shows your own
|
||||
|
||||
## Adding scoped authorization handlers {#scoped-authorization}
|
||||
|
||||
The broad `@auth.on` handler matches on all [authorization events](../../concepts/auth.md#authorization-events). This is concise, but it means the contents of the `value` dict are not well-scoped, and we apply the same user-level access control to every resource. If we want to be more fine-grained, we can also control specific actions on resources.
|
||||
|
||||
Update `src/security/auth.py` to add handlers for specific resource types:
|
||||
|
||||
```python
|
||||
# Keep our previous handlers...
|
||||
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
@auth.on.threads.create
|
||||
async def on_thread_create(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.on.threads.create.value,
|
||||
):
|
||||
"""Add owner when creating threads.
|
||||
|
||||
This handler runs when creating new threads and does two things:
|
||||
1. Sets metadata on the thread being created to track ownership
|
||||
2. Returns a filter that ensures only the creator can access it
|
||||
"""
|
||||
# Example value:
|
||||
# {'thread_id': UUID('99b045bc-b90b-41a8-b882-dabc541cf740'), 'metadata': {}, 'if_exists': 'raise'}
|
||||
|
||||
# Add owner metadata to the thread being created
|
||||
# This metadata is stored with the thread and persists
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["owner"] = ctx.user.identity
|
||||
|
||||
|
||||
# Return filter to restrict access to just the creator
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
@auth.on.threads.read
|
||||
async def on_thread_read(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.on.threads.read.value,
|
||||
):
|
||||
"""Only let users read their own threads.
|
||||
|
||||
This handler runs on read operations. We don't need to set
|
||||
metadata since the thread already exists - we just need to
|
||||
return a filter to ensure users can only see their own threads.
|
||||
"""
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
@auth.on.assistants
|
||||
async def on_assistants(
|
||||
ctx: Auth.types.AuthContext,
|
||||
value: Auth.types.on.assistants.value,
|
||||
):
|
||||
# For illustration purposes, we will deny all requests
|
||||
# that touch the assistants resource
|
||||
# Example value:
|
||||
# {
|
||||
# 'assistant_id': UUID('63ba56c3-b074-4212-96e2-cc333bbc4eb4'),
|
||||
# 'graph_id': 'agent',
|
||||
# 'config': {},
|
||||
# 'metadata': {},
|
||||
# 'name': 'Untitled'
|
||||
# }
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="User lacks the required permissions.",
|
||||
)
|
||||
```
|
||||
|
||||
Notice that instead of one global handler, we now have specific handlers for:
|
||||
|
||||
1. Creating threads
|
||||
2. Reading threads
|
||||
3. Accessing assistants
|
||||
|
||||
The first three of these match specific **actions** on each resource (see [resource actions](../../concepts/auth.md#resource-actions)), while the last one (`@auth.on.assistants`) matches _any_ action on the `assistants` resource. For each request, LangGraph will run the most specific handler that matches the resource and action being accessed. This means that the four handlers above will run rather than the broadly scoped "`@auth.on`" handler.
|
||||
|
||||
Try adding the following test code to your test file:
|
||||
|
||||
```python
|
||||
# ... Same as before
|
||||
# Try creating an assistant. This should fail
|
||||
try:
|
||||
await alice.assistants.create("agent")
|
||||
print("❌ Alice shouldn't be able to create assistants!")
|
||||
except Exception as e:
|
||||
print("✅ Alice correctly denied access:", e)
|
||||
|
||||
# Try searching for assistants. This also should fail
|
||||
try:
|
||||
await alice.assistants.search()
|
||||
print("❌ Alice shouldn't be able to search assistants!")
|
||||
except Exception as e:
|
||||
print("✅ Alice correctly denied access to searching assistants:", e)
|
||||
|
||||
# Alice can still create threads
|
||||
alice_thread = await alice.threads.create()
|
||||
print(f"✅ Alice created thread: {alice_thread['thread_id']}")
|
||||
```
|
||||
|
||||
And then run the test code again:
|
||||
|
||||
```bash
|
||||
✅ Alice created thread: dcea5cd8-eb70-4a01-a4b6-643b14e8f754
|
||||
✅ Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/dcea5cd8-eb70-4a01-a4b6-643b14e8f754'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
|
||||
✅ Bob created his own thread: 400f8d41-e946-429f-8f93-4fe395bc3eed
|
||||
✅ Alice sees 1 thread
|
||||
✅ Bob sees 1 thread
|
||||
✅ Alice correctly denied access:
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500
|
||||
✅ Alice correctly denied access to searching assistants:
|
||||
```
|
||||
|
||||
Congratulations! You've built a chatbot where each user has their own private conversations. While this system uses simple token-based authentication, the authorization patterns we've learned will work with implementing any real authentication system. In the next tutorial, we'll replace our test users with real user accounts using OAuth2.
|
||||
|
||||
## What's Next?
|
||||
|
||||
Now that you can control access to resources, you might want to:
|
||||
|
||||
1. Move on to [Production Auth](add_auth_server.md) to add real user accounts
|
||||
2. Read more about [authorization patterns](../../concepts/auth.md#authorization)
|
||||
3. Check out the [API reference](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) for details about the interfaces and methods used in this tutorial
|
||||
@@ -11,11 +11,12 @@ New to LangGraph or LLM app development? Read this material to get up and runnin
|
||||
## Get Started 🚀 {#quick-start}
|
||||
|
||||
- [LangGraph Quickstart](introduction.ipynb): Build a chatbot that can use tools and keep track of conversation history. Add human-in-the-loop capabilities and explore how time-travel works.
|
||||
- [LangGraph Server Quickstart](langgraph-platform/local-server.md): Launch a LangGraph server locally and interact with it using the REST API and LangGraph Studio Web UI.
|
||||
- [LangGraph Cloud QuickStart](../cloud/quick_start.md): Deploy a LangGraph app using LangGraph Cloud.
|
||||
- [LangGraph Template Quickstart](../concepts/template_applications.md): Quickly start building with LangGraph Platform using a template application.
|
||||
- [LangGraph Server Quickstart](langgraph-platform/local-server.md): Launch a LangGraph server locally and interact with it using REST API and LangGraph Studio Web UI.
|
||||
- [LangGraph Template Quickstart](../concepts/template_applications.md): Start building with LangGraph Platform using a template application.
|
||||
- [Deploy with LangGraph Cloud Quickstart](../cloud/quick_start.md): Deploy a LangGraph app using LangGraph Cloud.
|
||||
|
||||
## Use cases 🛠️ {#use-cases}
|
||||
|
||||
## Use cases 🛠️
|
||||
|
||||
Explore practical implementations tailored for specific scenarios:
|
||||
|
||||
@@ -71,3 +72,13 @@ Explore practical implementations tailored for specific scenarios:
|
||||
- [Web Navigation](web-navigation/web_voyager.ipynb): Build an agent that can navigate and interact with websites
|
||||
- [Competitive Programming](usaco/usaco.ipynb): Build an agent with few-shot "episodic memory" and human-in-the-loop collaboration to solve problems from the USA Computing Olympiad; adapted from the ["Can Language Models Solve Olympiad Programming?"](https://arxiv.org/abs/2404.10952v1) paper by Shi, Tang, Narasimhan, and Yao.
|
||||
- [Complex data extraction](extraction/retries.ipynb): Build an agent that can use function calling to do complex extraction tasks
|
||||
|
||||
## LangGraph Platform 🧱 {#platform}
|
||||
|
||||
### Authentication & Access Control
|
||||
|
||||
Add custom authentication and authorization to an existing LangGraph Platform deployment in the following three-part guide:
|
||||
|
||||
1. [Setting Up Custom Authentication](auth/getting_started.md): Implement OAuth2 authentication to authorize users on your deployment
|
||||
2. [Resource Authorization](auth/resource_auth.md): Let users have private conversations
|
||||
3. [Connecting an Authentication Provider](auth/add_auth_server.md): Add real user accounts and validate using OAuth2
|
||||
@@ -553,9 +553,6 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import Literal\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def route_tools(\n",
|
||||
" state: State,\n",
|
||||
"):\n",
|
||||
@@ -2653,7 +2650,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from typing import Annotated, Literal\n",
|
||||
"from typing import Annotated\n",
|
||||
"\n",
|
||||
"from langchain_anthropic import ChatAnthropic\n",
|
||||
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Quick Start: Launch Local LangGraph Server
|
||||
# QuickStart: Launch Local LangGraph Server
|
||||
|
||||
This is a quick start guide to help you get a LangGraph app up and running locally.
|
||||
|
||||
@@ -10,7 +10,7 @@ This is a quick start guide to help you get a LangGraph app up and running local
|
||||
## Install the LangGraph CLI
|
||||
|
||||
```bash
|
||||
pip install -U "langgraph-cli[inmem]" python-dotenv
|
||||
pip install --upgrade "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
## 🌱 Create a LangGraph App
|
||||
@@ -53,21 +53,12 @@ ANTHROPIC_API_KEY=sk-
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
<details><summary>Get API Keys</summary>
|
||||
<ul>
|
||||
<li> <b>LANGSMITH_API_KEY</b>: Go to the <a href="https://smith.langchain.com/settings">LangSmith Settings page</a>. Then clck <b>Create API Key</b>.
|
||||
</li>
|
||||
<li>
|
||||
<b>ANTHROPIC_API_KEY</b>: Get an API key from <a href="https://console.anthropic.com/">Anthropic</a>.
|
||||
</li>
|
||||
<li>
|
||||
<b>OPENAI_API_KEY</b>: Get an API key from <a href="https://openai.com/">OpenAI</a>.
|
||||
</li>
|
||||
<li>
|
||||
<b>TAVILY_API_KEY</b>: Get an API key on the <a href="https://app.tavily.com/">Tavily website</a>.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
??? note "Get API Keys"
|
||||
|
||||
- **LANGSMITH_API_KEY**: Go to the [LangSmith Settings page](https://smith.langchain.com/settings). Then clck **Create API Key**.
|
||||
- **ANTHROPIC_API_KEY**: Get an API key from [Anthropic](https://console.anthropic.com/).
|
||||
- **OPENAI_API_KEY**: Get an API key from [OpenAI](https://openai.com/).
|
||||
- **TAVILY_API_KEY**: Get an API key on the [Tavily website](https://app.tavily.com/).
|
||||
|
||||
## 🚀 Launch LangGraph Server
|
||||
|
||||
@@ -79,11 +70,11 @@ This will start up the LangGraph API server locally. If this runs successfully,
|
||||
|
||||
> Ready!
|
||||
>
|
||||
> - API: [http://localhost:8123](http://localhost:8123/)
|
||||
> - API: [http://localhost:2024](http://localhost:2024/)
|
||||
>
|
||||
> - Docs: http://localhost:8123/docs
|
||||
> - Docs: http://localhost:2024/docs
|
||||
>
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
|
||||
|
||||
!!! note "In-Memory Mode"
|
||||
@@ -95,9 +86,18 @@ This will start up the LangGraph API server locally. If this runs successfully,
|
||||
|
||||
## LangGraph Studio Web UI
|
||||
|
||||
Test your graph in the LangGraph Studio Web UI by visiting the URL provided in the output of the `langgraph up` command.
|
||||
LangGraph Studio Web is a specialized UI that you can connect to LangGraph API server to enable visualization, interaction, and debugging of your application locally. Test your graph in the LangGraph Studio Web UI by visiting the URL provided in the output of the `langgraph dev` command.
|
||||
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
|
||||
!!! info "Connecting to a server with a custom host/port"
|
||||
|
||||
If you are running the LangGraph API server with a custom host / port, you can point the Studio Web UI at it by changing the `baseUrl` URL param. For example, if you are running your server on port 8000, you can change the above URL to the following:
|
||||
|
||||
```
|
||||
https://smith.langchain.com/studio/baseUrl=http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
|
||||
|
||||
!!! warning "Safari Compatibility"
|
||||
|
||||
@@ -118,7 +118,7 @@ Test your graph in the LangGraph Studio Web UI by visiting the URL provided in t
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url="http://localhost:8123")
|
||||
client = get_client(url="http://localhost:2024")
|
||||
|
||||
async for chunk in client.runs.stream(
|
||||
None, # Threadless run
|
||||
@@ -149,7 +149,7 @@ Test your graph in the LangGraph Studio Web UI by visiting the URL provided in t
|
||||
```python
|
||||
from langgraph_sdk import get_sync_client
|
||||
|
||||
client = get_sync_client(url="http://localhost:8123")
|
||||
client = get_sync_client(url="http://localhost:2024")
|
||||
|
||||
for chunk in client.runs.stream(
|
||||
None, # Threadless run
|
||||
@@ -180,8 +180,8 @@ Test your graph in the LangGraph Studio Web UI by visiting the URL provided in t
|
||||
```js
|
||||
const { Client } = await import("@langchain/langgraph-sdk");
|
||||
|
||||
// only set the apiUrl if you changed the default port when calling langgraph up
|
||||
const client = new Client({ apiUrl: "http://localhost:8123"});
|
||||
// only set the apiUrl if you changed the default port when calling langgraph dev
|
||||
const client = new Client({ apiUrl: "http://localhost:2024"});
|
||||
|
||||
const streamResponse = client.runs.stream(
|
||||
null, // Threadless run
|
||||
@@ -207,7 +207,7 @@ Test your graph in the LangGraph Studio Web UI by visiting the URL provided in t
|
||||
|
||||
```bash
|
||||
curl -s --request POST \
|
||||
--url "http://localhost:8123/runs/stream" \
|
||||
--url "http://localhost:2024/runs/stream" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{
|
||||
\"assistant_id\": \"agent\",
|
||||
@@ -235,7 +235,7 @@ Now that you have a LangGraph app running locally, take your journey further by
|
||||
|
||||
### 🌐 Deploy to LangGraph Cloud
|
||||
|
||||
- **[LangGraph Cloud QuickStart](../../cloud/quick_start.md)**: Deploy your LangGraph app using LangGraph Cloud.
|
||||
- **[LangGraph Cloud Quickstart](../../cloud/quick_start.md)**: Deploy your LangGraph app using LangGraph Cloud.
|
||||
|
||||
### 📚 Learn More about LangGraph Platform
|
||||
|
||||
|
||||
@@ -870,7 +870,7 @@
|
||||
" # No deps or all deps satisfied\n",
|
||||
" # can schedule now\n",
|
||||
" schedule_task.invoke(dict(task=task, observations=observations))\n",
|
||||
" # futures.append(executor.submit(schedule_task.invoke dict(task=task, observations=observations)))\n",
|
||||
" # futures.append(executor.submit(schedule_task.invoke, dict(task=task, observations=observations)))\n",
|
||||
"\n",
|
||||
" # All tasks have been submitted or enqueued\n",
|
||||
" # Wait for them to complete\n",
|
||||
|
||||
@@ -73,7 +73,7 @@ plugins:
|
||||
allow_inspection: true
|
||||
heading_level: 2
|
||||
show_bases: true
|
||||
show_source: true
|
||||
show_source: false
|
||||
summary: true
|
||||
inherited_members: true
|
||||
selection:
|
||||
@@ -141,6 +141,11 @@ nav:
|
||||
- tutorials/web-navigation/web_voyager.ipynb
|
||||
- tutorials/usaco/usaco.ipynb
|
||||
- tutorials/extraction/retries.ipynb
|
||||
- LangGraph Platform:
|
||||
- LangGraph Platform: concepts#langgraph-platform
|
||||
- tutorials/auth/getting_started.md
|
||||
- tutorials/auth/resource_auth.md
|
||||
- tutorials/auth/add_auth_server.md
|
||||
|
||||
- How-to Guides:
|
||||
- how-tos/index.md
|
||||
@@ -201,7 +206,10 @@ nav:
|
||||
- how-tos/subgraphs-manage-state.ipynb
|
||||
- how-tos/subgraph-transform-state.ipynb
|
||||
- Multi-agent:
|
||||
- Multi-agent: how-tos#multi-agent
|
||||
- how-tos/agent-handoffs.ipynb
|
||||
- how-tos/multi-agent-network.ipynb
|
||||
- how-tos/multi-agent-multi-turn-convo.ipynb
|
||||
- State Management:
|
||||
- State Management: how-tos#state-management
|
||||
- how-tos/state-model.ipynb
|
||||
@@ -239,6 +247,10 @@ nav:
|
||||
- cloud/deployment/cloud.md
|
||||
- how-tos/deploy-self-hosted.md
|
||||
- how-tos/use-remote-graph.md
|
||||
- Authentication & Access Control:
|
||||
- Authentication & Access Control: how-tos#authentication-access-control
|
||||
- cloud/how-tos/auth/custom_auth_new.md
|
||||
- cloud/how-tos/auth/openapi_security_new.md
|
||||
- Assistants:
|
||||
- Assistants: how-tos#assistants
|
||||
- cloud/how-tos/configuration_cloud.md
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
.jupyter-wrapper .jp-Notebook .jp-Cell .jp-OutputPrompt {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.md-banner {
|
||||
background-color: #CFC9FA;
|
||||
color: #000000;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -190,3 +195,8 @@
|
||||
<title>{{ config.site_name }}</title>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block announce %}
|
||||
To learn more about LangGraph, check out our first LangChain Academy course, <em>Introduction to LangGraph</em>, available for free <a href="https://academy.langchain.com/courses/intro-to-langgraph">here</a>.
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,6 +19,7 @@ from langgraph.checkpoint.base import (
|
||||
)
|
||||
from langgraph.checkpoint.postgres import _internal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
Conn = _internal.Conn # For backward compatibility
|
||||
@@ -396,4 +397,4 @@ class PostgresSaver(BasePostgresSaver):
|
||||
yield cur
|
||||
|
||||
|
||||
__all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
|
||||
__all__ = ["PostgresSaver", "BasePostgresSaver", "ShallowPostgresSaver", "Conn"]
|
||||
|
||||
@@ -19,6 +19,7 @@ from langgraph.checkpoint.base import (
|
||||
)
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
Conn = _ainternal.Conn # For backward compatibility
|
||||
@@ -464,4 +465,4 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
).result()
|
||||
|
||||
|
||||
__all__ = ["AsyncPostgresSaver", "Conn"]
|
||||
__all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"]
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg import (
|
||||
AsyncConnection,
|
||||
AsyncCursor,
|
||||
AsyncPipeline,
|
||||
Capabilities,
|
||||
Connection,
|
||||
Cursor,
|
||||
Pipeline,
|
||||
)
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import AsyncConnectionPool, ConnectionPool
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
WRITES_IDX_MAP,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import _ainternal, _internal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.types import TASKS
|
||||
|
||||
"""
|
||||
To add a new migration, add a new string to the MIGRATIONS list.
|
||||
The position of the migration in the list is the version number.
|
||||
"""
|
||||
MIGRATIONS = [
|
||||
"""CREATE TABLE IF NOT EXISTS checkpoint_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
);""",
|
||||
"""CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
type TEXT,
|
||||
checkpoint JSONB NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (thread_id, checkpoint_ns)
|
||||
);""",
|
||||
"""CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, channel)
|
||||
);""",
|
||||
"""CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
blob BYTEA NOT NULL,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
);""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
|
||||
""",
|
||||
]
|
||||
|
||||
SELECT_SQL = f"""
|
||||
select
|
||||
thread_id,
|
||||
checkpoint,
|
||||
checkpoint_ns,
|
||||
metadata,
|
||||
(
|
||||
select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob])
|
||||
from jsonb_each_text(checkpoint -> 'channel_versions')
|
||||
inner join checkpoint_blobs bl
|
||||
on bl.thread_id = checkpoints.thread_id
|
||||
and bl.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
and bl.channel = jsonb_each_text.key
|
||||
) as channel_values,
|
||||
(
|
||||
select
|
||||
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
||||
from checkpoint_writes cw
|
||||
where cw.thread_id = checkpoints.thread_id
|
||||
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
and cw.checkpoint_id = (checkpoint->>'id')
|
||||
) as pending_writes,
|
||||
(
|
||||
select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
||||
from checkpoint_writes cw
|
||||
where cw.thread_id = checkpoints.thread_id
|
||||
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
and cw.channel = '{TASKS}'
|
||||
) as pending_sends
|
||||
from checkpoints """
|
||||
|
||||
UPSERT_CHECKPOINT_BLOBS_SQL = """
|
||||
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, type, blob)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (thread_id, checkpoint_ns, channel) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
blob = EXCLUDED.blob;
|
||||
"""
|
||||
|
||||
UPSERT_CHECKPOINTS_SQL = """
|
||||
INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint, metadata)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (thread_id, checkpoint_ns)
|
||||
DO UPDATE SET
|
||||
checkpoint = EXCLUDED.checkpoint,
|
||||
metadata = EXCLUDED.metadata;
|
||||
"""
|
||||
|
||||
UPSERT_CHECKPOINT_WRITES_SQL = """
|
||||
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET
|
||||
channel = EXCLUDED.channel,
|
||||
type = EXCLUDED.type,
|
||||
blob = EXCLUDED.blob;
|
||||
"""
|
||||
|
||||
INSERT_CHECKPOINT_WRITES_SQL = """
|
||||
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
|
||||
"""
|
||||
|
||||
|
||||
def _dump_blobs(
|
||||
serde: SerializerProtocol,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
values: dict[str, Any],
|
||||
versions: ChannelVersions,
|
||||
) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
|
||||
if not versions:
|
||||
return []
|
||||
|
||||
return [
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
k,
|
||||
*(serde.dumps_typed(values[k]) if k in values else ("empty", None)),
|
||||
)
|
||||
for k in versions
|
||||
]
|
||||
|
||||
|
||||
class ShallowPostgresSaver(BasePostgresSaver):
|
||||
"""A checkpoint saver that uses Postgres to store checkpoints.
|
||||
|
||||
This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
|
||||
It is meant to be a light-weight drop-in replacement for the PostgresSaver that
|
||||
supports most of the LangGraph persistence functionality with the exception of time travel.
|
||||
"""
|
||||
|
||||
SELECT_SQL = SELECT_SQL
|
||||
MIGRATIONS = MIGRATIONS
|
||||
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
||||
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
||||
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
||||
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
|
||||
|
||||
lock: threading.Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: _internal.Conn,
|
||||
pipe: Optional[Pipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
if isinstance(conn, ConnectionPool) and pipe is not None:
|
||||
raise ValueError(
|
||||
"Pipeline should be used only with a single Connection, not ConnectionPool."
|
||||
)
|
||||
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.lock = threading.Lock()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def from_conn_string(
|
||||
cls, conn_string: str, *, pipeline: bool = False
|
||||
) -> Iterator["ShallowPostgresSaver"]:
|
||||
"""Create a new ShallowPostgresSaver instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
pipeline (bool): whether to use Pipeline
|
||||
|
||||
Returns:
|
||||
ShallowPostgresSaver: A new ShallowPostgresSaver instance.
|
||||
"""
|
||||
with Connection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
if pipeline:
|
||||
with conn.pipeline() as pipe:
|
||||
yield cls(conn, pipe)
|
||||
else:
|
||||
yield cls(conn)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
This method creates the necessary tables in the Postgres database if they don't
|
||||
already exist and runs database migrations. It MUST be called directly by the user
|
||||
the first time checkpointer is used.
|
||||
"""
|
||||
with self._cursor() as cur:
|
||||
cur.execute(self.MIGRATIONS[0])
|
||||
results = cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = results.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
for v, migration in zip(
|
||||
range(version + 1, len(self.MIGRATIONS)),
|
||||
self.MIGRATIONS[version + 1 :],
|
||||
):
|
||||
cur.execute(migration)
|
||||
cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
|
||||
if self.pipe:
|
||||
self.pipe.sync()
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the Postgres database based
|
||||
on the provided config. For ShallowPostgresSaver, this method returns a list with
|
||||
ONLY the most recent checkpoint.
|
||||
"""
|
||||
where, args = self._search_where(config, filter, before)
|
||||
query = self.SELECT_SQL + where
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
with self._cursor() as cur:
|
||||
cur.execute(self.SELECT_SQL + where, args, binary=True)
|
||||
for value in cur:
|
||||
checkpoint = self._load_checkpoint(
|
||||
value["checkpoint"],
|
||||
value["channel_values"],
|
||||
value["pending_sends"],
|
||||
)
|
||||
yield CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": value["thread_id"],
|
||||
"checkpoint_ns": value["checkpoint_ns"],
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
},
|
||||
checkpoint=checkpoint,
|
||||
metadata=self._load_metadata(value["metadata"]),
|
||||
pending_writes=self._load_writes(value["pending_writes"]),
|
||||
)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database.
|
||||
|
||||
This method retrieves a checkpoint tuple from the Postgres database based on the
|
||||
provided config (matching the thread ID in the config).
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
|
||||
Examples:
|
||||
|
||||
Basic:
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
|
||||
With timestamp:
|
||||
|
||||
>>> config = {
|
||||
... "configurable": {
|
||||
... "thread_id": "1",
|
||||
... "checkpoint_ns": "",
|
||||
... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
... }
|
||||
... }
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
""" # noqa
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
args = (thread_id, checkpoint_ns)
|
||||
where = "WHERE thread_id = %s AND checkpoint_ns = %s"
|
||||
|
||||
with self._cursor() as cur:
|
||||
cur.execute(
|
||||
self.SELECT_SQL + where,
|
||||
args,
|
||||
binary=True,
|
||||
)
|
||||
|
||||
for value in cur:
|
||||
checkpoint = self._load_checkpoint(
|
||||
value["checkpoint"],
|
||||
value["channel_values"],
|
||||
value["pending_sends"],
|
||||
)
|
||||
return CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
},
|
||||
checkpoint=checkpoint,
|
||||
metadata=self._load_metadata(value["metadata"]),
|
||||
pending_writes=self._load_writes(value["pending_writes"]),
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database.
|
||||
|
||||
This method saves a checkpoint to the Postgres database. The checkpoint is associated
|
||||
with the provided config. For ShallowPostgresSaver, this method saves ONLY the most recent
|
||||
checkpoint and overwrites a previous checkpoint, if it exists.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
|
||||
new_versions (ChannelVersions): New channel versions as of this write.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: Updated configuration after storing the checkpoint.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> from langgraph.checkpoint.postgres import ShallowPostgresSaver
|
||||
>>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
|
||||
>>> with ShallowPostgresSaver.from_conn_string(DB_URI) as memory:
|
||||
>>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
|
||||
>>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}}
|
||||
>>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {})
|
||||
>>> print(saved_config)
|
||||
{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}}
|
||||
"""
|
||||
configurable = config["configurable"].copy()
|
||||
thread_id = configurable.pop("thread_id")
|
||||
checkpoint_ns = configurable.pop("checkpoint_ns")
|
||||
|
||||
copy = checkpoint.copy()
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
|
||||
with self._cursor(pipeline=True) as cur:
|
||||
cur.execute(
|
||||
"""DELETE FROM checkpoint_writes
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint["id"],
|
||||
configurable.get("checkpoint_id", ""),
|
||||
),
|
||||
)
|
||||
cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
_dump_blobs(
|
||||
self.serde,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
copy.pop("channel_values"), # type: ignore[misc]
|
||||
new_versions,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
self.UPSERT_CHECKPOINTS_SQL,
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
Jsonb(self._dump_checkpoint(copy)),
|
||||
self._dump_metadata(metadata),
|
||||
),
|
||||
)
|
||||
return next_config
|
||||
|
||||
def put_writes(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
writes: Sequence[tuple[str, Any]],
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Store intermediate writes linked to a checkpoint.
|
||||
|
||||
This method saves intermediate writes associated with a checkpoint to the Postgres database.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): Configuration of the related checkpoint.
|
||||
writes (List[Tuple[str, Any]]): List of writes to store.
|
||||
task_id (str): Identifier for the task creating the writes.
|
||||
"""
|
||||
query = (
|
||||
self.UPSERT_CHECKPOINT_WRITES_SQL
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes)
|
||||
else self.INSERT_CHECKPOINT_WRITES_SQL
|
||||
)
|
||||
with self._cursor(pipeline=True) as cur:
|
||||
cur.executemany(
|
||||
query,
|
||||
self._dump_writes(
|
||||
config["configurable"]["thread_id"],
|
||||
config["configurable"]["checkpoint_ns"],
|
||||
config["configurable"]["checkpoint_id"],
|
||||
task_id,
|
||||
writes,
|
||||
),
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the ShallowPostgresSaver instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
with _internal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
# used at a time
|
||||
try:
|
||||
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
finally:
|
||||
if pipeline:
|
||||
self.pipe.sync()
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
if self.supports_pipeline:
|
||||
with (
|
||||
self.lock,
|
||||
conn.pipeline(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
# Use connection's transaction context manager when pipeline mode not supported
|
||||
with (
|
||||
self.lock,
|
||||
conn.transaction(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
|
||||
class AsyncShallowPostgresSaver(BasePostgresSaver):
|
||||
"""A checkpoint saver that uses Postgres to store checkpoints asynchronously.
|
||||
|
||||
This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
|
||||
It is meant to be a light-weight drop-in replacement for the AsyncPostgresSaver that
|
||||
supports most of the LangGraph persistence functionality with the exception of time travel.
|
||||
"""
|
||||
|
||||
SELECT_SQL = SELECT_SQL
|
||||
MIGRATIONS = MIGRATIONS
|
||||
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
||||
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
||||
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
||||
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
|
||||
lock: asyncio.Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: _ainternal.Conn,
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
||||
raise ValueError(
|
||||
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
|
||||
)
|
||||
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.lock = asyncio.Lock()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
*,
|
||||
pipeline: bool = False,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> AsyncIterator["AsyncShallowPostgresSaver"]:
|
||||
"""Create a new AsyncShallowPostgresSaver instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
pipeline (bool): whether to use AsyncPipeline
|
||||
|
||||
Returns:
|
||||
AsyncShallowPostgresSaver: A new AsyncShallowPostgresSaver instance.
|
||||
"""
|
||||
async with await AsyncConnection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
if pipeline:
|
||||
async with conn.pipeline() as pipe:
|
||||
yield cls(conn=conn, pipe=pipe, serde=serde)
|
||||
else:
|
||||
yield cls(conn=conn, serde=serde)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
This method creates the necessary tables in the Postgres database if they don't
|
||||
already exist and runs database migrations. It MUST be called directly by the user
|
||||
the first time checkpointer is used.
|
||||
"""
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(self.MIGRATIONS[0])
|
||||
results = await cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = await results.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
for v, migration in zip(
|
||||
range(version + 1, len(self.MIGRATIONS)),
|
||||
self.MIGRATIONS[version + 1 :],
|
||||
):
|
||||
await cur.execute(migration)
|
||||
await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
|
||||
if self.pipe:
|
||||
await self.pipe.sync()
|
||||
|
||||
async def alist(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database asynchronously.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the Postgres database based
|
||||
on the provided config. For ShallowPostgresSaver, this method returns a list with
|
||||
ONLY the most recent checkpoint.
|
||||
"""
|
||||
where, args = self._search_where(config, filter, before)
|
||||
query = self.SELECT_SQL + where
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(self.SELECT_SQL + where, args, binary=True)
|
||||
async for value in cur:
|
||||
checkpoint = await asyncio.to_thread(
|
||||
self._load_checkpoint,
|
||||
value["checkpoint"],
|
||||
value["channel_values"],
|
||||
value["pending_sends"],
|
||||
)
|
||||
yield CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": value["thread_id"],
|
||||
"checkpoint_ns": value["checkpoint_ns"],
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
},
|
||||
checkpoint=checkpoint,
|
||||
metadata=self._load_metadata(value["metadata"]),
|
||||
pending_writes=await asyncio.to_thread(
|
||||
self._load_writes, value["pending_writes"]
|
||||
),
|
||||
)
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database asynchronously.
|
||||
|
||||
This method retrieves a checkpoint tuple from the Postgres database based on the
|
||||
provided config (matching the thread ID in the config).
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
args = (thread_id, checkpoint_ns)
|
||||
where = "WHERE thread_id = %s AND checkpoint_ns = %s"
|
||||
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(
|
||||
self.SELECT_SQL + where,
|
||||
args,
|
||||
binary=True,
|
||||
)
|
||||
|
||||
async for value in cur:
|
||||
checkpoint = await asyncio.to_thread(
|
||||
self._load_checkpoint,
|
||||
value["checkpoint"],
|
||||
value["channel_values"],
|
||||
value["pending_sends"],
|
||||
)
|
||||
return CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
},
|
||||
checkpoint=checkpoint,
|
||||
metadata=self._load_metadata(value["metadata"]),
|
||||
pending_writes=await asyncio.to_thread(
|
||||
self._load_writes, value["pending_writes"]
|
||||
),
|
||||
)
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database asynchronously.
|
||||
|
||||
This method saves a checkpoint to the Postgres database. The checkpoint is associated
|
||||
with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
|
||||
checkpoint and overwrites a previous checkpoint, if it exists.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
|
||||
new_versions (ChannelVersions): New channel versions as of this write.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: Updated configuration after storing the checkpoint.
|
||||
"""
|
||||
configurable = config["configurable"].copy()
|
||||
thread_id = configurable.pop("thread_id")
|
||||
checkpoint_ns = configurable.pop("checkpoint_ns")
|
||||
|
||||
copy = checkpoint.copy()
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
|
||||
async with self._cursor(pipeline=True) as cur:
|
||||
await cur.execute(
|
||||
"""DELETE FROM checkpoint_writes
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint["id"],
|
||||
configurable.get("checkpoint_id", ""),
|
||||
),
|
||||
)
|
||||
await cur.executemany(
|
||||
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
||||
_dump_blobs(
|
||||
self.serde,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
copy.pop("channel_values"), # type: ignore[misc]
|
||||
new_versions,
|
||||
),
|
||||
)
|
||||
await cur.execute(
|
||||
self.UPSERT_CHECKPOINTS_SQL,
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
Jsonb(self._dump_checkpoint(copy)),
|
||||
self._dump_metadata(metadata),
|
||||
),
|
||||
)
|
||||
return next_config
|
||||
|
||||
async def aput_writes(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
writes: Sequence[tuple[str, Any]],
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Store intermediate writes linked to a checkpoint asynchronously.
|
||||
|
||||
This method saves intermediate writes associated with a checkpoint to the database.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): Configuration of the related checkpoint.
|
||||
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
|
||||
task_id (str): Identifier for the task creating the writes.
|
||||
"""
|
||||
query = (
|
||||
self.UPSERT_CHECKPOINT_WRITES_SQL
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes)
|
||||
else self.INSERT_CHECKPOINT_WRITES_SQL
|
||||
)
|
||||
params = await asyncio.to_thread(
|
||||
self._dump_writes,
|
||||
config["configurable"]["thread_id"],
|
||||
config["configurable"]["checkpoint_ns"],
|
||||
config["configurable"]["checkpoint_id"],
|
||||
task_id,
|
||||
writes,
|
||||
)
|
||||
async with self._cursor(pipeline=True) as cur:
|
||||
await cur.executemany(query, params)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cursor(
|
||||
self, *, pipeline: bool = False
|
||||
) -> AsyncIterator[AsyncCursor[DictRow]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the AsyncShallowPostgresSaver instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
async with _ainternal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
# used at a time
|
||||
try:
|
||||
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
finally:
|
||||
if pipeline:
|
||||
await self.pipe.sync()
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
if self.supports_pipeline:
|
||||
async with (
|
||||
self.lock,
|
||||
conn.pipeline(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
# Use connection's transaction context manager when pipeline mode not supported
|
||||
async with (
|
||||
self.lock,
|
||||
conn.transaction(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
async with (
|
||||
self.lock,
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the Postgres database based
|
||||
on the provided config. For ShallowPostgresSaver, this method returns a list with
|
||||
ONLY the most recent checkpoint.
|
||||
"""
|
||||
aiter_ = self.alist(config, filter=filter, before=before, limit=limit)
|
||||
while True:
|
||||
try:
|
||||
yield asyncio.run_coroutine_threadsafe(
|
||||
anext(aiter_), # noqa: F821
|
||||
self.loop,
|
||||
).result()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database.
|
||||
|
||||
This method retrieves a checkpoint tuple from the Postgres database based on the
|
||||
provided config (matching the thread ID in the config).
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
"""
|
||||
try:
|
||||
# check if we are in the main thread, only bg threads can block
|
||||
# we don't check in other methods to avoid the overhead
|
||||
if asyncio.get_running_loop() is self.loop:
|
||||
raise asyncio.InvalidStateError(
|
||||
"Synchronous calls to AsyncShallowPostgresSaver are only allowed from a "
|
||||
"different thread. From the main thread, use the async interface."
|
||||
"For example, use `await checkpointer.aget_tuple(...)` or `await "
|
||||
"graph.ainvoke(...)`."
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aget_tuple(config), self.loop
|
||||
).result()
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database.
|
||||
|
||||
This method saves a checkpoint to the Postgres database. The checkpoint is associated
|
||||
with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
|
||||
checkpoint and overwrites a previous checkpoint, if it exists.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
|
||||
new_versions (ChannelVersions): New channel versions as of this write.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: Updated configuration after storing the checkpoint.
|
||||
"""
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aput(config, checkpoint, metadata, new_versions), self.loop
|
||||
).result()
|
||||
|
||||
def put_writes(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
writes: Sequence[tuple[str, Any]],
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Store intermediate writes linked to a checkpoint.
|
||||
|
||||
This method saves intermediate writes associated with a checkpoint to the database.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): Configuration of the related checkpoint.
|
||||
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
|
||||
task_id (str): Identifier for the task creating the writes.
|
||||
"""
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aput_writes(config, writes, task_id), self.loop
|
||||
).result()
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.8"
|
||||
version = "2.0.9"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -16,7 +16,10 @@ from langgraph.checkpoint.base import (
|
||||
create_checkpoint,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import (
|
||||
AsyncPostgresSaver,
|
||||
AsyncShallowPostgresSaver,
|
||||
)
|
||||
from tests.conftest import DEFAULT_POSTGRES_URI
|
||||
|
||||
|
||||
@@ -103,11 +106,41 @@ async def _base_saver():
|
||||
await conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _shallow_saver():
|
||||
"""Fixture for shallow connection mode testing."""
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI + database,
|
||||
autocommit=True,
|
||||
prepare_threshold=0,
|
||||
row_factory=dict_row,
|
||||
) as conn:
|
||||
checkpointer = AsyncShallowPostgresSaver(conn)
|
||||
await checkpointer.setup()
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _saver(name: str):
|
||||
if name == "base":
|
||||
async with _base_saver() as saver:
|
||||
yield saver
|
||||
elif name == "shallow":
|
||||
async with _shallow_saver() as saver:
|
||||
yield saver
|
||||
elif name == "pool":
|
||||
async with _pool_saver() as saver:
|
||||
yield saver
|
||||
@@ -167,7 +200,7 @@ def test_data():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
||||
async def test_asearch(request, saver_name: str, test_data) -> None:
|
||||
async with _saver(saver_name) as saver:
|
||||
configs = test_data["configs"]
|
||||
@@ -212,7 +245,7 @@ async def test_asearch(request, saver_name: str, test_data) -> None:
|
||||
} == {"", "inner"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
||||
async def test_null_chars(request, saver_name: str, test_data) -> None:
|
||||
async with _saver(saver_name) as saver:
|
||||
config = await saver.aput(
|
||||
|
||||
@@ -16,7 +16,7 @@ from langgraph.checkpoint.base import (
|
||||
create_checkpoint,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver
|
||||
from tests.conftest import DEFAULT_POSTGRES_URI
|
||||
|
||||
|
||||
@@ -91,11 +91,37 @@ def _base_saver():
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _shallow_saver():
|
||||
"""Fixture for regular connection mode testing with a shallow checkpointer."""
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
|
||||
conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
with Connection.connect(
|
||||
DEFAULT_POSTGRES_URI + database,
|
||||
autocommit=True,
|
||||
prepare_threshold=0,
|
||||
row_factory=dict_row,
|
||||
) as conn:
|
||||
checkpointer = ShallowPostgresSaver(conn)
|
||||
checkpointer.setup()
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _saver(name: str):
|
||||
if name == "base":
|
||||
with _base_saver() as saver:
|
||||
yield saver
|
||||
elif name == "shallow":
|
||||
with _shallow_saver() as saver:
|
||||
yield saver
|
||||
elif name == "pool":
|
||||
with _pool_saver() as saver:
|
||||
yield saver
|
||||
@@ -155,7 +181,7 @@ def test_data():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
||||
def test_search(saver_name: str, test_data) -> None:
|
||||
with _saver(saver_name) as saver:
|
||||
configs = test_data["configs"]
|
||||
@@ -198,7 +224,7 @@ def test_search(saver_name: str, test_data) -> None:
|
||||
} == {"", "inner"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
||||
def test_null_chars(saver_name: str, test_data) -> None:
|
||||
with _saver(saver_name) as saver:
|
||||
config = saver.put(
|
||||
|
||||
@@ -565,7 +565,7 @@ def dev(
|
||||
host: str,
|
||||
port: int,
|
||||
no_reload: bool,
|
||||
config: pathlib.Path,
|
||||
config: str,
|
||||
n_jobs_per_worker: Optional[int],
|
||||
no_browser: bool,
|
||||
debug_port: Optional[int],
|
||||
@@ -594,7 +594,7 @@ def dev(
|
||||
"Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
|
||||
) from None
|
||||
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
config_json = langgraph_cli.config.validate_config_file(pathlib.Path(config))
|
||||
cwd = os.getcwd()
|
||||
sys.path.append(cwd)
|
||||
dependencies = config_json.get("dependencies", [])
|
||||
|
||||
@@ -34,13 +34,13 @@ trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.8.30"
|
||||
version = "2024.12.14"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
|
||||
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
|
||||
{file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"},
|
||||
{file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -515,13 +515,13 @@ tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "0.3.24"
|
||||
version = "0.3.26"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "langchain_core-0.3.24-py3-none-any.whl", hash = "sha256:97192552ef882a3dd6ae3b870a180a743801d0137a1159173f51ac555eeb7eec"},
|
||||
{file = "langchain_core-0.3.24.tar.gz", hash = "sha256:460851e8145327f70b70aad7dce2cdbd285e144d14af82b677256b941fc99656"},
|
||||
{file = "langchain_core-0.3.26-py3-none-any.whl", hash = "sha256:03c1f43d1e1fc3ec37700764a7f4fc79279072978a9cae1ad3a395b2d02f3e8f"},
|
||||
{file = "langchain_core-0.3.26.tar.gz", hash = "sha256:828133f3a05fbb71d09f00c9fa7d2cbaccb6232ae4aa7e1b1be09951cac0bac5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -538,13 +538,13 @@ typing-extensions = ">=4.7"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.2.59"
|
||||
version = "0.2.60"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.9.0"
|
||||
files = [
|
||||
{file = "langgraph-0.2.59-py3-none-any.whl", hash = "sha256:9b2d1331bbdcea96cfffde8e88700776118f4583d15f6a3423fc4482fe84ae56"},
|
||||
{file = "langgraph-0.2.59.tar.gz", hash = "sha256:61cb5dd409878641fe8a12e9d7928a7524caa3e54ab12bc883feeaba4a5ba618"},
|
||||
{file = "langgraph-0.2.60-py3-none-any.whl", hash = "sha256:0c454a04f358e8eb0d75cf05d129ae7d10fce1bda1b53d328ec346c718d57e56"},
|
||||
{file = "langgraph-0.2.60.tar.gz", hash = "sha256:24d1779e0506de5f8de57ffc7422f176a5f42caa342b8f8492a9a1763f8f9f48"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -554,13 +554,13 @@ langgraph-sdk = ">=0.1.42,<0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-api"
|
||||
version = "0.0.8"
|
||||
version = "0.0.12"
|
||||
description = ""
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.11.0"
|
||||
files = [
|
||||
{file = "langgraph_api-0.0.8-py3-none-any.whl", hash = "sha256:ab9b4a5ec8393d17ef921b94f3f019f49f3f17910ba9351beb1e1f962b02a33e"},
|
||||
{file = "langgraph_api-0.0.8.tar.gz", hash = "sha256:b1304beb9d72392ce5d5602cd524a4f57839f1dcd1da6f4e1391832fb4a6edc5"},
|
||||
{file = "langgraph_api-0.0.12-py3-none-any.whl", hash = "sha256:abe94ef5d2888bfdf378645efa4ce8ab0d9a4ad8b331a0d6106ddf5445754690"},
|
||||
{file = "langgraph_api-0.0.12.tar.gz", hash = "sha256:47315252855f04a321aeecf898d6dbc463fd5f34a4bb91ac3a5031676d29caab"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -570,7 +570,8 @@ jsonschema-rs = ">=0.25.0,<0.26.0"
|
||||
langchain-core = ">=0.2.38,<0.4.0"
|
||||
langgraph = ">=0.2.56,<0.3.0"
|
||||
langgraph-checkpoint = ">=2.0.7,<3.0"
|
||||
langsmith = ">=0.1.63,<0.2.0"
|
||||
langgraph-sdk = ">=0.1.48,<0.2.0"
|
||||
langsmith = ">=0.1.63,<0.3.0"
|
||||
orjson = ">=3.10.1"
|
||||
pyjwt = ">=2.9.0,<3.0.0"
|
||||
sse-starlette = ">=2.1.0,<3.0.0"
|
||||
@@ -597,13 +598,13 @@ msgpack = ">=1.1.0,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.44"
|
||||
version = "0.1.48"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = true
|
||||
python-versions = "<4.0.0,>=3.9.0"
|
||||
files = [
|
||||
{file = "langgraph_sdk-0.1.44-py3-none-any.whl", hash = "sha256:a5a623429b44616c4369c0caf2ea30ce8d3a103123f7bb3ca50dc726eaa313a4"},
|
||||
{file = "langgraph_sdk-0.1.44.tar.gz", hash = "sha256:5b17fe7c0a0fe4b83170c9b8987e0f98dc0636fcc0166dbdba725cc8ffd9b1cf"},
|
||||
{file = "langgraph_sdk-0.1.48-py3-none-any.whl", hash = "sha256:2c6c01d48e8eddff7e9688bdbda943ddb107831972667eb08a0c30e3a937fa11"},
|
||||
{file = "langgraph_sdk-0.1.48.tar.gz", hash = "sha256:a14ddfc0ea444b33fb5a7357ca0359d2d77961b61244a028dd952658d4d6bc92"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -612,13 +613,13 @@ orjson = ">=3.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.1.147"
|
||||
version = "0.2.3"
|
||||
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.8.1"
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "langsmith-0.1.147-py3-none-any.whl", hash = "sha256:7166fc23b965ccf839d64945a78e9f1157757add228b086141eb03a60d699a15"},
|
||||
{file = "langsmith-0.1.147.tar.gz", hash = "sha256:2e933220318a4e73034657103b3b1a3a6109cc5db3566a7e8e03be8d6d7def7a"},
|
||||
{file = "langsmith-0.2.3-py3-none-any.whl", hash = "sha256:4958b6e918f57fedba6ddc55b8534d1e06478bb44c779aa73713ce898ca6ae87"},
|
||||
{file = "langsmith-0.2.3.tar.gz", hash = "sha256:54c231b07fdeb0f8472925074a0ec0ed2cb654a0437d63c6ccf76a9da635900d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -894,18 +895,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.10.3"
|
||||
version = "2.10.4"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"},
|
||||
{file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"},
|
||||
{file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"},
|
||||
{file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.27.1"
|
||||
pydantic-core = "2.27.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
|
||||
[package.extras]
|
||||
@@ -914,111 +915,111 @@ timezone = ["tzdata"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.27.1"
|
||||
version = "2.27.2"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"},
|
||||
{file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"},
|
||||
{file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"},
|
||||
{file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"},
|
||||
{file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"},
|
||||
{file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"},
|
||||
{file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"},
|
||||
{file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"},
|
||||
{file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"},
|
||||
{file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"},
|
||||
{file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"},
|
||||
{file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"},
|
||||
{file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"},
|
||||
{file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"},
|
||||
{file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"},
|
||||
{file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"},
|
||||
{file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"},
|
||||
{file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"},
|
||||
{file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"},
|
||||
{file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"},
|
||||
{file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"},
|
||||
{file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"},
|
||||
{file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"},
|
||||
{file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"},
|
||||
{file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
|
||||
{file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1284,20 +1285,20 @@ examples = ["fastapi"]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.41.3"
|
||||
version = "0.42.0"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7"},
|
||||
{file = "starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835"},
|
||||
{file = "starlette-0.42.0-py3-none-any.whl", hash = "sha256:02f877201a3d6d301714b5c72f15cac305ea5cc9e213c4b46a5af7eecad0d625"},
|
||||
{file = "starlette-0.42.0.tar.gz", hash = "sha256:91f1fbd612f3e3d821a8a5f46bf381afe2a9722a7b8bbde1c07fb83384c2882a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.4.0,<5"
|
||||
|
||||
[package.extras]
|
||||
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
|
||||
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
@@ -1402,13 +1403,13 @@ zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.32.1"
|
||||
version = "0.34.0"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"},
|
||||
{file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"},
|
||||
{file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"},
|
||||
{file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1549,4 +1550,4 @@ inmem = ["langgraph-api", "python-dotenv"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "c36514f708e8b2b32c20ad9b7a3ba6fca41f043ced0149f0aae4eef43fc6c10b"
|
||||
content-hash = "d0297af9c28614fc2471ed4f0d86be6f717d8a9b54e360c14e5bd6325c4c00de"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.64"
|
||||
version = "0.1.65"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -14,7 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.8,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-api = { version = ">=0.0.12,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
python-dotenv = { version = ">=0.8.0", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import uuid
|
||||
from typing import Annotated, TypedDict, Union, cast
|
||||
import warnings
|
||||
from functools import partial
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Callable,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from langchain_core.messages import (
|
||||
AnyMessage,
|
||||
BaseMessage,
|
||||
BaseMessageChunk,
|
||||
MessageLikeRepresentation,
|
||||
RemoveMessage,
|
||||
@@ -15,7 +28,32 @@ from langgraph.graph.state import StateGraph
|
||||
Messages = Union[list[MessageLikeRepresentation], MessageLikeRepresentation]
|
||||
|
||||
|
||||
def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
def _add_messages_wrapper(func: Callable) -> Callable[[Messages, Messages], Messages]:
|
||||
def _add_messages(
|
||||
left: Optional[Messages] = None, right: Optional[Messages] = None, **kwargs: Any
|
||||
) -> Union[Messages, Callable[[Messages, Messages], Messages]]:
|
||||
if left is not None and right is not None:
|
||||
return func(left, right, **kwargs)
|
||||
elif left is not None or right is not None:
|
||||
msg = (
|
||||
f"Must specify non-null arguments for both 'left' and 'right'. Only "
|
||||
f"received: '{'left' if left else 'right'}'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
return partial(func, **kwargs)
|
||||
|
||||
_add_messages.__doc__ = func.__doc__
|
||||
return cast(Callable[[Messages, Messages], Messages], _add_messages)
|
||||
|
||||
|
||||
@_add_messages_wrapper
|
||||
def add_messages(
|
||||
left: Messages,
|
||||
right: Messages,
|
||||
*,
|
||||
format: Optional[Literal["langchain-openai"]] = None,
|
||||
) -> Messages:
|
||||
"""Merges two lists of messages, updating existing messages by ID.
|
||||
|
||||
By default, this ensures the state is "append-only", unless the
|
||||
@@ -25,6 +63,14 @@ def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
left: The base list of messages.
|
||||
right: The list of messages (or single message) to merge
|
||||
into the base list.
|
||||
format: The format to return messages in. If None then messages will be
|
||||
returned as is. If 'langchain-openai' then messages will be returned as
|
||||
BaseMessage objects with their contents formatted to match OpenAI message
|
||||
format, meaning contents can be string, 'text' blocks, or 'image_url' blocks
|
||||
and tool responses are returned as their own ToolMessages.
|
||||
|
||||
**REQUIREMENT**: Must have ``langchain-core>=0.3.11`` installed to use this
|
||||
feature.
|
||||
|
||||
Returns:
|
||||
A new list of messages with the messages from `right` merged into `left`.
|
||||
@@ -58,8 +104,59 @@ def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
>>> graph = builder.compile()
|
||||
>>> graph.invoke({})
|
||||
{'messages': [AIMessage(content='Hello', id=...)]}
|
||||
|
||||
>>> from typing import Annotated
|
||||
>>> from typing_extensions import TypedDict
|
||||
>>> from langgraph.graph import StateGraph, add_messages
|
||||
>>>
|
||||
>>> class State(TypedDict):
|
||||
... messages: Annotated[list, add_messages(format='langchain-openai')]
|
||||
...
|
||||
>>> def chatbot_node(state: State) -> list:
|
||||
... return {"messages": [
|
||||
... {
|
||||
... "role": "user",
|
||||
... "content": [
|
||||
... {
|
||||
... "type": "text",
|
||||
... "text": "Here's an image:",
|
||||
... "cache_control": {"type": "ephemeral"},
|
||||
... },
|
||||
... {
|
||||
... "type": "image",
|
||||
... "source": {
|
||||
... "type": "base64",
|
||||
... "media_type": "image/jpeg",
|
||||
... "data": "1234",
|
||||
... },
|
||||
... },
|
||||
... ]
|
||||
... },
|
||||
... ]}
|
||||
>>> builder = StateGraph(State)
|
||||
>>> builder.add_node("chatbot", chatbot_node)
|
||||
>>> builder.set_entry_point("chatbot")
|
||||
>>> builder.set_finish_point("chatbot")
|
||||
>>> graph = builder.compile()
|
||||
>>> graph.invoke({"messages": []})
|
||||
{
|
||||
'messages': [
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Here's an image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,1234"},
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
..versionchanged:: 0.2.61
|
||||
|
||||
Support for 'format="langchain-openai"' flag added.
|
||||
"""
|
||||
# coerce to list
|
||||
if not isinstance(left, list):
|
||||
@@ -100,6 +197,15 @@ def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
|
||||
merged.append(m)
|
||||
merged = [m for m in merged if m.id not in ids_to_remove]
|
||||
|
||||
if format == "langchain-openai":
|
||||
merged = _format_messages(merged)
|
||||
elif format:
|
||||
msg = f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
pass
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@@ -156,3 +262,19 @@ class MessageGraph(StateGraph):
|
||||
|
||||
class MessagesState(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], add_messages]
|
||||
|
||||
|
||||
def _format_messages(messages: Sequence[BaseMessage]) -> list[BaseMessage]:
|
||||
try:
|
||||
from langchain_core.messages import convert_to_openai_messages
|
||||
except ImportError:
|
||||
msg = (
|
||||
"Must have langchain-core>=0.3.11 installed to use automatic message "
|
||||
"formatting (format='langchain-openai'). Please update your langchain-core "
|
||||
"version or remove the 'format' flag. Returning un-formatted "
|
||||
"messages."
|
||||
)
|
||||
warnings.warn(msg)
|
||||
return list(messages)
|
||||
else:
|
||||
return convert_to_messages(convert_to_openai_messages(messages))
|
||||
|
||||
@@ -961,8 +961,12 @@ def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
|
||||
if len(meta) >= 1 and callable(meta[-1]):
|
||||
sig = signature(meta[-1])
|
||||
params = list(sig.parameters.values())
|
||||
if len(params) == 2 and all(
|
||||
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in params
|
||||
if (
|
||||
sum(
|
||||
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
||||
for p in params
|
||||
)
|
||||
== 2
|
||||
):
|
||||
return BinaryOperatorAggregate(typ, meta[-1])
|
||||
else:
|
||||
|
||||
@@ -382,6 +382,8 @@ def create_react_agent(
|
||||
|
||||
```pycon
|
||||
>>> from typing import TypedDict
|
||||
>>>
|
||||
>>> from langgraph.managed import IsLastStep
|
||||
>>> prompt = ChatPromptTemplate.from_messages(
|
||||
... [
|
||||
... ("system", "Today is {today}"),
|
||||
@@ -392,7 +394,7 @@ def create_react_agent(
|
||||
>>> class CustomState(TypedDict):
|
||||
... today: str
|
||||
... messages: Annotated[list[BaseMessage], add_messages]
|
||||
... is_last_step: str
|
||||
... is_last_step: IsLastStep
|
||||
>>>
|
||||
>>> graph = create_react_agent(model, tools, state_schema=CustomState, state_modifier=prompt)
|
||||
>>> inputs = {"messages": [("user", "What's today's date? And what's the weather in SF?")], "today": "July 16, 2004"}
|
||||
|
||||
@@ -1139,6 +1139,10 @@ class Pregel(PregelProtocol):
|
||||
values: dict[str, Any] | Any,
|
||||
as_node: Optional[str] = None,
|
||||
) -> RunnableConfig:
|
||||
"""Update the state of the graph asynchronously with the given values, as if they came from
|
||||
node `as_node`. If `as_node` is not provided, it will be set to the last node
|
||||
that updated the state, if not ambiguous.
|
||||
"""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
|
||||
@@ -271,7 +271,7 @@ class Command(Generic[N], ToolOutputMixin):
|
||||
"""
|
||||
|
||||
graph: Optional[str] = None
|
||||
update: Any = ()
|
||||
update: Optional[Any] = None
|
||||
resume: Optional[Union[Any, dict[str, Any]]] = None
|
||||
goto: Union[Send, Sequence[Union[Send, str]], str] = ()
|
||||
|
||||
@@ -292,8 +292,10 @@ class Command(Generic[N], ToolOutputMixin):
|
||||
for t in self.update
|
||||
):
|
||||
return self.update
|
||||
else:
|
||||
elif self.update is not None:
|
||||
return [("__root__", self.update)]
|
||||
else:
|
||||
return []
|
||||
|
||||
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
@@ -1325,18 +1325,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "0.3.23"
|
||||
version = "0.3.25"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "langchain_core-0.3.23-py3-none-any.whl", hash = "sha256:550c0b996990830fa6515a71a1192a8a0343367999afc36d4ede14222941e420"},
|
||||
{file = "langchain_core-0.3.23.tar.gz", hash = "sha256:f9e175e3b82063cc3b160c2ca2b155832e1c6f915312e1204828f97d4aabf6e1"},
|
||||
{file = "langchain_core-0.3.25-py3-none-any.whl", hash = "sha256:e10581c6c74ba16bdc6fdf16b00cced2aa447cc4024ed19746a1232918edde38"},
|
||||
{file = "langchain_core-0.3.25.tar.gz", hash = "sha256:fdb8df41e5cdd928c0c2551ebbde1cea770ee3c64598395367ad77ddf9acbae7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jsonpatch = ">=1.33,<2.0"
|
||||
langsmith = ">=0.1.125,<0.2.0"
|
||||
langsmith = ">=0.1.125,<0.3"
|
||||
packaging = ">=23.2,<25"
|
||||
pydantic = [
|
||||
{version = ">=2.5.2,<3.0.0", markers = "python_full_version < \"3.12.4\""},
|
||||
@@ -1348,7 +1348,7 @@ typing-extensions = ">=4.7"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.8"
|
||||
version = "2.0.9"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -1418,7 +1418,7 @@ url = "../checkpoint-sqlite"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.43"
|
||||
version = "0.1.47"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -3123,22 +3123,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.4.1"
|
||||
version = "6.4.2"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"},
|
||||
{file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38"},
|
||||
{file = "tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph"
|
||||
version = "0.2.59"
|
||||
version = "0.2.60"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# serializer version: 1
|
||||
# name: test_weather_subgraph[memory]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_weather_subgraph[postgres_aio]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_weather_subgraph[postgres_aio_pipe]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_weather_subgraph[postgres_aio_pool]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_weather_subgraph[postgres_aio_shallow]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_weather_subgraph[sqlite_aio]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
router_node(router_node)
|
||||
normal_llm_node(normal_llm_node)
|
||||
weather_graph_model_node(model_node)
|
||||
weather_graph_weather_node(weather_node<hr/><small><em>__interrupt = before</em></small>)
|
||||
__end__([<p>__end__</p>]):::last
|
||||
__start__ --> router_node;
|
||||
normal_llm_node --> __end__;
|
||||
weather_graph_weather_node --> __end__;
|
||||
router_node -.-> normal_llm_node;
|
||||
router_node -.-> weather_graph_model_node;
|
||||
router_node -.-> __end__;
|
||||
subgraph weather_graph
|
||||
weather_graph_model_node --> weather_graph_weather_node;
|
||||
end
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
@@ -2878,6 +2878,19 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge[postgres_shallow]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> rewrite_query;
|
||||
analyzer_one --> retriever_one;
|
||||
qa --> __end__;
|
||||
retriever_one --> qa;
|
||||
retriever_two --> qa;
|
||||
rewrite_query --> analyzer_one;
|
||||
rewrite_query --> retriever_two;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge[sqlite]
|
||||
'''
|
||||
graph TD;
|
||||
@@ -3311,6 +3324,76 @@
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[postgres_shallow]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> rewrite_query;
|
||||
analyzer_one --> retriever_one;
|
||||
qa --> __end__;
|
||||
retriever_one --> qa;
|
||||
retriever_two --> qa;
|
||||
rewrite_query --> analyzer_one;
|
||||
rewrite_query -.-> retriever_two;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[postgres_shallow].1
|
||||
dict({
|
||||
'definitions': dict({
|
||||
'InnerObject': dict({
|
||||
'properties': dict({
|
||||
'yo': dict({
|
||||
'title': 'Yo',
|
||||
'type': 'integer',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'yo',
|
||||
]),
|
||||
'title': 'InnerObject',
|
||||
'type': 'object',
|
||||
}),
|
||||
}),
|
||||
'properties': dict({
|
||||
'inner': dict({
|
||||
'$ref': '#/definitions/InnerObject',
|
||||
}),
|
||||
'query': dict({
|
||||
'title': 'Query',
|
||||
'type': 'string',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'query',
|
||||
'inner',
|
||||
]),
|
||||
'title': 'Input',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[postgres_shallow].2
|
||||
dict({
|
||||
'properties': dict({
|
||||
'answer': dict({
|
||||
'title': 'Answer',
|
||||
'type': 'string',
|
||||
}),
|
||||
'docs': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'title': 'Docs',
|
||||
'type': 'array',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'answer',
|
||||
'docs',
|
||||
]),
|
||||
'title': 'Output',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[sqlite]
|
||||
'''
|
||||
graph TD;
|
||||
@@ -3788,6 +3871,76 @@
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_shallow]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> rewrite_query;
|
||||
analyzer_one --> retriever_one;
|
||||
qa --> __end__;
|
||||
retriever_one --> qa;
|
||||
retriever_two --> qa;
|
||||
rewrite_query --> analyzer_one;
|
||||
rewrite_query -.-> retriever_two;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_shallow].1
|
||||
dict({
|
||||
'$defs': dict({
|
||||
'InnerObject': dict({
|
||||
'properties': dict({
|
||||
'yo': dict({
|
||||
'title': 'Yo',
|
||||
'type': 'integer',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'yo',
|
||||
]),
|
||||
'title': 'InnerObject',
|
||||
'type': 'object',
|
||||
}),
|
||||
}),
|
||||
'properties': dict({
|
||||
'inner': dict({
|
||||
'$ref': '#/$defs/InnerObject',
|
||||
}),
|
||||
'query': dict({
|
||||
'title': 'Query',
|
||||
'type': 'string',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'query',
|
||||
'inner',
|
||||
]),
|
||||
'title': 'Input',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_shallow].2
|
||||
dict({
|
||||
'properties': dict({
|
||||
'answer': dict({
|
||||
'title': 'Answer',
|
||||
'type': 'string',
|
||||
}),
|
||||
'docs': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'title': 'Docs',
|
||||
'type': 'array',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'answer',
|
||||
'docs',
|
||||
]),
|
||||
'title': 'Output',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[sqlite]
|
||||
'''
|
||||
graph TD;
|
||||
@@ -3923,6 +4076,19 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch[postgres_shallow]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> rewrite_query;
|
||||
analyzer_one --> retriever_one;
|
||||
qa --> __end__;
|
||||
retriever_one --> qa;
|
||||
retriever_two --> qa;
|
||||
rewrite_query --> analyzer_one;
|
||||
rewrite_query -.-> retriever_two;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch[sqlite]
|
||||
'''
|
||||
graph TD;
|
||||
|
||||
@@ -934,6 +934,127 @@
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_aio_shallow]
|
||||
'''
|
||||
graph TD;
|
||||
__start__ --> rewrite_query;
|
||||
analyzer_one --> retriever_one;
|
||||
qa --> __end__;
|
||||
retriever_one --> qa;
|
||||
retriever_two --> qa;
|
||||
rewrite_query --> analyzer_one;
|
||||
rewrite_query -.-> retriever_two;
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_aio_shallow].1
|
||||
dict({
|
||||
'$defs': dict({
|
||||
'InnerObject': dict({
|
||||
'properties': dict({
|
||||
'yo': dict({
|
||||
'title': 'Yo',
|
||||
'type': 'integer',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'yo',
|
||||
]),
|
||||
'title': 'InnerObject',
|
||||
'type': 'object',
|
||||
}),
|
||||
}),
|
||||
'properties': dict({
|
||||
'answer': dict({
|
||||
'anyOf': list([
|
||||
dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
dict({
|
||||
'type': 'null',
|
||||
}),
|
||||
]),
|
||||
'default': None,
|
||||
'title': 'Answer',
|
||||
}),
|
||||
'docs': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'title': 'Docs',
|
||||
'type': 'array',
|
||||
}),
|
||||
'inner': dict({
|
||||
'$ref': '#/$defs/InnerObject',
|
||||
}),
|
||||
'query': dict({
|
||||
'title': 'Query',
|
||||
'type': 'string',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'query',
|
||||
'inner',
|
||||
'docs',
|
||||
]),
|
||||
'title': 'State',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[postgres_aio_shallow].2
|
||||
dict({
|
||||
'$defs': dict({
|
||||
'InnerObject': dict({
|
||||
'properties': dict({
|
||||
'yo': dict({
|
||||
'title': 'Yo',
|
||||
'type': 'integer',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'yo',
|
||||
]),
|
||||
'title': 'InnerObject',
|
||||
'type': 'object',
|
||||
}),
|
||||
}),
|
||||
'properties': dict({
|
||||
'answer': dict({
|
||||
'anyOf': list([
|
||||
dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
dict({
|
||||
'type': 'null',
|
||||
}),
|
||||
]),
|
||||
'default': None,
|
||||
'title': 'Answer',
|
||||
}),
|
||||
'docs': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'title': 'Docs',
|
||||
'type': 'array',
|
||||
}),
|
||||
'inner': dict({
|
||||
'$ref': '#/$defs/InnerObject',
|
||||
}),
|
||||
'query': dict({
|
||||
'title': 'Query',
|
||||
'type': 'string',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'query',
|
||||
'inner',
|
||||
'docs',
|
||||
]),
|
||||
'title': 'State',
|
||||
'type': 'object',
|
||||
})
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[sqlite_aio]
|
||||
'''
|
||||
graph TD;
|
||||
@@ -1362,6 +1483,21 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_send_react_interrupt_control[postgres_aio_shallow]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
graph TD;
|
||||
__start__([<p>__start__</p>]):::first
|
||||
agent(agent)
|
||||
foo([foo]):::last
|
||||
__start__ --> agent;
|
||||
agent -.-> foo;
|
||||
classDef default fill:#f2f0ff,line-height:1.2
|
||||
classDef first fill-opacity:0
|
||||
classDef last fill:#bfb6fc
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_send_react_interrupt_control[sqlite_aio]
|
||||
'''
|
||||
%%{init: {'flowchart': {'curve': 'linear'}}}%%
|
||||
|
||||
@@ -13,8 +13,11 @@ from pytest_mock import MockerFixture
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.checkpoint.duckdb import DuckDBSaver
|
||||
from langgraph.checkpoint.duckdb.aio import AsyncDuckDBSaver
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import (
|
||||
AsyncPostgresSaver,
|
||||
AsyncShallowPostgresSaver,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from langgraph.store.base import BaseStore
|
||||
@@ -100,6 +103,25 @@ def checkpointer_postgres():
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def checkpointer_postgres_shallow():
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
|
||||
conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
# yield checkpointer
|
||||
with ShallowPostgresSaver.from_conn_string(
|
||||
DEFAULT_POSTGRES_URI + database
|
||||
) as checkpointer:
|
||||
checkpointer.setup()
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def checkpointer_postgres_pipe():
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
@@ -167,6 +189,31 @@ async def _checkpointer_postgres_aio():
|
||||
await conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_postgres_aio_shallow():
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
# create unique db
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
# yield checkpointer
|
||||
async with AsyncShallowPostgresSaver.from_conn_string(
|
||||
DEFAULT_POSTGRES_URI + database
|
||||
) as checkpointer:
|
||||
await checkpointer.setup()
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
async with await AsyncConnection.connect(
|
||||
DEFAULT_POSTGRES_URI, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_postgres_aio_pipe():
|
||||
if sys.version_info < (3, 10):
|
||||
@@ -240,6 +287,9 @@ async def awith_checkpointer(
|
||||
elif checkpointer_name == "postgres_aio":
|
||||
async with _checkpointer_postgres_aio() as checkpointer:
|
||||
yield checkpointer
|
||||
elif checkpointer_name == "postgres_aio_shallow":
|
||||
async with _checkpointer_postgres_aio_shallow() as checkpointer:
|
||||
yield checkpointer
|
||||
elif checkpointer_name == "postgres_aio_pipe":
|
||||
async with _checkpointer_postgres_aio_pipe() as checkpointer:
|
||||
yield checkpointer
|
||||
@@ -417,20 +467,30 @@ async def awith_store(store_name: Optional[str]) -> AsyncIterator[BaseStore]:
|
||||
raise NotImplementedError(f"Unknown store {store_name}")
|
||||
|
||||
|
||||
ALL_CHECKPOINTERS_SYNC = [
|
||||
SHALLOW_CHECKPOINTERS_SYNC = ["postgres_shallow"]
|
||||
REGULAR_CHECKPOINTERS_SYNC = [
|
||||
"memory",
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"postgres_pipe",
|
||||
"postgres_pool",
|
||||
]
|
||||
ALL_CHECKPOINTERS_ASYNC = [
|
||||
ALL_CHECKPOINTERS_SYNC = [
|
||||
*REGULAR_CHECKPOINTERS_SYNC,
|
||||
*SHALLOW_CHECKPOINTERS_SYNC,
|
||||
]
|
||||
SHALLOW_CHECKPOINTERS_ASYNC = ["postgres_aio_shallow"]
|
||||
REGULAR_CHECKPOINTERS_ASYNC = [
|
||||
"memory",
|
||||
"sqlite_aio",
|
||||
"postgres_aio",
|
||||
"postgres_aio_pipe",
|
||||
"postgres_aio_pool",
|
||||
]
|
||||
ALL_CHECKPOINTERS_ASYNC = [
|
||||
*REGULAR_CHECKPOINTERS_ASYNC,
|
||||
*SHALLOW_CHECKPOINTERS_ASYNC,
|
||||
]
|
||||
ALL_CHECKPOINTERS_ASYNC_PLUS_NONE = [
|
||||
*ALL_CHECKPOINTERS_ASYNC,
|
||||
None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import langchain_core
|
||||
import pytest
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
@@ -8,9 +9,11 @@ from langchain_core.messages import (
|
||||
HumanMessage,
|
||||
RemoveMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import add_messages
|
||||
from langgraph.graph.message import MessagesState
|
||||
@@ -18,6 +21,8 @@ from langgraph.graph.state import END, START, StateGraph
|
||||
from tests.conftest import IS_LANGCHAIN_CORE_030_OR_GREATER
|
||||
from tests.messages import _AnyIdHumanMessage
|
||||
|
||||
_, CORE_MINOR, CORE_PATCH = (int(v) for v in langchain_core.__version__.split("."))
|
||||
|
||||
|
||||
def test_add_single_message():
|
||||
left = [HumanMessage(content="Hello", id="1")]
|
||||
@@ -178,3 +183,108 @@ def test_messages_state(state_schema):
|
||||
_AnyIdHumanMessage(content="foo"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
condition=not ((CORE_MINOR == 3 and CORE_PATCH >= 11) or CORE_MINOR > 3),
|
||||
reason="Requires langchain_core>=0.3.11.",
|
||||
)
|
||||
def test_messages_state_format_openai():
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], add_messages(format="langchain-openai")]
|
||||
|
||||
def foo(state):
|
||||
messages = [
|
||||
HumanMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Here's an image:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "1234",
|
||||
},
|
||||
},
|
||||
]
|
||||
),
|
||||
AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "foo",
|
||||
"input": {"bar": "baz"},
|
||||
"id": "1",
|
||||
}
|
||||
]
|
||||
),
|
||||
HumanMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "1",
|
||||
"is_error": False,
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "1234",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
),
|
||||
]
|
||||
return {"messages": messages}
|
||||
|
||||
expected = [
|
||||
HumanMessage(content="meow"),
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Here's an image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,1234"},
|
||||
},
|
||||
],
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "foo",
|
||||
"type": "tool_calls",
|
||||
"args": {"bar": "baz"},
|
||||
"id": "1",
|
||||
}
|
||||
],
|
||||
),
|
||||
ToolMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,1234"},
|
||||
}
|
||||
],
|
||||
tool_call_id="1",
|
||||
),
|
||||
]
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_edge(START, "foo")
|
||||
graph.add_edge("foo", END)
|
||||
graph.add_node(foo)
|
||||
|
||||
app = graph.compile()
|
||||
|
||||
result = app.invoke({"messages": [("user", "meow")]})
|
||||
for m in result["messages"]:
|
||||
m.id = None
|
||||
assert result == {"messages": expected}
|
||||
|
||||
@@ -78,6 +78,7 @@ from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence
|
||||
from tests.conftest import (
|
||||
ALL_CHECKPOINTERS_SYNC,
|
||||
ALL_STORES_SYNC,
|
||||
REGULAR_CHECKPOINTERS_SYNC,
|
||||
SHOULD_CHECK_SNAPSHOTS,
|
||||
)
|
||||
from tests.memory_assert import MemorySaverAssertCheckpointMetadata
|
||||
@@ -624,7 +625,7 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
|
||||
assert step == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
|
||||
def test_run_from_checkpoint_id_retains_previous_writes(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
|
||||
) -> None:
|
||||
@@ -1157,6 +1158,10 @@ def test_pending_writes_resume(
|
||||
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
|
||||
assert graph.invoke(None, thread1) == {"value": 6}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
assert len(list(checkpointer.list(thread1))) == 1
|
||||
return
|
||||
|
||||
# check all final checkpoints
|
||||
checkpoints = [c for c in checkpointer.list(thread1)]
|
||||
# we should have 3
|
||||
@@ -1618,6 +1623,9 @@ def test_invoke_checkpoint_three(
|
||||
assert state.values.get("total") == 5
|
||||
assert state.next == ()
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
return
|
||||
|
||||
assert len(list(app.get_state_history(thread_1, limit=1))) == 1
|
||||
# list all checkpoints for thread 1
|
||||
thread_1_history = [c for c in app.get_state_history(thread_1)]
|
||||
@@ -2270,6 +2278,11 @@ def test_in_one_fan_out_state_graph_waiting_edge(
|
||||
]
|
||||
|
||||
app_w_interrupt.update_state(config, {"docs": ["doc5"]})
|
||||
expected_parent_config = (
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else list(app_w_interrupt.checkpointer.list(config, limit=2))[-1].config
|
||||
)
|
||||
assert app_w_interrupt.get_state(config) == StateSnapshot(
|
||||
values={
|
||||
"query": "analyzed: query: what is weather in sf",
|
||||
@@ -2277,8 +2290,14 @@ def test_in_one_fan_out_state_graph_waiting_edge(
|
||||
},
|
||||
tasks=(PregelTask(AnyStr(), "qa", (PULL, "qa")),),
|
||||
next=("qa",),
|
||||
config=app_w_interrupt.checkpointer.get_tuple(config).config,
|
||||
created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"],
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
metadata={
|
||||
"parents": {},
|
||||
"source": "update",
|
||||
@@ -2286,7 +2305,7 @@ def test_in_one_fan_out_state_graph_waiting_edge(
|
||||
"writes": {"retriever_one": {"docs": ["doc5"]}},
|
||||
"thread_id": "2",
|
||||
},
|
||||
parent_config=[*app_w_interrupt.checkpointer.list(config, limit=2)][-1].config,
|
||||
parent_config=expected_parent_config,
|
||||
)
|
||||
|
||||
assert [c for c in app_w_interrupt.stream(None, config, debug=1)] == [
|
||||
@@ -4670,13 +4689,17 @@ def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str)
|
||||
"parents": {},
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(),
|
||||
)
|
||||
|
||||
@@ -5203,11 +5226,15 @@ def test_checkpoint_recovery(request: pytest.FixtureRequest, checkpointer_name:
|
||||
assert state is not None
|
||||
assert state.values == {"steps": ["start"], "attempt": 1} # input state saved
|
||||
assert state.next == ("node1",) # Should retry failed node
|
||||
assert "RuntimeError('Simulated failure')" in state.tasks[0].error
|
||||
|
||||
# Retry with updated attempt count
|
||||
result = graph.invoke({"steps": [], "attempt": 2}, config)
|
||||
assert result == {"steps": ["start", "node1", "node2"], "attempt": 2}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
return
|
||||
|
||||
# Verify checkpoint history shows both attempts
|
||||
history = list(graph.get_state_history(config))
|
||||
assert len(history) == 6 # Initial + failed attempt + successful attempt
|
||||
|
||||
@@ -75,6 +75,7 @@ from tests.conftest import (
|
||||
ALL_CHECKPOINTERS_ASYNC,
|
||||
ALL_CHECKPOINTERS_ASYNC_PLUS_NONE,
|
||||
ALL_STORES_ASYNC,
|
||||
REGULAR_CHECKPOINTERS_ASYNC,
|
||||
SHOULD_CHECK_SNAPSHOTS,
|
||||
awith_checkpointer,
|
||||
awith_store,
|
||||
@@ -347,22 +348,23 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None:
|
||||
)
|
||||
},
|
||||
]
|
||||
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
if "shallow" not in checkpointer_name:
|
||||
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
tup = await tool_two.checkpointer.aget_tuple(thread1)
|
||||
assert await tool_two.aget_state(thread1) == StateSnapshot(
|
||||
values={"my_key": "value ⛰️", "market": "DE"},
|
||||
@@ -390,9 +392,13 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None:
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [c async for c in tool_two.checkpointer.alist(thread1, limit=2)][
|
||||
-1
|
||||
].config
|
||||
),
|
||||
)
|
||||
|
||||
# clear the interrupt and next tasks
|
||||
@@ -412,9 +418,13 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None:
|
||||
"writes": {},
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [c async for c in tool_two.checkpointer.alist(thread1, limit=2)][
|
||||
-1
|
||||
].config
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -524,22 +534,25 @@ async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None:
|
||||
)
|
||||
},
|
||||
]
|
||||
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1root)] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
if "shallow" not in checkpointer_name:
|
||||
assert [
|
||||
c.metadata async for c in tool_two.checkpointer.alist(thread1root)
|
||||
] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
tup = await tool_two.checkpointer.aget_tuple(thread1)
|
||||
assert await tool_two.aget_state(thread1) == StateSnapshot(
|
||||
values={"my_key": "value ⛰️", "market": "DE"},
|
||||
@@ -573,9 +586,13 @@ async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None:
|
||||
"writes": None,
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [
|
||||
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
|
||||
][-1].config
|
||||
),
|
||||
)
|
||||
|
||||
# clear the interrupt and next tasks
|
||||
@@ -595,9 +612,13 @@ async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None:
|
||||
"writes": {},
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [
|
||||
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
|
||||
][-1].config
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -699,22 +720,25 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None:
|
||||
"my_key": "value ⛰️ one",
|
||||
"market": "DE",
|
||||
}
|
||||
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": {"tool_one": {"my_key": " one"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
|
||||
if "shallow" not in checkpointer_name:
|
||||
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [
|
||||
{
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"writes": {"tool_one": {"my_key": " one"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
{
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
]
|
||||
|
||||
tup = await tool_two.checkpointer.aget_tuple(thread1)
|
||||
assert await tool_two.aget_state(thread1) == StateSnapshot(
|
||||
values={"my_key": "value ⛰️ one", "market": "DE"},
|
||||
@@ -742,9 +766,13 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None:
|
||||
"writes": {"tool_one": {"my_key": " one"}},
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [c async for c in tool_two.checkpointer.alist(thread1, limit=2)][
|
||||
-1
|
||||
].config
|
||||
),
|
||||
)
|
||||
# clear the interrupt and next tasks
|
||||
await tool_two.aupdate_state(thread1, None)
|
||||
@@ -770,9 +798,13 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None:
|
||||
"writes": {},
|
||||
"thread_id": "1",
|
||||
},
|
||||
parent_config=[
|
||||
c async for c in tool_two.checkpointer.alist(thread1, limit=2)
|
||||
][-1].config,
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else [c async for c in tool_two.checkpointer.alist(thread1, limit=2)][
|
||||
-1
|
||||
].config
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1754,6 +1786,10 @@ async def test_pending_writes_resume(
|
||||
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
|
||||
assert await graph.ainvoke(None, thread1) == {"value": 6}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
assert len([c async for c in checkpointer.alist(thread1)]) == 1
|
||||
return
|
||||
|
||||
# check all final checkpoints
|
||||
checkpoints = [c async for c in checkpointer.alist(thread1)]
|
||||
# we should have 3
|
||||
@@ -1911,7 +1947,7 @@ async def test_pending_writes_resume(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_run_from_checkpoint_id_retains_previous_writes(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
|
||||
) -> None:
|
||||
@@ -2339,7 +2375,7 @@ async def test_imp_stream_order(checkpointer_name: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
if not FF_SEND_V2:
|
||||
pytest.skip("Send deduplication is only available in Send V2")
|
||||
@@ -2792,13 +2828,17 @@ async def test_send_react_interrupt(checkpointer_name: str) -> None:
|
||||
"thread_id": "2",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -2875,13 +2915,17 @@ async def test_send_react_interrupt(checkpointer_name: str) -> None:
|
||||
"thread_id": "2",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(),
|
||||
)
|
||||
|
||||
@@ -2951,13 +2995,17 @@ async def test_send_react_interrupt(checkpointer_name: str) -> None:
|
||||
"thread_id": "3",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "3",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "3",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -3060,13 +3108,17 @@ async def test_send_react_interrupt(checkpointer_name: str) -> None:
|
||||
"thread_id": "3",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "3",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "3",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -3260,13 +3312,17 @@ async def test_send_react_interrupt_control(
|
||||
"thread_id": "2",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -3343,13 +3399,17 @@ async def test_send_react_interrupt_control(
|
||||
"thread_id": "2",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(),
|
||||
)
|
||||
|
||||
@@ -3572,6 +3632,9 @@ async def test_invoke_checkpoint_three(
|
||||
assert state.values.get("total") == 5
|
||||
assert state.next == ()
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
return
|
||||
|
||||
assert len([c async for c in app.aget_state_history(thread_1, limit=1)]) == 1
|
||||
# list all checkpoints for thread 1
|
||||
thread_1_history = [c async for c in app.aget_state_history(thread_1)]
|
||||
@@ -4279,13 +4342,17 @@ async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class(
|
||||
"thread_id": "1",
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async with assert_ctx_once():
|
||||
@@ -5758,13 +5825,17 @@ async def test_parent_command(checkpointer_name: str) -> None:
|
||||
"parents": {},
|
||||
},
|
||||
created_at=AnyStr(),
|
||||
parent_config={
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
parent_config=(
|
||||
None
|
||||
if "shallow" in checkpointer_name
|
||||
else {
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": AnyStr(),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
tasks=(),
|
||||
)
|
||||
|
||||
@@ -6281,6 +6352,9 @@ async def test_checkpoint_recovery_async(checkpointer_name: str):
|
||||
result = await graph.ainvoke({"steps": [], "attempt": 2}, config)
|
||||
assert result == {"steps": ["start", "node1", "node2"], "attempt": 2}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
return
|
||||
|
||||
# Verify checkpoint history shows both attempts
|
||||
history = [c async for c in graph.aget_state_history(config)]
|
||||
assert len(history) == 6 # Initial + failed attempt + successful attempt
|
||||
|
||||
@@ -11,26 +11,54 @@ AH = typing.TypeVar("AH", bound=types.Authenticator)
|
||||
|
||||
|
||||
class Auth:
|
||||
"""Authentication and authorization management for LangGraph.
|
||||
"""Add custom authentication and authorization management to your LangGraph application.
|
||||
|
||||
The Auth class provides a unified system for handling authentication and
|
||||
authorization in LangGraph applications. It supports:
|
||||
authorization in LangGraph applications. It supports custom user authentication
|
||||
protocols and fine-grained authorization rules for different resources and
|
||||
actions.
|
||||
|
||||
1. Authentication via a decorator-based handler system
|
||||
2. Fine-grained authorization rules for different resources and actions
|
||||
3. Global and resource-specific authorization handlers
|
||||
To use, create a separate python file and add the path to the file to your
|
||||
LangGraph API configuration file (`langgraph.json`). Within that file, create
|
||||
an instance of the Auth class and register authentication and authorization
|
||||
handlers as needed.
|
||||
|
||||
Example `langgraph.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./my_agent/agent.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
"path": "./auth.py:my_auth"
|
||||
}
|
||||
```
|
||||
|
||||
Then the LangGraph server will load your auth file and run it server-side whenever a request comes in.
|
||||
|
||||
???+ example "Basic Usage"
|
||||
```python
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
auth = Auth()
|
||||
my_auth = Auth()
|
||||
|
||||
async def verify_token(token: str) -> str:
|
||||
# Verify token and return user_id
|
||||
# This would typically be a call to your auth server
|
||||
return "user_id"
|
||||
|
||||
@auth.authenticate
|
||||
async def authenticate(authorization: str) -> tuple[list[str], str]:
|
||||
# Verify token and return (scopes, user_id)
|
||||
user_id = verify_token(authorization)
|
||||
return ["read", "write"], user_id
|
||||
async def authenticate(authorization: str) -> str:
|
||||
# Verify token and return user_id
|
||||
result = await verify_token(authorization)
|
||||
if result != "user_id":
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=401, detail="Unauthorized"
|
||||
)
|
||||
return result
|
||||
|
||||
# Global fallback handler
|
||||
@auth.on
|
||||
@@ -44,11 +72,12 @@ class Auth:
|
||||
```
|
||||
|
||||
???+ note "Request Processing Flow"
|
||||
1. Authentication is performed first on every request
|
||||
1. Authentication (your `@auth.authenticate` handler) is performed first on **every request**
|
||||
2. For authorization, the most specific matching handler is called:
|
||||
- If a handler exists for the exact resource and action, it is used
|
||||
- Otherwise, if a handler exists for the resource with any action, it is used
|
||||
- Finally, if no specific handlers match, the global handler is used (if any)
|
||||
* If a handler exists for the exact resource and action, it is used (e.g., `@auth.on.threads.create`)
|
||||
* Otherwise, if a handler exists for the resource with any action, it is used (e.g., `@auth.on.threads`)
|
||||
* Finally, if no specific handlers match, the global handler is used (e.g., `@auth.on`)
|
||||
* If no global handler is set, the request is accepted
|
||||
|
||||
This allows you to set default behavior with a global handler while
|
||||
overriding specific routes as needed.
|
||||
@@ -71,10 +100,64 @@ class Auth:
|
||||
"""Reference to auth exception definitions.
|
||||
|
||||
Provides access to all exception definitions used in the auth system,
|
||||
like HTTPException, etc."""
|
||||
like HTTPException, etc.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.on = _On(self)
|
||||
"""Entry point for authorization handlers that control access to specific resources.
|
||||
|
||||
The on class provides a flexible way to define authorization rules for different
|
||||
resources and actions in your application. It supports three main usage patterns:
|
||||
|
||||
1. Global handlers that run for all resources and actions
|
||||
2. Resource-specific handlers that run for all actions on a resource
|
||||
3. Resource and action specific handlers for fine-grained control
|
||||
|
||||
Each handler must be an async function that accepts two parameters:
|
||||
- ctx (AuthContext): Contains request context and authenticated user info
|
||||
- value: The data being authorized (type varies by endpoint)
|
||||
|
||||
The handler should return one of:
|
||||
|
||||
- None or True: Accept the request
|
||||
- False: Reject with 403 error
|
||||
- FilterType: Apply filtering rules to the response
|
||||
|
||||
???+ example "Examples"
|
||||
Global handler for all requests:
|
||||
```python
|
||||
@auth.on
|
||||
async def reject_unhandled_requests(ctx: AuthContext, value: Any) -> None:
|
||||
print(f"Request to {ctx.path} by {ctx.user.identity}")
|
||||
return False
|
||||
```
|
||||
|
||||
Resource-specific handler. This would take precedence over the global handler
|
||||
for all actions on the `threads` resource:
|
||||
```python
|
||||
@auth.on.threads
|
||||
async def check_thread_access(ctx: AuthContext, value: Any) -> bool:
|
||||
# Allow access only to threads created by the user
|
||||
return value.get("created_by") == ctx.user.identity
|
||||
```
|
||||
|
||||
Resource and action specific handler:
|
||||
```python
|
||||
@auth.on.threads.delete
|
||||
async def prevent_thread_deletion(ctx: AuthContext, value: Any) -> bool:
|
||||
# Only admins can delete threads
|
||||
return "admin" in ctx.user.permissions
|
||||
```
|
||||
|
||||
Multiple resources or actions:
|
||||
```python
|
||||
@auth.on(resources=["threads", "runs"], actions=["create", "update"])
|
||||
async def rate_limit_writes(ctx: AuthContext, value: Any) -> bool:
|
||||
# Implement rate limiting for write operations
|
||||
return await check_rate_limit(ctx.user.identity)
|
||||
```
|
||||
"""
|
||||
# These are accessed by the API. Changes to their names or types is
|
||||
# will be considered a breaking change.
|
||||
self._handlers: dict[tuple[str, str], list[types.Handler]] = {}
|
||||
@@ -88,21 +171,23 @@ class Auth:
|
||||
The authentication handler is responsible for verifying credentials
|
||||
and returning user scopes. It can accept any of the following parameters
|
||||
by name:
|
||||
|
||||
- request (Request): The raw ASGI request object
|
||||
- body (dict): The parsed request body
|
||||
- path (str): The request path
|
||||
- method (str): The HTTP method
|
||||
- scopes (list[str]): Required scopes
|
||||
- path_params (dict[str, str]): URL path parameters
|
||||
- query_params (dict[str, str]): URL query parameters
|
||||
- headers (dict[str, bytes]): Request headers
|
||||
- authorization (str): The Authorization header value
|
||||
- path (str): The request path, e.g., "/threads/abcd-1234-abcd-1234/runs/abcd-1234-abcd-1234/stream"
|
||||
- method (str): The HTTP method, e.g., "GET"
|
||||
- path_params (dict[str, str]): URL path parameters, e.g., {"thread_id": "abcd-1234-abcd-1234", "run_id": "abcd-1234-abcd-1234"}
|
||||
- query_params (dict[str, str]): URL query parameters, e.g., {"stream": "true"}
|
||||
- headers (dict[bytes, bytes]): Request headers
|
||||
- authorization (str | None): The Authorization header value (e.g., "Bearer <token>")
|
||||
|
||||
Args:
|
||||
fn (Callable): The authentication handler function to register.
|
||||
Must return tuple[scopes, user]
|
||||
where scopes is a list of string claims (like "runs:read", etc.)
|
||||
and user is either a user object (or similar dict) or a user id string.
|
||||
Must return a representation of the user. This could be a:
|
||||
- string (the user id)
|
||||
- dict containing {"identity": str, "permissions": list[str]}
|
||||
- or an object with identity and permissions properties
|
||||
Permissions can be optionally used by your handlers downstream.
|
||||
|
||||
Returns:
|
||||
The registered handler function.
|
||||
@@ -114,21 +199,38 @@ class Auth:
|
||||
Basic token authentication:
|
||||
```python
|
||||
@auth.authenticate
|
||||
async def authenticate(authorization: str) -> tuple[list[str], str]:
|
||||
async def authenticate(authorization: str) -> str:
|
||||
user_id = verify_token(authorization)
|
||||
return ["read"], user_id
|
||||
return user_id
|
||||
```
|
||||
|
||||
Complex authentication with request context:
|
||||
Accept the full request context:
|
||||
```python
|
||||
@auth.authenticate
|
||||
async def authenticate(
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, bytes]
|
||||
) -> tuple[list[str], MinimalUser]:
|
||||
) -> str:
|
||||
user = await verify_request(method, path, headers)
|
||||
return user.scopes, user
|
||||
return user
|
||||
```
|
||||
|
||||
Return user name and permissions:
|
||||
```python
|
||||
@auth.authenticate
|
||||
async def authenticate(
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, bytes]
|
||||
) -> Auth.types.MinimalUserDict:
|
||||
permissions, user = await verify_request(method, path, headers)
|
||||
# Permissions could be things like ["runs:read", "runs:write", "threads:read", "threads:write"]
|
||||
return {
|
||||
"identity": user["id"],
|
||||
"permissions": permissions,
|
||||
"display_name": user["name"],
|
||||
}
|
||||
```
|
||||
"""
|
||||
if self._authenticate_handler is not None:
|
||||
@@ -363,9 +465,58 @@ AHO = typing.TypeVar("AHO", bound=_ActionHandler[dict[str, typing.Any]])
|
||||
|
||||
|
||||
class _On:
|
||||
"""Entry point for authorization handlers that control access to specific resources.
|
||||
|
||||
The _On class provides a flexible way to define authorization rules for different resources
|
||||
and actions in your application. It supports three main usage patterns:
|
||||
|
||||
1. Global handlers that run for all resources and actions
|
||||
2. Resource-specific handlers that run for all actions on a resource
|
||||
3. Resource and action specific handlers for fine-grained control
|
||||
|
||||
Each handler must be an async function that accepts two parameters:
|
||||
- ctx (AuthContext): Contains request context and authenticated user info
|
||||
- value: The data being authorized (type varies by endpoint)
|
||||
|
||||
The handler should return one of:
|
||||
- None or True: Accept the request
|
||||
- False: Reject with 403 error
|
||||
- FilterType: Apply filtering rules to the response
|
||||
|
||||
???+ example "Examples"
|
||||
|
||||
Global handler for all requests:
|
||||
```python
|
||||
@auth.on
|
||||
async def log_all_requests(ctx: AuthContext, value: Any) -> None:
|
||||
print(f"Request to {ctx.path} by {ctx.user.identity}")
|
||||
return True
|
||||
```
|
||||
|
||||
Resource-specific handler:
|
||||
```python
|
||||
@auth.on.threads
|
||||
async def check_thread_access(ctx: AuthContext, value: Any) -> bool:
|
||||
# Allow access only to threads created by the user
|
||||
return value.get("created_by") == ctx.user.identity
|
||||
```
|
||||
|
||||
Resource and action specific handler:
|
||||
```python
|
||||
@auth.on.threads.delete
|
||||
async def prevent_thread_deletion(ctx: AuthContext, value: Any) -> bool:
|
||||
# Only admins can delete threads
|
||||
return "admin" in ctx.user.permissions
|
||||
```
|
||||
|
||||
Multiple resources or actions:
|
||||
```python
|
||||
@auth.on(resources=["threads", "runs"], actions=["create", "update"])
|
||||
async def rate_limit_writes(ctx: AuthContext, value: Any) -> bool:
|
||||
# Implement rate limiting for write operations
|
||||
return await check_rate_limit(ctx.user.identity)
|
||||
```
|
||||
"""
|
||||
Entry point for @auth.on decorators.
|
||||
Provides access to specific resources."""
|
||||
|
||||
__slots__ = (
|
||||
"_auth",
|
||||
@@ -420,7 +571,9 @@ class _On:
|
||||
return fn
|
||||
|
||||
# Used with parameters, return a decorator
|
||||
def decorator(handler: AHO) -> AHO:
|
||||
def decorator(
|
||||
handler: AHO,
|
||||
) -> AHO:
|
||||
if isinstance(resources, str):
|
||||
resource_list = [resources]
|
||||
else:
|
||||
|
||||
@@ -16,11 +16,6 @@ class HTTPException(Exception):
|
||||
headers (typing.Mapping[str, str] | None, optional): Additional HTTP headers to
|
||||
include in the error response.
|
||||
|
||||
Attributes:
|
||||
status_code (int): The HTTP status code of the error
|
||||
detail (str): The error message or description
|
||||
headers (typing.Mapping[str, str] | None): Additional HTTP headers
|
||||
|
||||
Example:
|
||||
Default:
|
||||
```python
|
||||
@@ -53,19 +48,9 @@ class HTTPException(Exception):
|
||||
self.headers = headers
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the HTTP exception.
|
||||
|
||||
Returns:
|
||||
str: A string in the format 'status_code: detail'
|
||||
"""
|
||||
return f"{self.status_code}: {self.detail}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a detailed string representation of the HTTP exception.
|
||||
|
||||
Returns:
|
||||
str: A string representation showing the class name and all attributes
|
||||
"""
|
||||
class_name = self.__class__.__name__
|
||||
return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
import typing_extensions
|
||||
|
||||
RunStatus = typing.Literal["pending", "error", "success", "timeout", "interrupted"]
|
||||
"""Status of a run execution.
|
||||
|
||||
@@ -59,23 +61,32 @@ FilterType = typing.Union[
|
||||
],
|
||||
typing.Dict[str, str],
|
||||
]
|
||||
"""Type for filtering queries.
|
||||
"""Response type for authorization handlers.
|
||||
|
||||
Supports exact matches and operators:
|
||||
- Simple match: {"field": "value"}
|
||||
- Equals: {"field": {"$eq": "value"}}
|
||||
- Exact match shorthand: {"field": "value"}
|
||||
- Exact match: {"field": {"$eq": "value"}}
|
||||
- Contains: {"field": {"$contains": "value"}}
|
||||
|
||||
???+ example "Examples"
|
||||
Simple exact match filter for the resource owner:
|
||||
```python
|
||||
# Simple match
|
||||
filter = {"status": "pending"}
|
||||
filter = {"owner": "user-abcd123"}
|
||||
```
|
||||
|
||||
# Equals operator
|
||||
filter = {"status": {"$eq": "success"}}
|
||||
Explicit version of the exact match filter:
|
||||
```python
|
||||
filter = {"owner": {"$eq": "user-abcd123"}}
|
||||
```
|
||||
|
||||
# Contains operator
|
||||
filter = {"metadata.tags": {"$contains": "important"}}
|
||||
Containment:
|
||||
```python
|
||||
filter = {"participants": {"$contains": "user-abcd123"}}
|
||||
```
|
||||
|
||||
Combining filters (treated as a logical `AND`):
|
||||
```python
|
||||
filter = {"owner": "user-abcd123", "participants": {"$contains": "user-efgh456"}}
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -107,9 +118,9 @@ Keys must be strings, values can be any JSON-serializable type.
|
||||
|
||||
HandlerResult = typing.Union[None, bool, FilterType]
|
||||
"""The result of a handler can be:
|
||||
- None | True: accept the request.
|
||||
- False: reject the request with a 403 error
|
||||
- FilterType: filter to apply
|
||||
* None | True: accept the request.
|
||||
* False: reject the request with a 403 error
|
||||
* FilterType: filter to apply
|
||||
"""
|
||||
|
||||
Handler = Callable[..., Awaitable[HandlerResult]]
|
||||
@@ -141,11 +152,20 @@ class MinimalUser(typing.Protocol):
|
||||
|
||||
|
||||
class MinimalUserDict(typing.TypedDict, total=False):
|
||||
"""The minimal user dictionary."""
|
||||
"""The dictionary representation of a user."""
|
||||
|
||||
identity: str
|
||||
identity: typing_extensions.Required[str]
|
||||
"""The required unique identifier for the user."""
|
||||
display_name: str
|
||||
"""The optional display name for the user."""
|
||||
is_authenticated: bool
|
||||
"""Whether the user is authenticated. Defaults to True."""
|
||||
permissions: Sequence[str]
|
||||
"""A list of permissions associated with the user.
|
||||
|
||||
You can use these in your `@auth.on` authorization logic to determine
|
||||
access permissions to different resources.
|
||||
"""
|
||||
|
||||
|
||||
@typing.runtime_checkable
|
||||
@@ -167,25 +187,79 @@ class BaseUser(typing.Protocol):
|
||||
"""The unique identifier for the user."""
|
||||
...
|
||||
|
||||
@property
|
||||
def permissions(self) -> Sequence[str]:
|
||||
"""The permissions associated with the user."""
|
||||
...
|
||||
|
||||
|
||||
class StudioUser:
|
||||
"""A user object that's populated from authenticated requests from the LangGraph studio.
|
||||
|
||||
Note: Studio auth can be disabled in your `langgraph.json` config.
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"disable_studio_auth": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can use `isinstance` checks in your authorization handlers (`@auth.on`) to control access specifically
|
||||
for developers accessing the instance from the LangGraph Studio UI.
|
||||
|
||||
???+ example "Examples"
|
||||
```python
|
||||
@auth.on
|
||||
async def allow_developers(ctx: Auth.types.AuthContext, value: Any) -> None:
|
||||
if isinstance(ctx.user, Auth.types.StudioUser):
|
||||
return None
|
||||
...
|
||||
return False
|
||||
```
|
||||
"""
|
||||
|
||||
__slots__ = ("username", "_is_authenticated", "_permissions")
|
||||
|
||||
def __init__(self, username: str, is_authenticated: bool = False) -> None:
|
||||
self.username = username
|
||||
self._is_authenticated = is_authenticated
|
||||
self._permissions = ["authenticated"] if is_authenticated else []
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self._is_authenticated
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def permissions(self) -> Sequence[str]:
|
||||
return self._permissions
|
||||
|
||||
|
||||
Authenticator = Callable[
|
||||
...,
|
||||
Awaitable[
|
||||
tuple[
|
||||
list[str],
|
||||
typing.Union[
|
||||
MinimalUser, str, MinimalUserDict, typing.Mapping[str, typing.Any]
|
||||
],
|
||||
]
|
||||
typing.Union[
|
||||
MinimalUser, str, BaseUser, MinimalUserDict, typing.Mapping[str, typing.Any]
|
||||
],
|
||||
],
|
||||
]
|
||||
"""Type for authentication functions.
|
||||
|
||||
An authenticator can return either:
|
||||
1. A tuple of (scopes, MinimalUser/BaseUser)
|
||||
2. A tuple of (scopes, str) where str is the user identity
|
||||
1. A string (user_id)
|
||||
2. A dict containing {"identity": str, "permissions": list[str]}
|
||||
3. An object with identity and permissions properties
|
||||
|
||||
Scopes can be used downstream by your authorization logic to determine
|
||||
Permissions can be used downstream by your authorization logic to determine
|
||||
access permissions to different resources.
|
||||
|
||||
The authenticate decorator will automatically inject any of the following parameters
|
||||
@@ -196,11 +270,10 @@ Parameters:
|
||||
body (dict): The parsed request body
|
||||
path (str): The request path
|
||||
method (str): The HTTP method (GET, POST, etc.)
|
||||
scopes (list[str]): The required scopes for this endpoint
|
||||
path_params (dict[str, str] | None): URL path parameters
|
||||
query_params (dict[str, str] | None): URL query parameters
|
||||
headers (dict[str, bytes] | None): Request headers
|
||||
authorization (str | None): The Authorization header value
|
||||
authorization (str | None): The Authorization header value (e.g. "Bearer <token>")
|
||||
|
||||
???+ example "Examples"
|
||||
Basic authentication with token:
|
||||
@@ -210,9 +283,8 @@ Parameters:
|
||||
auth = Auth()
|
||||
|
||||
@auth.authenticate
|
||||
async def authenticate1(authorization: str) -> tuple[list[str], MinimalUser]:
|
||||
user = await get_user(authorization)
|
||||
return ["read", "write"], user
|
||||
async def authenticate1(authorization: str) -> Auth.types.MinimalUserDict:
|
||||
return await get_user(authorization)
|
||||
```
|
||||
|
||||
Authentication with multiple parameters:
|
||||
@@ -222,17 +294,17 @@ Parameters:
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, bytes]
|
||||
) -> tuple[list[str], str]:
|
||||
) -> Auth.types.MinimalUserDict:
|
||||
# Custom auth logic using method, path and headers
|
||||
user_id = verify_request(method, path, headers)
|
||||
return ["read"], user_id
|
||||
user = verify_request(method, path, headers)
|
||||
return user
|
||||
```
|
||||
|
||||
Accepting the raw ASGI request:
|
||||
```python
|
||||
MY_SECRET = "my-secret-key"
|
||||
@auth.authenticate
|
||||
async def get_current_user(request: Request) -> tuple[list[str], dict]:
|
||||
async def get_current_user(request: Request) -> Auth.types.MinimalUserDict:
|
||||
try:
|
||||
token = (request.headers.get("authorization") or "").split(" ", 1)[1]
|
||||
payload = jwt.decode(token, MY_SECRET, algorithms=["HS256"])
|
||||
@@ -252,10 +324,11 @@ Parameters:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
user_data = response.json()
|
||||
return payload.get("role", []), {
|
||||
"username": user_data["id"],
|
||||
"email": user_data["email"],
|
||||
"full_name": user_data.get("user_metadata", {}).get("full_name")
|
||||
return {
|
||||
"identity": user_data["id"],
|
||||
"display_name": user_data.get("name"),
|
||||
"permissions": user_data.get("permissions", []),
|
||||
"is_authenticated": True,
|
||||
}
|
||||
```
|
||||
"""
|
||||
@@ -269,8 +342,8 @@ class BaseAuthContext:
|
||||
authorization decisions.
|
||||
"""
|
||||
|
||||
scopes: Sequence[str]
|
||||
"""The scopes granted to the authenticated user."""
|
||||
permissions: Sequence[str]
|
||||
"""The permissions granted to the authenticated user."""
|
||||
|
||||
user: BaseUser
|
||||
"""The authenticated user."""
|
||||
@@ -696,16 +769,18 @@ class on:
|
||||
```python
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
@Auth.on
|
||||
auth = Auth()
|
||||
|
||||
@auth.on
|
||||
def handle_all(params: Auth.on.value):
|
||||
raise Exception("Not authorized")
|
||||
|
||||
@Auth.on.threads.create
|
||||
@auth.on.threads.create
|
||||
def handle_thread_create(params: Auth.on.threads.create.value):
|
||||
# Handle thread creation
|
||||
pass
|
||||
|
||||
@Auth.on.assistants.search
|
||||
@auth.on.assistants.search
|
||||
def handle_assistant_search(params: Auth.on.assistants.search.value):
|
||||
# Handle assistant search
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.46"
|
||||
version = "0.1.48"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -6748,22 +6748,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.4.1"
|
||||
version = "6.4.2"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"},
|
||||
{file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"},
|
||||
{file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482"},
|
||||
{file = "tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38"},
|
||||
{file = "tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||