Compare commits

..
Author SHA1 Message Date
William Fu-Hinthorn 823ed55849 Add generics tests
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-21 15:05:19 -07:00
16 changed files with 96 additions and 468 deletions
@@ -1,7 +1,7 @@
# LangGraph Studio With Local Deployment
!!! warning "Browser Compatibility"
Safari blocks `localhost` connections to Studio. To work around this, start the server with `--tunnel` and youll be able to access Studio from Safari via a secure tunnel.
Viewing the studio page of a local LangGraph deployment does not work in Safari. Use Chrome instead.
## Setup
+3 -10
View File
@@ -10,6 +10,9 @@ The LangGraph command line interface includes commands to build and run a LangGr
=== "Python"
```bash
pip install langgraph-cli
# Install via Homebrew
brew install langgraph-cli
```
=== "JS"
@@ -295,11 +298,6 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code (added in `0.2.6`) |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers like Safari or networks blocking localhost connections |
| `--help` | | Display command documentation |
@@ -323,11 +321,6 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers or networks blocking localhost connections |
| `--help` | | Display command documentation |
### `build`
-3
View File
@@ -1,3 +0,0 @@
.safari {
color: #0070C9;
}
@@ -14,4 +14,3 @@ Errors referenced below will have an `lc_error_code` property corresponding to o
These guides provide troubleshooting information for errors that are specific to the LangGraph Platform.
- [INVALID_LICENSE](./INVALID_LICENSE.md)
- [Studio Errors](../studio.md)
-45
View File
@@ -1,45 +0,0 @@
# Troubleshooting LangGraph Studio
## :fontawesome-brands-safari:{ .safari } Safari connection error with local dev server
Safari blocks plainHTTP traffic on localhost. If you start Studio with a vanilla
`langgraph dev`, the page may report a "Failed to load assistants" error (or something similar) and the browser DevTools will show network errors.
#### Quick fix — run Studio through a secure Cloudflare tunnel
=== "Python"
```shell
pip install -U langgraph-cli>=0.2.6 # Python
langgraph dev --tunnel
```
=== "JS"
```shell
# Requires @langchain/langgraph-cli>=0.0.26
npx @langchain/langgraph-cli dev
```
The command prints a URL like:
```shell
https://smith.langchain.com/studio/?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
where
```shell
?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
indicates the endpoint where your agent server is exposed.
Open that URL in Safari and Studio should load immediately.
#### Alternative — use a Chromiumbased browser
Chrome, Edge, and Brave allow HTTP on localhost, so a plain `langgraph dev` should work without extra steps.
#### If its still not loading
1. Make sure the `baseUrl` query parameter in the studio URL points to the **tunnel URL** NOT to localhost.
2. Confirm your CLI version with `langgraph --version`.
No other configuration, certificates, or CORS tweaks are required.
+2 -4
View File
@@ -1,5 +1,5 @@
site_name: "LangGraph"
site_description: Build reliable, stateful AI systems, without giving up control
site_name: ""
site_description: Build language agents as graphs
site_url: https://langchain-ai.github.io/langgraph/
repo_url: https://github.com/langchain-ai/langgraph
edit_uri: edit/main/docs/docs/
@@ -400,7 +400,6 @@ nav:
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
- troubleshooting/errors/INVALID_CHAT_HISTORY.md
- troubleshooting/errors/INVALID_LICENSE.md
- troubleshooting/studio.md
- LangGraph Academy Course: https://academy.langchain.com/courses/intro-to-langgraph
- Agents:
@@ -550,4 +549,3 @@ copyright: >
Copyright &copy; 2025 LangChain, Inc | <a href="#__consent">Consent Preferences</a>
extra_css:
- stylesheets/version_admonitions.css
- stylesheets/logos.css
-10
View File
@@ -572,14 +572,6 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
help="Don't raise errors for synchronous I/O blocking operations in your code.",
default=False,
)
@click.option(
"--tunnel",
is_flag=True,
help="Expose the local server via a public tunnel (in this case, Cloudflare) "
"for remote frontend access. This avoids issues with browsers "
"or networks blocking localhost connections.",
default=False,
)
@cli.command(
"dev",
help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
@@ -596,7 +588,6 @@ def dev(
wait_for_client: bool,
studio_url: Optional[str],
allow_blocking: bool,
tunnel: bool,
):
"""CLI entrypoint for running the LangGraph API server."""
try:
@@ -664,7 +655,6 @@ def dev(
ui_config=config_json.get("ui_config"),
studio_url=studio_url,
allow_blocking=allow_blocking,
tunnel=tunnel,
)
+16 -16
View File
@@ -585,15 +585,15 @@ tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"]
[[package]]
name = "langchain-core"
version = "0.3.55"
version = "0.3.54"
description = "Building applications with LLMs through composability"
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langchain_core-0.3.55-py3-none-any.whl", hash = "sha256:b3cb36bf37755a616158a79866657c6697b43a2f7c69dd723ce425f1c76c1baa"},
{file = "langchain_core-0.3.55.tar.gz", hash = "sha256:0f2b3e311621116a83510c70b0ac9d959030a0a457a69483535cff18501fedc9"},
{file = "langchain_core-0.3.54-py3-none-any.whl", hash = "sha256:cd42155d9089e2fd4695ee02a4b2bc6daf55b9d4e1a37639647cf2455ed4fa04"},
{file = "langchain_core-0.3.54.tar.gz", hash = "sha256:55ce38939038e19b1271f36f512335462d7f64057b531598b3651d2b403e1b42"},
]
[package.dependencies]
@@ -630,15 +630,15 @@ xxhash = ">=3.5.0,<4.0.0"
[[package]]
name = "langgraph-api"
version = "0.1.12"
version = "0.1.9"
description = ""
optional = true
python-versions = "<4.0,>=3.11.0"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_api-0.1.12-py3-none-any.whl", hash = "sha256:0f9417052ac75f6da892902083b7cf6a515bee12dd035cfbc3f0ddb813738830"},
{file = "langgraph_api-0.1.12.tar.gz", hash = "sha256:1646a904121a5dc84cece6a81b9c49693ccfbd6f1a2904e0ed7aa7eb711e64fc"},
{file = "langgraph_api-0.1.9-py3-none-any.whl", hash = "sha256:f84b11b1855e68dbef9f0a78db803e325b8dc11e2e19613178ab84cb0d99627c"},
{file = "langgraph_api-0.1.9.tar.gz", hash = "sha256:3530d82e715b9f99eeb8753c365f4d16c99ce60533fa15530d5ad1d493aeec06"},
]
[package.dependencies]
@@ -712,15 +712,15 @@ blockbuster = ">=1.5.24,<2.0.0"
[[package]]
name = "langgraph-sdk"
version = "0.1.63"
version = "0.1.61"
description = "SDK for interacting with LangGraph API"
optional = true
python-versions = "<4.0.0,>=3.9.0"
groups = ["main"]
markers = "python_version >= \"3.11\""
files = [
{file = "langgraph_sdk-0.1.63-py3-none-any.whl", hash = "sha256:6fb78a7fc6a30eea43bd0d6401dbc9e3263d0d4c03f63c04035980da7e586b05"},
{file = "langgraph_sdk-0.1.63.tar.gz", hash = "sha256:62bf2cc31e5aa6c5b9011ee1702bcf1e36e67e142a60bd97af2611162fb58e18"},
{file = "langgraph_sdk-0.1.61-py3-none-any.whl", hash = "sha256:f2d774b12497c428862993090622d51e0dbc3f53e0cee3d74a13c7495d835cc6"},
{file = "langgraph_sdk-0.1.61.tar.gz", hash = "sha256:87dd1f07ab82da8875ac343268ece8bf5414632017ebc9d1cef4b523962fd601"},
]
[package.dependencies]
@@ -729,15 +729,15 @@ orjson = ">=3.10.1"
[[package]]
name = "langsmith"
version = "0.3.33"
version = "0.3.32"
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langsmith-0.3.33-py3-none-any.whl", hash = "sha256:6fa453942014945e1de7e283880ed3e8031b5d84e0dc75b87d101ecedb62371b"},
{file = "langsmith-0.3.33.tar.gz", hash = "sha256:0f439e945528c6d14140137b918cc048aea04c6a987525926dbfda2560002924"},
{file = "langsmith-0.3.32-py3-none-any.whl", hash = "sha256:d79af299038cd13db6d53f99fdc6a7171b731702536b7635f2da132555a6bebc"},
{file = "langsmith-0.3.32.tar.gz", hash = "sha256:3d7b1149e9fbe0f388303bc94d8deeeb822acc89cdea34f24151ea1316eb487d"},
]
[package.dependencies]
@@ -1611,15 +1611,15 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.34.2"
version = "0.34.1"
description = "The lightning-fast ASGI server."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"},
{file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
{file = "uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065"},
{file = "uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc"},
]
[package.dependencies]
@@ -2011,4 +2011,4 @@ inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
[metadata]
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "6f3f275ae70749922db5bd1105711fbb0f8ac3b215982f7c75861c93d956e95d"
content-hash = "afc2f8776b4b6144bd1197df49ba34089889e2a1110b8470d8f1b212e0b08380"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.2.6"
version = "0.2.5"
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.1.12,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
+23 -147
View File
@@ -5,25 +5,23 @@ from inspect import isclass
from typing import (
Any,
Callable,
Hashable,
Optional,
Type,
TypeVar,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel, Discriminator
from pydantic.fields import FieldInfo
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated, Literal
from typing_extensions import Annotated
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]] = (
weakref.WeakKeyDictionary()
)
@@ -63,9 +61,7 @@ class SchemaCoercionMapper:
self.type_hints = (
type_hints
if type_hints is not None
else get_type_hints(
schema, localns={schema.__name__: schema}, include_extras=True
)
else get_type_hints(schema, localns={schema.__name__: schema})
)
if issubclass(schema, BaseModelV1):
@@ -131,38 +127,15 @@ class SchemaCoercionMapper:
if depth == 0:
return self._passthrough
field_type, metadata = self._unwrap_annotated(field_type)
origin = get_origin(field_type)
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
return self._passthrough
if isinstance(field_type, TypeVar):
concrete = self.type_hints.get(field_type) # type: ignore
if concrete is not None:
return self._build_coercer(concrete, depth - 1)
return self._passthrough
if hasattr(field_type, "__parameters__") and hasattr(
field_type, "model_fields"
):
try:
type_hints = self.resolve_concrete_type_hints(field_type)
def generic_model_coercer(v: Any, d: int) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError(
f"Expected dict for {field_type}, got {type(v)}"
)
return v
mapper = SchemaCoercionMapper(field_type, type_hints, max_depth=d)
return mapper.coerce(v, d)
return generic_model_coercer
except Exception as e:
logger.debug(f"Generic type resolution failed: {e}")
return self._passthrough
if origin is Annotated:
real_type, *_ = get_args(field_type)
sub = self._build_coercer(real_type, depth - 1)
return lambda v, d: sub(v, d)
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
@@ -207,7 +180,6 @@ class SchemaCoercionMapper:
return {sub(x, d - 1) for x in v}
return set_coercer
if origin is dict or field_type is dict:
args = get_args(field_type)
if len(args) != 2:
@@ -246,75 +218,27 @@ class SchemaCoercionMapper:
)
if origin is Union:
args = get_args(field_type)
discriminator_key = self._extract_discriminator_key(metadata)
none_in_union = False
discriminator_map = {}
for arg in args:
uargs = get_args(field_type)
subs, none_in_union = [], False
for ix, arg in enumerate(uargs):
if arg is type(None):
none_in_union = True
continue
base_type = arg
if get_origin(arg) is Annotated:
base_type, _ = get_args(arg)[0], get_args(arg)[1:]
try:
hint = get_type_hints(base_type)
lit = hint.get(discriminator_key)
if get_origin(lit) is Literal:
for val in get_args(lit):
discriminator_map[val] = base_type
except Exception as e:
if throw:
raise e
else:
logger.debug(f"Failed to extract discriminator: {e}")
else:
subs.append(
self._build_coercer(arg, depth - 1, throw=ix < len(uargs) - 1)
)
def union_coercer(v: Any, d: Any) -> Any:
if v is None and none_in_union:
return None
tag = None
if callable(discriminator_key):
err = None
for sp in subs:
try:
tag = discriminator_key(v)
except Exception as e:
logger.debug(f"Failed to call discriminator func: {e}")
elif (
isinstance(v, dict)
and isinstance(discriminator_key, str)
and discriminator_key in v
):
tag = v[discriminator_key]
if tag is not None:
for arg in args:
base_type = arg
if get_origin(arg) is Annotated:
base_type, _ = get_args(arg)[0], get_args(arg)[1:]
try:
if issubclass(base_type, (BaseModel, BaseModelV1)):
return SchemaCoercionMapper(
base_type, max_depth=d
).coerce(v, d)
except Exception as e:
logger.debug(
f"Coercion with {base_type} failed for tag={tag}: {e}"
)
continue
# fallback: try coercing each branch
for arg in args:
try:
sub = self._build_coercer(arg, d - 1)
return sub(v, d - 1)
except Exception as e:
if throw:
raise e
else:
logger.debug(f"Fallback coercion failed for arg={arg}: {e}")
return sp(v, d - 1)
except TypeError as e:
err = e
if err:
raise err
return v
return union_coercer
@@ -326,58 +250,10 @@ class SchemaCoercionMapper:
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
return v
@staticmethod
def _extract_discriminator_key(meta: list[Any]) -> str | Callable[[Any], Hashable]:
"""Extract discriminator field name or function from Annotated metadata"""
for m in meta:
if isinstance(m, FieldInfo):
disc = getattr(m, "discriminator", None)
if isinstance(disc, Discriminator):
return disc.discriminator
elif isinstance(disc, str):
return disc
return "type"
@staticmethod
def _unwrap_annotated(tp: Any) -> tuple[Any, list[Any]]:
"""Unwrap nested Annotated types, extracting the base type and all metadata"""
metadata = []
while get_origin(tp) is Annotated:
tp, *meta = get_args(tp)
metadata.extend(meta)
return tp, metadata
@staticmethod
def resolve_concrete_type_hints(generic_model_type: Any) -> dict[Any, Any]:
"""Resolve concrete type hints in a generic model"""
origin = get_origin(generic_model_type)
args = get_args(generic_model_type)
param_names = getattr(origin, "__parameters__", [])
if not args or not param_names:
return {}
type_map = dict(zip(param_names, args))
result = {}
for field_name, model_field in origin.model_fields.items():
anno = model_field.annotation
if get_origin(anno) is Annotated:
base, *meta = get_args(anno)
if isinstance(base, TypeVar) and base in type_map:
result[field_name] = Annotated[type_map[base], *meta]
else:
result[field_name] = anno
elif isinstance(anno, TypeVar) and anno in type_map:
result[field_name] = type_map[anno]
else:
result[field_name] = anno
return result
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
_IDENTITY_TYPES: tuple[type[Any], ...] = (
int,
float,
+44 -23
View File
@@ -24,12 +24,14 @@ from typing import (
Any,
Dict,
Generator,
Generic,
Iterator,
List,
Literal,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
get_type_hints,
)
@@ -37,11 +39,7 @@ from typing import (
import httpx
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.runnables import (
RunnableConfig,
RunnableLambda,
RunnablePassthrough,
)
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.runnables.graph import Edge
from langsmith import traceable
from pytest_mock import MockerFixture
@@ -3109,10 +3107,10 @@ def test_nested_pydantic_models(version: str) -> None:
# Import necessary modules
if version == "v1":
from pydantic.v1 import ( # type: ignore
from pydantic.v1 import (
BaseModel,
ByteSize,
Field,
Field, # type: ignore
SecretStr,
confloat,
conint,
@@ -3120,13 +3118,11 @@ def test_nested_pydantic_models(version: str) -> None:
constr,
)
else:
from pydantic import ( # type: ignore
from pydantic import (
BaseModel,
ByteSize,
Discriminator,
Field,
Field, # type: ignore
SecretStr,
Tag,
confloat,
conint,
conlist,
@@ -3175,16 +3171,6 @@ def test_nested_pydantic_models(version: str) -> None:
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
else:
conlist_type = conlist(item_type=int, min_items=2, max_items=5)
if version == "v2":
FuncDiscriminatorPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator=Discriminator(lambda obj: obj.get("pet_type"))),
]
else:
FuncDiscriminatorPet = Union[Dog, Cat]
class State(BaseModel):
# Basic nested model tests
@@ -3224,7 +3210,6 @@ def test_nested_pydantic_models(version: str) -> None:
pattern: re.Pattern
secret: SecretStr
file_size: ByteSize
discriminated_pet: FuncDiscriminatorPet
# Constrained types
positive_value: PositiveInt
@@ -3296,7 +3281,6 @@ def test_nested_pydantic_models(version: str) -> None:
"pattern": "^test$",
"secret": "password123",
"file_size": 1024,
"discriminated_pet": {"pet_type": "cat", "meow": "indubitably"},
# Constrained types
"positive_value": 42,
"non_negative": 0.0,
@@ -3437,6 +3421,43 @@ def test_pydantic_v1_state_root_validator():
assert res["text"] == "Hello, Validated John!"
def test_pydantic_generics():
from pydantic import BaseModel
class A(BaseModel):
a: str
class B(BaseModel):
b: str
AorB = TypeVar("AorB", A, B)
class C(BaseModel, Generic[AorB]):
c: AorB
class State(BaseModel):
text: str
count: int
c: C[A]
input_state = {"text": "1", "count": 0, "c": {"c": {"a": "1"}}}
expected_input = State.model_validate(input_state)
def process_node(state: State):
assert state == expected_input
new_text = ", the type of c is " + str(type(state.c.c))
return {"text": state.text + new_text, "count": state.count + 1}
builder = StateGraph(State)
builder.add_node("process", process_node)
builder.add_edge(START, "process")
g = builder.compile()
g.invoke(input_state)
g.invoke(expected_input)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_in_one_fan_out_state_graph_waiting_edge_plus_regular(
request: pytest.FixtureRequest, checkpointer_name: str
@@ -1,197 +0,0 @@
from typing import Dict, Generic, List, Literal, Optional, Set, Tuple, TypeVar, Union
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from pydantic import BaseModel, Discriminator, Field, Tag
from typing_extensions import Annotated
from langgraph.graph.schema_utils import SchemaCoercionMapper
def test_any_message():
class MyMessage(BaseModel):
msg: List[AnyMessage]
data = {
"msg": [
{"type": "human", "content": "Hello"},
{"type": "ai", "content": "Hi there!"},
]
}
MyMessage.model_validate(data)
mapper = SchemaCoercionMapper(MyMessage)
result = mapper(data)
assert isinstance(result, MyMessage)
assert isinstance(result.msg, list)
assert len(result.msg) == 2
assert isinstance(result.msg[0], (HumanMessage))
assert isinstance(result.msg[1], (AIMessage))
# ==== 基础模型 ====
class SimpleModel(BaseModel):
name: str
age: int
def test_simple_model():
data = {"name": "Alice", "age": 30}
mapper = SchemaCoercionMapper(SimpleModel)
result = mapper(data)
assert isinstance(result, SimpleModel)
assert result.name == "Alice"
assert result.age == 30
# ==== 容器类型 ====
class ContainerModel(BaseModel):
items: List[int]
mapping: Dict[str, float]
tags: Set[str]
coords: Tuple[int, int]
def test_container_model():
data = {
"items": [1, 2, 3],
"mapping": {"a": 1.1},
"tags": ["x", "y"],
"coords": [10, 20],
}
mapper = SchemaCoercionMapper(ContainerModel)
result = mapper(data)
assert isinstance(result.items, list)
assert isinstance(result.mapping, dict)
assert isinstance(result.tags, set)
assert isinstance(result.coords, tuple)
# ==== 泛型 ====
T = TypeVar("T")
class Wrapper(BaseModel, Generic[T]):
value: T
def test_generic_model():
class IntWrapper(Wrapper[int]):
pass
data = {"value": 123}
mapper = SchemaCoercionMapper(IntWrapper)
result = mapper(data)
assert result.value == 123
# ==== Union 类型 ====
class Dog(BaseModel):
type: Literal["dog"]
age: int
class Cat(BaseModel):
type: Literal["cat"]
name: str
Pet = Union[Dog, Cat]
class Owner(BaseModel):
pet: Pet
def test_union_type():
data = {"pet": {"type": "dog", "age": 5}}
mapper = SchemaCoercionMapper(Owner)
result = mapper(data)
assert isinstance(result.pet, Dog)
# ==== Annotated + Tag + discriminator ====
TaggedPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator="type"),
]
class TaggedOwner(BaseModel):
pet: TaggedPet
def test_tagged_union():
data = {"pet": {"type": "cat", "name": "Mimi"}}
mapper = SchemaCoercionMapper(TaggedOwner)
result = mapper(data)
assert isinstance(result.pet, Cat)
# ==== Annotated + Field(discriminator=Discriminator(func)) ====
def _get_type(obj):
return obj.get("type")
FuncDiscriminatorPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator=Discriminator(_get_type)),
]
class FuncOwner(BaseModel):
pet: FuncDiscriminatorPet
def test_func_discriminator():
data = {"pet": {"type": "dog", "age": 9}}
mapper = SchemaCoercionMapper(FuncOwner)
result = mapper(data)
assert isinstance(result.pet, Dog)
# ==== Optional + 泛型 + 多态嵌套 ====
class Box(BaseModel, Generic[T]):
content: Optional[T]
class Crate(BaseModel, Generic[T]):
payload: Box[T]
class Zoo(BaseModel):
animal: Box[TaggedPet]
class Warehouse(BaseModel):
cage: Crate[TaggedPet]
def test_nested_optional_generic_union():
# Box[TaggedPet]
data1 = {"animal": {"content": {"type": "cat", "name": "Kitty"}}}
mapper1 = SchemaCoercionMapper(Zoo)
result1 = mapper1(data1)
assert isinstance(result1.animal.content, Cat)
# Crate[TaggedPet]
data2 = {"cage": {"payload": {"content": {"type": "dog", "age": 8}}}}
mapper2 = SchemaCoercionMapper(Warehouse)
result2 = mapper2(data2)
assert isinstance(result2.cage.payload.content, Dog)
# Optional None
data3 = {"animal": {"content": None}}
result3 = mapper1(data3)
assert result3.animal.content is None
# deeply nested Optional
data4 = {"cage": {"payload": {"content": None}}}
result4 = mapper2(data4)
assert result4.cage.payload.content is None
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.70",
"version": "0.0.69",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
+1 -1
View File
@@ -621,7 +621,7 @@ export class ThreadsClient<
/**
* Sort by.
*/
sortBy?: "thread_id" | "status" | "created_at" | "updated_at";
sortBy?: "id" | "status" | "created_at" | "updated_at";
/**
* Sort order.
* Must be one of 'asc' or 'desc'.
+2 -6
View File
@@ -1043,10 +1043,8 @@ class ThreadsClient:
status: Optional[ThreadStatus] = None,
limit: int = 10,
offset: int = 0,
sort_by: Optional[
Literal["thread_id", "status", "created_at", "updated_at"]
] = None,
sort_order: Optional[Literal["asc", "desc"]] = None,
sort_by: Literal["id", "status", "created_at", "updated_at"] = "created_at",
sort_order: Literal["asc", "desc"] = "asc",
headers: Optional[dict[str, str]] = None,
) -> list[Thread]:
"""Search for threads.
@@ -1058,8 +1056,6 @@ class ThreadsClient:
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
limit: Limit on number of threads to return.
offset: Offset in threads table to start search from.
sort_by: Sort by field.
sort_order: Sort order.
headers: Optional custom headers to include with the request.
Returns:
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-sdk"
version = "0.1.63"
version = "0.1.62"
description = "SDK for interacting with LangGraph API"
authors = []
license = "MIT"