Compare commits

..

12 Commits

Author SHA1 Message Date
Eugene Yurtsev 6e7a9ee3f5 0.3.0.dev1 release (#748) 2024-09-09 16:09:34 -04:00
Eugene Yurtsev 81c0285af2 update default playground to handle oneOf type (#747)
Update the default playground to handle the oneOf type which appears as
the input into language models.

This change stems from the upgrade to pydantic 2.
2024-09-09 16:06:36 -04:00
Eugene Yurtsev b62b925825 chat-playground: update to handle oneOf (resulted from pydantic upgrade) (#746)
Update the chat playground to handle the oneOf type which appears as the
input into language models.

This change stems from the upgrade to pydantic 2.
2024-09-09 16:06:29 -04:00
Eugene Yurtsev b528955b60 migrate examples to pydantic 2 (#745)
Migrate examples to pydantic 2
2024-09-09 11:03:26 -04:00
Eugene Yurtsev 5aedbf7083 upgrade to pydantic 2 (#744)
This PR upgrades langserve to pydantic 2.

* Added a failing unit test that has 2 known failures (that need to be
fixed in langchain-core)
* Deprecation warnings will be resolved separately.
2024-09-09 10:23:16 -04:00
Eugene Yurtsev 41a9d798aa Update unit tests to catch up with langchain-core (#743)
Updating the unit tests to catch up with langchain-core. No adjustements
should be necessary to user code, the issues manifest themselves only with the
given test set up (e.g., snapshots). langchain-core changes were either
additive or self-consistent.
2024-09-06 13:55:23 -04:00
Erick Friis d4704c2b45 infra: release permissions (#738) 2024-09-01 22:09:28 -04:00
Eugene Yurtsev c259ec3e4d Release 0.2.3 (#737) 2024-09-01 21:55:23 -04:00
William FH 62e648a2bf Support correction when creating feedback with token (#736)
Closes https://github.com/langchain-ai/langserve/issues/735

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2024-09-01 21:43:50 -04:00
Eugene Yurtsev a74e072486 Add langgraph compatibility section (#718) 2024-07-26 14:29:38 -04:00
Erick Friis 1487bf1ce5 Add instructions to address pydantic v2 incompatibility (#713) 2024-07-22 14:40:03 -04:00
ccurme 050a0cc674 update readme (#697) 2024-06-28 11:02:32 -04:00
41 changed files with 2231 additions and 2238 deletions
@@ -1,94 +0,0 @@
name: pydantic v1/v2 compatibility
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.5.1"
jobs:
build:
timeout-minutes: 10
defaults:
run:
working-directory: ${{ inputs.working-directory }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
name: Pydantic v1/v2 compatibility - Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }}
uses: "./.github/actions/poetry_setup"
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ env.POETRY_VERSION }}
working-directory: ${{ inputs.working-directory }}
cache-key: pydantic-cross-compat
- name: Install dependencies
shell: bash
run: poetry install
- name: Install the opposite major version of pydantic
# If normal tests use pydantic v1, here we'll use v2, and vice versa.
shell: bash
run: |
# Determine the major part of pydantic version
REGULAR_VERSION=$(poetry run python -c "import pydantic; print(pydantic.__version__)" | cut -d. -f1)
if [[ "$REGULAR_VERSION" == "1" ]]; then
PYDANTIC_DEP=">=2.1,<3"
TEST_WITH_VERSION="2"
elif [[ "$REGULAR_VERSION" == "2" ]]; then
PYDANTIC_DEP="<2"
TEST_WITH_VERSION="1"
else
echo "Unexpected pydantic major version '$REGULAR_VERSION', cannot determine which version to use for cross-compatibility test."
exit 1
fi
# Install via `pip` instead of `poetry add` to avoid changing lockfile,
# which would prevent caching from working: the cache would get saved
# to a different key than where it gets loaded from.
poetry run pip install "pydantic${PYDANTIC_DEP}"
# Ensure that the correct pydantic is installed now.
echo "Checking pydantic version... Expecting ${TEST_WITH_VERSION}"
# Determine the major part of pydantic version
CURRENT_VERSION=$(poetry run python -c "import pydantic; print(pydantic.__version__)" | cut -d. -f1)
# Check that the major part of pydantic version is as expected, if not
# raise an error
if [[ "$CURRENT_VERSION" != "$TEST_WITH_VERSION" ]]; then
echo "Error: expected pydantic version ${CURRENT_VERSION} to have been installed, but found: ${TEST_WITH_VERSION}"
exit 1
fi
echo "Found pydantic version ${CURRENT_VERSION}, as expected"
- name: Run pydantic compatibility tests
shell: bash
run: make test
- name: Ensure the tests did not create any additional files
shell: bash
run: |
set -eu
STATUS="$(git status)"
echo "$STATUS"
# grep will exit non-zero if the target message isn't found,
# and `set -e` above will cause the step to fail.
echo "$STATUS" | grep 'nothing to commit, working tree clean'
-7
View File
@@ -39,13 +39,6 @@ jobs:
with:
working-directory: .
secrets: inherit
pydantic-compatibility:
uses:
./.github/workflows/_pydantic_compatibility.yml
with:
working-directory: .
secrets: inherit
test:
timeout-minutes: 10
runs-on: ubuntu-latest
+1
View File
@@ -10,4 +10,5 @@ jobs:
./.github/workflows/_release.yml
with:
working-directory: .
permissions: write-all
secrets: inherit
+8 -12
View File
@@ -5,10 +5,6 @@
[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langserve)](https://github.com/langchain-ai/langserve/issues)
[![](https://dcbadge.vercel.app/api/server/6adMQxSpJS?compact=true&style=flat)](https://discord.com/channels/1038097195422978059/1170024642245832774)
🚩 We will be releasing a hosted version of LangServe for one-click deployments of
LangChain applications. [Sign up here](https://forms.gle/KC13Nzn76UeLaghK7)
to get on the waitlist.
## Overview
[LangServe](https://github.com/langchain-ai/langserve) helps developers
@@ -42,6 +38,13 @@ in [LangChain.js](https://js.langchain.com/docs/ecosystem/langserve).
locally (or call the HTTP API directly)
- [LangServe Hub](https://github.com/langchain-ai/langchain/blob/master/templates/README.md)
## ⚠️ LangGraph Compatibility
LangServe is designed to primarily deploy simple Runnables and wok with well-known primitives in langchain-core.
If you need a deployment option for LangGraph, you should instead be looking at [LangGraph Cloud (beta)](https://langchain-ai.github.io/langgraph/cloud/) which will
be better suited for deploying LangGraph applications.
## Limitations
- Client callbacks are not yet supported for events that originate on the server
@@ -49,13 +52,6 @@ in [LangChain.js](https://js.langchain.com/docs/ecosystem/langserve).
support [mixing pydantic v1 and v2 namespaces](https://github.com/tiangolo/fastapi/issues/10360).
See section below for more details.
## Hosted LangServe
We will be releasing a hosted version of LangServe for one-click deployments of
LangChain
applications. [Sign up here](https://forms.gle/KC13Nzn76UeLaghK7)
to get on the waitlist.
## Security
- Vulnerability in Versions 0.0.13 - 0.0.15 -- playground endpoint allows accessing
@@ -479,7 +475,7 @@ gcloud run deploy [your-service-name] --source . --port 8001 --allow-unauthentic
LangServe provides support for Pydantic 2 with some limitations.
1. OpenAPI docs will not be generated for invoke/batch/stream/stream_log when using
Pydantic V2. Fast API does not support [mixing pydantic v1 and v2 namespaces].
Pydantic V2. Fast API does not support [mixing pydantic v1 and v2 namespaces]. To fix this, use `pip install pydantic==1.10.17`.
2. LangChain uses the v1 namespace in Pydantic v2. Please read
the [following guidelines to ensure compatibility with LangChain](https://github.com/langchain-ai/langchain/discussions/9337)
+1 -1
View File
@@ -23,12 +23,12 @@ from fastapi import FastAPI
from langchain.agents import AgentExecutor
from langchain.agents.format_scratchpad import format_to_openai_functions
from langchain.agents.output_parsers import OpenAIFunctionsAgentOutputParser
from langchain.pydantic_v1 import BaseModel
from langchain_community.vectorstores import FAISS
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.tools import tool
from langchain_core.utils.function_calling import format_tool_to_openai_function
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from pydantic import BaseModel
from langserve import add_routes
+1 -1
View File
@@ -57,9 +57,9 @@ from langchain_core.runnables import RunnableLambda
from langchain_core.tools import tool
from langchain_core.utils.function_calling import format_tool_to_openai_tool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel
prompt = ChatPromptTemplate.from_messages(
[
+1 -1
View File
@@ -36,9 +36,9 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.tools import tool
from langchain_core.utils.function_calling import format_tool_to_openai_tool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
prompt = ChatPromptTemplate.from_messages(
[
+4 -5
View File
@@ -44,10 +44,10 @@ from langchain_core.runnables import (
)
from langchain_core.vectorstores import VectorStore
from langchain_openai import OpenAIEmbeddings
from pydantic import BaseModel, ConfigDict
from typing_extensions import Annotated
from langserve import APIHandler
from langserve.pydantic_v1 import BaseModel
class User(BaseModel):
@@ -150,10 +150,9 @@ class PerUserVectorstore(RunnableSerializable):
user_id: Optional[str]
vectorstore: VectorStore
class Config:
# Allow arbitrary types since VectorStore is an abstract interface
# and not a pydantic model
arbitrary_types_allowed = True
model_config = ConfigDict(
arbitrary_types_allowed=True,
)
def _invoke(
self, input: str, config: Optional[RunnableConfig] = None, **kwargs: Any
@@ -45,10 +45,10 @@ from langchain_core.runnables import (
)
from langchain_core.vectorstores import VectorStore
from langchain_openai import OpenAIEmbeddings
from pydantic import BaseModel, ConfigDict
from typing_extensions import Annotated
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel
class User(BaseModel):
@@ -147,10 +147,9 @@ class PerUserVectorstore(RunnableSerializable):
user_id: Optional[str]
vectorstore: VectorStore
class Config:
# Allow arbitrary types since VectorStore is an abstract interface
# and not a pydantic model
arbitrary_types_allowed = True
model_config = ConfigDict(
arbitrary_types_allowed=True,
)
def _invoke(
self, input: str, config: Optional[RunnableConfig] = None, **kwargs: Any
@@ -8,9 +8,9 @@ from fastapi import FastAPI
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
app = FastAPI(
title="LangChain Server",
+1 -1
View File
@@ -8,9 +8,9 @@ from fastapi import FastAPI
from langchain_anthropic.chat_models import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
app = FastAPI(
title="LangChain Server",
+1 -1
View File
@@ -18,9 +18,9 @@ from langchain_community.chat_message_histories import FileChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
def _is_valid_identifier(value: str) -> bool:
@@ -20,7 +20,6 @@ from fastapi import FastAPI
from langchain.agents import AgentExecutor
from langchain.agents.format_scratchpad import format_to_openai_functions
from langchain.agents.output_parsers import OpenAIFunctionsAgentOutputParser
from langchain.pydantic_v1 import BaseModel
from langchain_community.vectorstores import FAISS
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import (
@@ -33,6 +32,7 @@ from langchain_core.runnables.utils import Input, Output
from langchain_core.tools import tool
from langchain_core.utils.function_calling import format_tool_to_openai_function
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from pydantic import BaseModel
from langserve import add_routes
+1 -1
View File
@@ -15,9 +15,9 @@ from langchain_core.runnables import (
)
from langchain_core.vectorstores import VectorStore
from langchain_openai import OpenAIEmbeddings
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
vectorstore1 = FAISS.from_texts(
["cats like fish", "dogs like sticks"], embedding=OpenAIEmbeddings()
@@ -18,9 +18,9 @@ from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate, format_document
from langchain_core.runnables import RunnableMap, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
_TEMPLATE = """Given the following conversation and a follow up question, rephrase the
follow up question to be a standalone question, in its original language.
+1 -1
View File
@@ -15,10 +15,10 @@ allowing one to upload a binary file using the langserve playground UI.
import base64
from fastapi import FastAPI
from langchain.pydantic_v1 import Field
from langchain_community.document_loaders.parsers.pdf import PDFMinerParser
from langchain_core.document_loaders import Blob
from langchain_core.runnables import RunnableLambda
from pydantic import Field
from langserve import CustomUserType, add_routes
+1 -1
View File
@@ -10,9 +10,9 @@ from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from pydantic import BaseModel, Field
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
app = FastAPI(
title="LangChain Server",
+1 -1
View File
@@ -6,7 +6,6 @@ from typing import Any, Dict, List, Tuple
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from langchain.pydantic_v1 import BaseModel, Field
from langchain_community.document_loaders.parsers.pdf import PDFMinerParser
from langchain_core.document_loaders import Blob
from langchain_core.messages import (
@@ -17,6 +16,7 @@ from langchain_core.messages import (
)
from langchain_core.runnables import RunnableLambda, RunnableParallel
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from langserve import CustomUserType
from langserve.server import add_routes
+53
View File
@@ -0,0 +1,53 @@
from typing import Any, Dict, Type, cast
from pydantic import BaseModel, ConfigDict, RootModel
from pydantic.json_schema import (
DEFAULT_REF_TEMPLATE,
GenerateJsonSchema,
JsonSchemaMode,
)
def _create_root_model(name: str, type_: Any) -> Type[RootModel]:
"""Create a base class."""
def schema(
cls: Type[BaseModel],
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
) -> Dict[str, Any]:
# Complains about schema not being defined in superclass
schema_ = super(cls, cls).schema( # type: ignore[misc]
by_alias=by_alias, ref_template=ref_template
)
schema_["title"] = name
return schema_
def model_json_schema(
cls: Type[BaseModel],
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema,
mode: JsonSchemaMode = "validation",
) -> Dict[str, Any]:
# Complains about model_json_schema not being defined in superclass
schema_ = super(cls, cls).model_json_schema( # type: ignore[misc]
by_alias=by_alias,
ref_template=ref_template,
schema_generator=schema_generator,
mode=mode,
)
schema_["title"] = name
return schema_
base_class_attributes = {
"__annotations__": {"root": type_},
"model_config": ConfigDict(arbitrary_types_allowed=True),
"schema": classmethod(schema),
"model_json_schema": classmethod(model_json_schema),
# Should replace __module__ with caller based on stack frame.
"__module__": "langserve._pydantic",
}
custom_root_type = type(name, (RootModel,), base_class_attributes)
return cast(Type[RootModel], custom_root_type)
+34 -31
View File
@@ -29,6 +29,7 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from langchain_core._api.beta_decorator import warn_beta
from langchain_core.callbacks.base import AsyncCallbackHandler
from langchain_core.callbacks.manager import BaseCallbackManager
from langchain_core.load.serializable import Serializable
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.runnables.config import (
@@ -40,14 +41,15 @@ from langchain_core.tracers import RunLogPatch
from langsmith import client as ls_client
from langsmith.schemas import FeedbackIngestToken
from langsmith.utils import tracing_is_enabled
from pydantic import BaseModel, Field, RootModel, ValidationError, create_model
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from typing_extensions import TypedDict
from langserve._pydantic import _create_root_model
from langserve.callbacks import AsyncEventAggregatorCallback, CallbackEventDict
from langserve.lzstring import LZString
from langserve.playground import serve_playground
from langserve.pydantic_v1 import BaseModel, Field, ValidationError, create_model
from langserve.schema import (
BatchResponseMetadata,
CustomUserType,
@@ -255,10 +257,12 @@ def _update_config_with_defaults(
}
metadata.update(hosted_metadata)
non_overridable_default_config = RunnableConfig(
run_name=run_name,
metadata=metadata,
)
non_overridable_default_config: RunnableConfig = {
"metadata": metadata,
}
if run_name:
non_overridable_default_config["run_name"] = run_name
# merge_configs is last-writer-wins, so we specifically pass in the
# overridable configs first, then the user provided configs, then
@@ -279,8 +283,8 @@ def _update_config_with_defaults(
def _unpack_input(validated_model: BaseModel) -> Any:
"""Unpack the decoded input from the validated model."""
if hasattr(validated_model, "__root__"):
model = validated_model.__root__
if isinstance(validated_model, RootModel):
model = validated_model.root
else:
model = validated_model
@@ -304,7 +308,7 @@ def _rename_pydantic_model(model: Type[BaseModel], prefix: str) -> Type[BaseMode
"""Rename the given pydantic model to the given name."""
return create_model(
prefix + model.__name__,
__config__=model.__config__,
__config__=model.model_config,
**{
fieldname: (
_rename_pydantic_model(field.annotation, prefix)
@@ -313,10 +317,10 @@ def _rename_pydantic_model(model: Type[BaseModel], prefix: str) -> Type[BaseMode
Field(
field.default,
title=fieldname,
description=field.field_info.description,
description=field.description,
),
)
for fieldname, field in model.__fields__.items()
for fieldname, field in model.model_fields.items()
},
)
@@ -333,7 +337,7 @@ def _resolve_model(
if isclass(type_) and issubclass(type_, BaseModel):
model = type_
else:
model = create_model(default_name, __root__=(type_, ...))
model = _create_root_model(default_name, type_)
hash_ = model.schema_json()
@@ -366,11 +370,7 @@ def _add_namespace_to_model(namespace: str, model: Type[BaseModel]) -> Type[Base
A new model with name prepended with the given namespace.
"""
model_with_unique_name = _rename_pydantic_model(model, namespace)
if "run_id" in model_with_unique_name.__annotations__:
# Help resolve reference by providing namespace references
model_with_unique_name.update_forward_refs(uuid=uuid)
else:
model_with_unique_name.update_forward_refs()
model_with_unique_name.model_rebuild()
return model_with_unique_name
@@ -403,7 +403,7 @@ def _with_validation_error_translation() -> Generator[None, None, None]:
try:
yield
except ValidationError as e:
raise RequestValidationError(e.errors(), body=e.model)
raise RequestValidationError(e.errors())
def _json_encode_response(model: BaseModel) -> JSONResponse:
@@ -423,27 +423,27 @@ def _json_encode_response(model: BaseModel) -> JSONResponse:
if isinstance(model, InvokeBaseResponse):
# Invoke Response
# Collapse '__root__' from output field if it exists. This is done
# Collapse 'root' from output field if it exists. This is done
# automatically by fastapi when annotating request and response with
# We need to do this manually since we're using vanilla JSONResponse
if isinstance(obj["output"], dict) and "__root__" in obj["output"]:
obj["output"] = obj["output"]["__root__"]
if isinstance(obj["output"], dict) and "root" in obj["output"]:
obj["output"] = obj["output"]["root"]
if "callback_events" in obj:
for idx, callback_event in enumerate(obj["callback_events"]):
if isinstance(callback_event, dict) and "__root__" in callback_event:
obj["callback_events"][idx] = callback_event["__root__"]
if isinstance(callback_event, dict) and "root" in callback_event:
obj["callback_events"][idx] = callback_event["root"]
elif isinstance(model, BatchBaseResponse):
if not isinstance(obj["output"], list):
raise AssertionError("Expected output to be a list")
# Collapse '__root__' from output field if it exists. This is done
# Collapse 'root' from output field if it exists. This is done
# automatically by fastapi when annotating request and response with
# We need to do this manually since we're using vanilla JSONResponse
outputs = obj["output"]
for idx, output in enumerate(outputs):
if isinstance(output, dict) and "__root__" in output:
outputs[idx] = output["__root__"]
if isinstance(output, dict) and "root" in output:
outputs[idx] = output["root"]
if "callback_events" in obj:
if not isinstance(obj["callback_events"], list):
@@ -451,11 +451,8 @@ def _json_encode_response(model: BaseModel) -> JSONResponse:
for callback_events in obj["callback_events"]:
for idx, callback_event in enumerate(callback_events):
if (
isinstance(callback_event, dict)
and "__root__" in callback_event
):
callback_events[idx] = callback_event["__root__"]
if isinstance(callback_event, dict) and "root" in callback_event:
callback_events[idx] = callback_event["root"]
else:
raise AssertionError(
f"Expected a InvokeBaseResponse or BatchBaseResponse got: {type(model)}"
@@ -470,7 +467,12 @@ def _add_callbacks(
"""Add the callback aggregator to the config."""
if "callbacks" not in config:
config["callbacks"] = []
config["callbacks"].extend(callbacks)
if "callbacks" in config:
if isinstance(config["callbacks"], list):
config["callbacks"].extend(callbacks)
elif isinstance(config["callbacks"], BaseCallbackManager):
for callback in callbacks:
config["callbacks"].add_handler(callback, inherit=True)
_MODEL_REGISTRY = {}
@@ -1590,6 +1592,7 @@ class APIHandler:
score=create_request.score,
value=create_request.value,
comment=create_request.comment,
correction=create_request.correction,
metadata=metadata,
)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/____LANGSERVE_BASE_URL/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Playground</title>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-86d4d9c0.js"></script>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-53ad47d4.js"></script>
<link rel="stylesheet" href="/____LANGSERVE_BASE_URL/assets/index-434ff580.css">
</head>
<body>
+1
View File
@@ -30,6 +30,7 @@ export function App() {
);
const outputSchemaSupported = (
outputDataSchema?.anyOf?.find((option) => option.properties?.type?.enum?.includes("ai")) ||
outputDataSchema?.oneOf?.find((option) => option.properties?.type?.enum?.includes("ai")) ||
outputDataSchema?.type === "string"
);
const isSupported = isLoading || (inputSchemaSupported && outputSchemaSupported);
+6 -2
View File
@@ -431,11 +431,15 @@ class RemoteRunnable(Runnable[Input, Output]):
self,
inputs: List[Input],
config: Optional[RunnableConfig] = None,
*,
return_exceptions: bool = False,
**kwargs: Any,
) -> List[Output]:
if kwargs:
raise NotImplementedError("kwargs not implemented yet.")
return self._batch_with_config(self._batch, inputs, config)
raise NotImplementedError(f"kwargs not implemented yet. Got {kwargs}")
return self._batch_with_config(
self._batch, inputs, config, return_exceptions=return_exceptions
)
async def _abatch(
self,
+1 -2
View File
@@ -6,8 +6,7 @@ from typing import Literal, Sequence, Type
from fastapi.responses import Response
from langchain_core.runnables import Runnable
from langserve.pydantic_v1 import BaseModel
from pydantic import BaseModel
class PlaygroundTemplate(Template):
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/____LANGSERVE_BASE_URL/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playground</title>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-dbc96538.js"></script>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-400979f0.js"></script>
<link rel="stylesheet" href="/____LANGSERVE_BASE_URL/assets/index-52e8ab2f.css">
</head>
<body>
@@ -6,12 +6,30 @@ import {
schemaMatches,
Paths,
isControl,
JsonSchema,
} from "@jsonforms/core";
import { useStreamCallback } from "../useStreamCallback";
import { isJsonSchemaExtra } from "../utils/schema";
import { MessageFields, ChatMessageInput } from "./ChatMessageInput";
import { useEffect } from "react";
function checkItemSchema(schema: JsonSchema) {
const isObjectMessage =
schema.type === "object" &&
(schema.title?.endsWith("Message") ||
schema.title?.endsWith("MessageChunk"));
const isTupleMessage =
schema.type === "array" &&
schema.minItems === 2 &&
schema.maxItems === 2 &&
Array.isArray(schema.items) &&
schema.items.length === 2 &&
schema.items.every((schema) => schema.type === "string");
return isObjectMessage || isTupleMessage;
}
export const chatMessagesTester = rankWith(
12,
and(
@@ -34,22 +52,11 @@ export const chatMessagesTester = rankWith(
}
if ("anyOf" in schema.items && schema.items.anyOf != null) {
return schema.items.anyOf.every((schema) => {
const isObjectMessage =
schema.type === "object" &&
(schema.title?.endsWith("Message") ||
schema.title?.endsWith("MessageChunk"));
return schema.items.anyOf.every(checkItemSchema);
}
const isTupleMessage =
schema.type === "array" &&
schema.minItems === 2 &&
schema.maxItems === 2 &&
Array.isArray(schema.items) &&
schema.items.length === 2 &&
schema.items.every((schema) => schema.type === "string");
return isObjectMessage || isTupleMessage;
});
if ("oneOf" in schema.items && schema.items.oneOf != null) {
return schema.items.oneOf.every(checkItemSchema);
}
return false;
@@ -64,10 +71,14 @@ export const ChatMessagesControlRenderer = withJsonFormsControlProps(
useEffect(() => {
if (!isJsonSchemaExtra(props.schema)) return;
if (props.schema.extra.widget.type !== "chat") return;
setTimeout(() => props.handleChange(props.path, [
...data,
{ content: "", type: "human" },
]), 10);
setTimeout(
() =>
props.handleChange(props.path, [
...data,
{ content: "", type: "human" },
]),
10
);
}, []);
useStreamCallback("onStart", () => {
@@ -81,7 +92,10 @@ export const ChatMessagesControlRenderer = withJsonFormsControlProps(
if (props.schema.extra.widget.type !== "chat") return;
if (aggregatedState?.final_output !== undefined) {
const msgPath = Paths.compose(props.path, `${data.length - 1}`);
if ((aggregatedState.final_output as MessageFields)?.type === "AIMessageChunk") {
if (
(aggregatedState.final_output as MessageFields)?.type ===
"AIMessageChunk"
) {
props.handleChange(
Paths.compose(msgPath, "content"),
(aggregatedState.final_output as MessageFields)?.content
@@ -140,7 +154,7 @@ export const ChatMessagesControlRenderer = withJsonFormsControlProps(
props.path,
data.filter((_, i) => i !== index)
);
}
};
return (
<ChatMessageInput
message={message}
@@ -148,7 +162,7 @@ export const ChatMessagesControlRenderer = withJsonFormsControlProps(
handleRemoval={handleChatMessageRemoval}
path={props.path}
key={index}
></ChatMessageInput>
></ChatMessageInput>
);
})}
</div>
-33
View File
@@ -1,33 +0,0 @@
from importlib import metadata
## Create namespaces for pydantic v1 and v2.
# This code must stay at the top of the file before other modules may
# attempt to import pydantic since it adds pydantic_v1 and pydantic_v2 to sys.modules.
#
# This hack is done for the following reasons:
# * Langchain will attempt to remain compatible with both pydantic v1 and v2 since
# both dependencies and dependents may be stuck on either version of v1 or v2.
# * Creating namespaces for pydantic v1 and v2 should allow us to write code that
# unambiguously uses either v1 or v2 API.
# * This change is easier to roll out and roll back.
try:
# F401: imported but unused
from pydantic.v1 import ( # noqa: F401
BaseModel,
Field,
ValidationError,
create_model,
)
except ImportError:
from pydantic import BaseModel, Field, ValidationError, create_model # noqa: F401
# This is not a pydantic v1 thing, but it feels too small to create a new module for.
PYDANTIC_VERSION = metadata.version("pydantic")
try:
_PYDANTIC_MAJOR_VERSION: int = int(PYDANTIC_VERSION.split(".")[0])
except metadata.PackageNotFoundError:
_PYDANTIC_MAJOR_VERSION = -1
+5 -4
View File
@@ -2,10 +2,11 @@ from datetime import datetime
from typing import Dict, List, Optional, Union
from uuid import UUID
from pydantic import BaseModel # Floats between v1 and v2
from langserve.pydantic_v1 import BaseModel as BaseModelV1
from langserve.pydantic_v1 import Field
from pydantic import (
BaseModel,
Field,
)
from pydantic import BaseModel as BaseModelV1
class CustomUserType(BaseModelV1):
+36 -38
View File
@@ -13,7 +13,7 @@ sensitive information from the server to the client.
import abc
import logging
from functools import lru_cache
from typing import Any, Dict, List, Union
from typing import Annotated, Any, Dict, List, Union
import orjson
from langchain_core.agents import AgentAction, AgentActionMessageLog, AgentFinish
@@ -34,12 +34,11 @@ from langchain_core.outputs import (
ChatGeneration,
ChatGenerationChunk,
Generation,
LLMResult,
)
from langchain_core.prompt_values import ChatPromptValueConcrete
from langchain_core.prompts.base import StringPromptValue
from pydantic import BaseModel, Field, RootModel, ValidationError
from langserve.pydantic_v1 import BaseModel, ValidationError
from langserve.validation import CallbackEvent
logger = logging.getLogger(__name__)
@@ -51,36 +50,35 @@ def _log_error_message_once(error_message: str) -> None:
logger.error(error_message)
class WellKnownLCObject(BaseModel):
"""A well known LangChain object.
A pydantic model that defines what constitutes a well known LangChain object.
All well-known objects are allowed to be serialized and de-serialized.
"""
__root__: Union[
Document,
HumanMessage,
SystemMessage,
ChatMessage,
FunctionMessage,
AIMessage,
HumanMessageChunk,
SystemMessageChunk,
ChatMessageChunk,
FunctionMessageChunk,
AIMessageChunk,
StringPromptValue,
ChatPromptValueConcrete,
AgentAction,
AgentFinish,
AgentActionMessageLog,
LLMResult,
ChatGeneration,
Generation,
ChatGenerationChunk,
# A well known LangChain object.
# A pydantic model that defines what constitutes a well known LangChain object.
# All well-known objects are allowed to be serialized and de-serialized.
WellKnownLCObject = RootModel[
Annotated[
Union[
Document,
HumanMessage,
SystemMessage,
ChatMessage,
FunctionMessage,
AIMessage,
HumanMessageChunk,
SystemMessageChunk,
ChatMessageChunk,
FunctionMessageChunk,
AIMessageChunk,
StringPromptValue,
ChatPromptValueConcrete,
AgentAction,
AgentFinish,
AgentActionMessageLog,
ChatGeneration,
Generation,
ChatGenerationChunk,
],
Field(discriminator="type"),
]
]
def default(obj) -> Any:
@@ -96,12 +94,12 @@ def _decode_lc_objects(value: Any) -> Any:
v = {key: _decode_lc_objects(v) for key, v in value.items()}
try:
obj = WellKnownLCObject.parse_obj(v)
parsed = obj.__root__
obj = WellKnownLCObject.model_validate(v)
parsed = obj.root
if set(parsed.dict()) != set(value):
raise ValueError("Invalid object")
return parsed
except (ValidationError, ValueError):
except (ValidationError, ValueError, TypeError):
return v
elif isinstance(value, list):
return [_decode_lc_objects(item) for item in value]
@@ -124,11 +122,11 @@ def _decode_event_data(value: Any) -> Any:
if isinstance(value, dict):
try:
obj = CallbackEvent.parse_obj(value)
return obj.__root__
return obj.root
except ValidationError:
try:
obj = WellKnownLCObject.parse_obj(value)
return obj.__root__
return obj.root
except ValidationError:
return {key: _decode_event_data(v) for key, v in value.items()}
elif isinstance(value, list):
@@ -217,7 +215,7 @@ def load_events(events: Any) -> List[Dict[str, Any]]:
_log_error_message_once(msg)
continue
decoded_event_data = _project_top_level(full_event.__root__)
decoded_event_data = _project_top_level(full_event.root)
if decoded_event_data["type"].endswith("_error"):
# Data is validated by this point, so we can assume that the shape
+261 -308
View File
@@ -16,6 +16,7 @@ from typing import (
)
from langchain_core.runnables import Runnable
from pydantic import BaseModel
from typing_extensions import Annotated
from langserve.api_handler import (
@@ -24,11 +25,6 @@ from langserve.api_handler import (
TokenFeedbackConfig,
_is_hosted,
)
from langserve.pydantic_v1 import (
_PYDANTIC_MAJOR_VERSION,
PYDANTIC_VERSION,
BaseModel,
)
try:
from fastapi import APIRouter, Depends, FastAPI, Request, Response
@@ -237,20 +233,6 @@ def _setup_global_app_handlers(
print(f'{green("LANGSERVE:")}')
print(f'{green("LANGSERVE:")} See all available routes at {app.docs_url}/')
if _PYDANTIC_MAJOR_VERSION == 2:
print()
print(f'{orange("LANGSERVE:")} ', end="")
print(
f"⚠️ Using pydantic {PYDANTIC_VERSION}. "
f"OpenAPI docs for invoke, batch, stream, stream_log "
f"endpoints will not be generated. API endpoints and playground "
f"should work as expected. "
f"If you need to see the docs, you can downgrade to pydantic 1. "
"For example, `pip install pydantic==1.10.13`. "
f"See https://github.com/tiangolo/fastapi/issues/10360 for details."
)
print()
# PUBLIC API
@@ -473,35 +455,9 @@ def add_routes(
if hasattr(app, "openapi_tags") and (path or (app not in _APP_SEEN)):
if not path:
_APP_SEEN.add(app)
if _PYDANTIC_MAJOR_VERSION == 1:
# Documentation for the default endpoints
default_endpoint_tags = {
"name": route_tags[0] if route_tags else "default",
}
elif _PYDANTIC_MAJOR_VERSION == 2:
# When using pydantic v2, we cannot generate openapi docs for
# the invoke/batch/stream/stream_log endpoints since the underlying
# models are from the pydantic.v1 namespace and cannot be supported
# by FastAPI's.
# https://github.com/tiangolo/fastapi/issues/10360
default_endpoint_tags = {
"name": route_tags[0] if route_tags else "default",
"description": (
f"⚠️ Using pydantic {PYDANTIC_VERSION}. "
f"OpenAPI docs for `invoke`, `batch`, `stream`, `stream_log` "
f"endpoints will not be generated. API endpoints and playground "
f"should work as expected. "
f"If you need to see the docs, you can downgrade to pydantic 1. "
"For example, `pip install pydantic==1.10.13`"
f"See https://github.com/tiangolo/fastapi/issues/10360 for details."
),
}
else:
raise AssertionError(
f"Expected pydantic major version 1 or 2, got {_PYDANTIC_MAJOR_VERSION}"
)
default_endpoint_tags = {
"name": route_tags[0] if route_tags else "default",
}
if endpoint_configuration.is_config_hash_enabled:
app.openapi_tags = [
*(getattr(app, "openapi_tags", []) or []),
@@ -778,329 +734,326 @@ def add_routes(
# Documentation variants of end points.
#######################################
# At the moment, we only support pydantic 1.x for documentation
if _PYDANTIC_MAJOR_VERSION == 1:
InvokeRequest = api_handler.InvokeRequest
InvokeResponse = api_handler.InvokeResponse
BatchRequest = api_handler.BatchRequest
BatchResponse = api_handler.BatchResponse
StreamRequest = api_handler.StreamRequest
StreamLogRequest = api_handler.StreamLogRequest
StreamEventsRequest = api_handler.StreamEventsRequest
InvokeRequest = api_handler.InvokeRequest
InvokeResponse = api_handler.InvokeResponse
BatchRequest = api_handler.BatchRequest
BatchResponse = api_handler.BatchResponse
StreamRequest = api_handler.StreamRequest
StreamLogRequest = api_handler.StreamLogRequest
StreamEventsRequest = api_handler.StreamEventsRequest
if endpoint_configuration.is_invoke_enabled:
if endpoint_configuration.is_invoke_enabled:
async def _invoke_docs(
invoke_request: Annotated[InvokeRequest, InvokeRequest],
config_hash: str = "",
) -> InvokeResponse:
"""Invoke the runnable with the given input and config."""
raise AssertionError("This endpoint should not be reachable.")
async def _invoke_docs(
invoke_request: Annotated[InvokeRequest, InvokeRequest],
config_hash: str = "",
) -> InvokeResponse:
"""Invoke the runnable with the given input and config."""
raise AssertionError("This endpoint should not be reachable.")
invoke_docs = app.post(
f"{namespace}/invoke",
invoke_docs = app.post(
f"{namespace}/invoke",
response_model=api_handler.InvokeResponse,
tags=route_tags,
name=_route_name("invoke"),
dependencies=dependencies,
)(_invoke_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/invoke",
response_model=api_handler.InvokeResponse,
tags=route_tags,
name=_route_name("invoke"),
tags=route_tags_with_config,
name=_route_name_with_config("invoke"),
dependencies=dependencies,
)(_invoke_docs)
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /invoke endpoint without "
"the `c/{config_hash}` path parameter."
),
)(invoke_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/invoke",
response_model=api_handler.InvokeResponse,
tags=route_tags_with_config,
name=_route_name_with_config("invoke"),
dependencies=dependencies,
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /invoke endpoint without "
"the `c/{config_hash}` path parameter."
),
)(invoke_docs)
if endpoint_configuration.is_batch_enabled:
if endpoint_configuration.is_batch_enabled:
async def _batch_docs(
batch_request: Annotated[BatchRequest, BatchRequest],
config_hash: str = "",
) -> BatchResponse:
"""Batch invoke the runnable with the given inputs and config."""
raise AssertionError("This endpoint should not be reachable.")
async def _batch_docs(
batch_request: Annotated[BatchRequest, BatchRequest],
config_hash: str = "",
) -> BatchResponse:
"""Batch invoke the runnable with the given inputs and config."""
raise AssertionError("This endpoint should not be reachable.")
batch_docs = app.post(
f"{namespace}/batch",
response_model=BatchResponse,
tags=route_tags,
name=_route_name("batch"),
dependencies=dependencies,
)(_batch_docs)
batch_docs = app.post(
f"{namespace}/batch",
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/batch",
response_model=BatchResponse,
tags=route_tags,
name=_route_name("batch"),
tags=route_tags_with_config,
name=_route_name_with_config("batch"),
dependencies=dependencies,
)(_batch_docs)
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /batch endpoint without "
"the `c/{config_hash}` path parameter."
),
)(batch_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/batch",
response_model=BatchResponse,
tags=route_tags_with_config,
name=_route_name_with_config("batch"),
dependencies=dependencies,
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /batch endpoint without "
"the `c/{config_hash}` path parameter."
),
)(batch_docs)
if endpoint_configuration.is_stream_enabled:
if endpoint_configuration.is_stream_enabled:
async def _stream_docs(
stream_request: Annotated[StreamRequest, StreamRequest],
config_hash: str = "",
) -> EventSourceResponse:
"""Invoke the runnable stream the output.
async def _stream_docs(
stream_request: Annotated[StreamRequest, StreamRequest],
config_hash: str = "",
) -> EventSourceResponse:
"""Invoke the runnable stream the output.
This endpoint allows to stream the output of the runnable.
This endpoint allows to stream the output of the runnable.
The endpoint uses a server sent event stream to stream the output.
The endpoint uses a server sent event stream to stream the output.
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
Important: Set the "text/event-stream" media type for request headers if
not using an existing SDK.
Important: Set the "text/event-stream" media type for request headers if
not using an existing SDK.
The events that the endpoint uses are the following:
* "data" -- used for streaming the output of the runnale
* "error" -- signaling an error while streaming and ends the stream.
* "end" -- used for signaling the end of the stream
* "metadata" -- used for sending metadata about the run; e.g., run id.
The events that the endpoint uses are the following:
* "data" -- used for streaming the output of the runnale
* "error" -- signaling an error while streaming and ends the stream.
* "end" -- used for signaling the end of the stream
* "metadata" -- used for sending metadata about the run; e.g., run id.
The event type is in the "event" field of the event.
The payload associated with the event is in the "data" field
of the event, and it is JSON encoded.
The event type is in the "event" field of the event.
The payload associated with the event is in the "data" field
of the event, and it is JSON encoded.
Here are some examples of events that the endpoint can send:
Here are some examples of events that the endpoint can send:
Regular streaming event:
{
"event": "data",
"data": {
...
}
}
Internal server error:
{
"event": "error",
"data": {
"status_code": 500,
"message": "Internal Server Error"
}
}
Streaming ended so client should stop listening for events:
{
"event": "end",
}
"""
raise AssertionError("This endpoint should not be reachable.")
stream_docs = app.post(
f"{namespace}/stream",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream"),
dependencies=dependencies,
description=(
"This endpoint allows to stream the output of the runnable. "
"The endpoint uses a server sent event stream to stream the "
"output. "
"https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events"
),
)(_stream_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/stream",
include_in_schema=True,
tags=route_tags_with_config,
name=_route_name_with_config("stream"),
dependencies=dependencies,
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream endpoint without "
"the `c/{config_hash}` path parameter."
),
)(stream_docs)
if endpoint_configuration.is_stream_log_enabled:
async def _stream_log_docs(
stream_log_request: Annotated[StreamLogRequest, StreamLogRequest],
config_hash: str = "",
) -> EventSourceResponse:
"""Invoke the runnable stream_log the output.
This endpoint allows to stream the output of the runnable, including
the output of all intermediate steps.
The endpoint uses a server sent event stream to stream the output.
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
Important: Set the "text/event-stream" media type for request headers if
not using an existing SDK.
This endpoint uses two different types of events:
* data - for streaming the output of the runnable
Regular streaming event:
{
"event": "data",
"data": {
...
...
}
}
Internal server error:
{
"event": "error",
"data": {
"status_code": 500,
"message": "Internal Server Error"
}
}
* error - for signaling an error in the stream, also ends the stream.
{
"event": "error",
"data": {
"status_code": 500,
"message": "Internal Server Error"
}
}
* end - for signaling the end of the stream.
This helps the client to know when to stop listening for events and
know that the streaming has ended successfully.
Streaming ended so client should stop listening for events:
{
"event": "end",
}
"""
raise AssertionError("This endpoint should not be reachable.")
"""
raise AssertionError("This endpoint should not be reachable.")
stream_docs = app.post(
f"{namespace}/stream",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream"),
dependencies=dependencies,
description=(
"This endpoint allows to stream the output of the runnable. "
"The endpoint uses a server sent event stream to stream the "
"output. "
"https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events"
),
)(_stream_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/stream",
include_in_schema=True,
tags=route_tags_with_config,
name=_route_name_with_config("stream"),
dependencies=dependencies,
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream endpoint without "
"the `c/{config_hash}` path parameter."
),
)(stream_docs)
if endpoint_configuration.is_stream_log_enabled:
async def _stream_log_docs(
stream_log_request: Annotated[StreamLogRequest, StreamLogRequest],
config_hash: str = "",
) -> EventSourceResponse:
"""Invoke the runnable stream_log the output.
This endpoint allows to stream the output of the runnable, including
the output of all intermediate steps.
The endpoint uses a server sent event stream to stream the output.
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
Important: Set the "text/event-stream" media type for request headers if
not using an existing SDK.
This endpoint uses two different types of events:
* data - for streaming the output of the runnable
{
"event": "data",
"data": {
...
}
}
* error - for signaling an error in the stream, also ends the stream.
{
"event": "error",
"data": {
"status_code": 500,
"message": "Internal Server Error"
}
}
* end - for signaling the end of the stream.
This helps the client to know when to stop listening for events and
know that the streaming has ended successfully.
{
"event": "end",
}
"""
raise AssertionError("This endpoint should not be reachable.")
app.post(
f"{namespace}/stream_log",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream_log"),
dependencies=dependencies,
)(_stream_log_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
f"{namespace}/stream_log",
namespace + "/c/{config_hash}/stream_log",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream_log"),
tags=route_tags_with_config,
name=_route_name_with_config("stream_log"),
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream_log endpoint without "
"the `c/{config_hash}` path parameter."
),
dependencies=dependencies,
)(_stream_log_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/stream_log",
include_in_schema=True,
tags=route_tags_with_config,
name=_route_name_with_config("stream_log"),
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream_log endpoint without "
"the `c/{config_hash}` path parameter."
),
dependencies=dependencies,
)(_stream_log_docs)
if has_astream_events and endpoint_configuration.is_stream_events_enabled:
if has_astream_events and endpoint_configuration.is_stream_events_enabled:
async def _stream_events_docs(
stream_events_request: Annotated[StreamEventsRequest, StreamEventsRequest],
config_hash: str = "",
) -> EventSourceResponse:
"""Stream events from the given runnable.
async def _stream_events_docs(
stream_events_request: Annotated[
StreamEventsRequest, StreamEventsRequest
],
config_hash: str = "",
) -> EventSourceResponse:
"""Stream events from the given runnable.
This endpoint allows to stream events from the runnable, including
events from all intermediate steps.
This endpoint allows to stream events from the runnable, including
events from all intermediate steps.
**Attention**
**Attention**
This is a new endpoint that only works for langchain-core >= 0.1.14.
This is a new endpoint that only works for langchain-core >= 0.1.14.
It belongs to a Beta API that may change in the future.
It belongs to a Beta API that may change in the future.
**Important**
Specify filters to the events you want to receive by setting
the appropriate filters in the request body.
**Important**
Specify filters to the events you want to receive by setting
the appropriate filters in the request body.
This will help avoid sending too much data over the network.
This will help avoid sending too much data over the network.
It will also prevent serialization issues with
any unsupported types since it won't need to serialize events
that aren't transmitted.
It will also prevent serialization issues with
any unsupported types since it won't need to serialize events
that aren't transmitted.
The endpoint uses a server sent event stream to stream the output.
The endpoint uses a server sent event stream to stream the output.
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
The encoding of events follows the following format:
The encoding of events follows the following format:
* data - for streaming the output of the runnable
{
"event": "data",
"data": {
...
}
}
* error - for signaling an error in the stream, also ends the stream.
* data - for streaming the output of the runnable
{
"event": "error",
"event": "data",
"data": {
"status_code": 500,
"message": "Internal Server Error"
...
}
}
* end - for signaling the end of the stream.
* error - for signaling an error in the stream, also ends the stream.
This helps the client to know when to stop listening for events and
know that the streaming has ended successfully.
{
"event": "error",
"data": {
"status_code": 500,
"message": "Internal Server Error"
}
}
{
"event": "end",
}
* end - for signaling the end of the stream.
`data` for the `data` event is a JSON object that corresponds
to a serialized representation of a StreamEvent.
This helps the client to know when to stop listening for events and
know that the streaming has ended successfully.
See LangChain documentation for more information about astream_events.
"""
raise AssertionError("This endpoint should not be reachable.")
{
"event": "end",
}
`data` for the `data` event is a JSON object that corresponds
to a serialized representation of a StreamEvent.
See LangChain documentation for more information about astream_events.
"""
raise AssertionError("This endpoint should not be reachable.")
app.post(
f"{namespace}/stream_events",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream_events"),
dependencies=dependencies,
)(_stream_events_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
f"{namespace}/stream_events",
namespace + "/c/{config_hash}/stream_events",
include_in_schema=True,
tags=route_tags,
name=_route_name("stream_events"),
tags=route_tags_with_config,
name=_route_name_with_config("stream_events"),
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream_events endpoint "
"without the `c/{config_hash}` path parameter."
),
dependencies=dependencies,
)(_stream_events_docs)
if endpoint_configuration.is_config_hash_enabled:
app.post(
namespace + "/c/{config_hash}/stream_events",
include_in_schema=True,
tags=route_tags_with_config,
name=_route_name_with_config("stream_events"),
description=(
"This endpoint is to be used with share links generated by the "
"LangServe playground. "
"The hash is an LZString compressed JSON string. "
"For regular use cases, use the /stream_events endpoint "
"without the `c/{config_hash}` path parameter."
),
dependencies=dependencies,
)(_stream_events_docs)
+11 -15
View File
@@ -22,16 +22,11 @@ from uuid import UUID
from langchain_core.documents import Document
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGeneration, Generation, RunInfo
from pydantic import BaseModel, Field, RootModel, create_model
from typing_extensions import Type
from langserve.schema import BatchResponseMetadata, InvokeResponseMetadata
try:
from pydantic.v1 import BaseModel, Field, create_model
except ImportError:
from pydantic import BaseModel, Field, create_model
# Type that is either a python annotation or a pydantic model that can be
# used to validate the input or output of a runnable.
Validator = Union[Type[BaseModel], type]
@@ -66,7 +61,7 @@ def create_invoke_request_model(
),
),
)
invoke_request_type.update_forward_refs()
invoke_request_type.model_rebuild()
return invoke_request_type
@@ -97,7 +92,7 @@ def create_stream_request_model(
),
),
)
stream_request_model.update_forward_refs()
stream_request_model.model_rebuild()
return stream_request_model
@@ -129,7 +124,7 @@ def create_batch_request_model(
),
),
)
batch_request_type.update_forward_refs()
batch_request_type.model_rebuild()
return batch_request_type
@@ -187,7 +182,7 @@ def create_stream_log_request_model(
),
kwargs=(dict, Field(default_factory=dict)),
)
stream_log_request.update_forward_refs()
stream_log_request.model_rebuild()
return stream_log_request
@@ -245,7 +240,7 @@ def create_stream_events_request_model(
),
kwargs=(dict, Field(default_factory=dict)),
)
stream_events_request.update_forward_refs()
stream_events_request.model_rebuild()
return stream_events_request
@@ -297,7 +292,7 @@ def create_invoke_response_model(
__base__=InvokeBaseResponse,
**fields,
)
invoke_response_type.update_forward_refs()
invoke_response_type.model_rebuild()
return invoke_response_type
@@ -347,7 +342,7 @@ def create_batch_response_model(
__base__=BatchBaseResponse,
**fields,
)
batch_response_type.update_forward_refs()
batch_response_type.model_rebuild()
return batch_response_type
@@ -566,8 +561,8 @@ class OnRetrieverEnd(BaseModel):
type: Literal["on_retriever_end"] = "on_retriever_end"
class CallbackEvent(BaseModel):
__root__: Union[
CallbackEvent = RootModel[
Union[
OnChainStart,
OnChainEnd,
OnChainError,
@@ -581,3 +576,4 @@ class CallbackEvent(BaseModel):
OnRetrieverEnd,
OnRetrieverError,
]
]
Generated
+1235 -1410
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langserve"
version = "0.2.2"
version = "0.3.0dev1"
description = ""
readme = "README.md"
authors = ["LangChain"]
@@ -10,12 +10,11 @@ exclude = ["langserve/playground,langserve/chat_playground"]
include = ["langserve/playground/dist/**/*", "langserve/chat_playground/dist/**/*"]
[tool.poetry.dependencies]
python = "^3.8.1"
python = "^3.9"
httpx = ">=0.23.0" # May be able to decrease this version
fastapi = {version = ">=0.90.1,<1", optional = true}
sse-starlette = {version = "^1.3.0", optional = true}
pydantic = ">=1"
langchain-core = ">=0.1,<0.3"
langchain-core = "0.3.0dev4"
orjson = ">=2"
pyproject-toml = "^0.0.10"
+24 -7
View File
@@ -4,15 +4,22 @@ from enum import Enum
from typing import Any
import pytest
from langchain_core.documents.base import Document
from langchain_core.messages import HumanMessage, HumanMessageChunk, SystemMessage
from langchain_core.outputs import ChatGeneration
from pydantic import BaseModel
try:
from pydantic.v1 import BaseModel
except ImportError:
from pydantic import BaseModel
from langserve.serialization import (
WellKnownLCObject,
WellKnownLCSerializer,
load_events,
)
from langserve.serialization import WellKnownLCSerializer, load_events
def test_document_serialization() -> None:
"""Simple test. Exhaustive tests follow below."""
doc = Document(page_content="hello")
d = doc.dict()
WellKnownLCObject.model_validate(d)
@pytest.mark.parametrize(
@@ -23,6 +30,8 @@ from langserve.serialization import WellKnownLCSerializer, load_events
[],
{},
{"a": 1},
Document(page_content="Hello"),
[Document(page_content="Hello")],
{"output": [HumanMessage(content="hello")]},
# Test with a single message (HumanMessage)
HumanMessage(content="Hello"),
@@ -39,7 +48,8 @@ from langserve.serialization import WellKnownLCSerializer, load_events
"numbers": [1, 2, 3],
"boom": "Hello, world!",
},
[ChatGeneration(message=HumanMessage(content="Hello"))],
# Requires typing ChatGeneration with Anymessage
# [ChatGeneration(message=HumanMessage(content="Hello"))],
],
)
def test_serialization(data: Any) -> None:
@@ -179,3 +189,10 @@ def test_encoding_of_well_known_types(obj: Any, expected: str) -> None:
"""
lc_serializer = WellKnownLCSerializer()
assert lc_serializer.dumpd(obj) == expected
@pytest.mark.xfail(reason="0.3")
def test_fail_03() -> None:
"""This test will fail on purposes. It contains a TODO list for 0.3 release."""
assert "LLMResult" == "WellKnocnLCOBject contains it" # Requires type
assert "CHatGeneration_Deserialized correct" == "UNcomment test above"
+373 -135
View File
@@ -2,6 +2,7 @@
import asyncio
import datetime
import json
import sys
import uuid
from asyncio import AbstractEventLoop
from contextlib import asynccontextmanager, contextmanager
@@ -32,13 +33,11 @@ from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.documents import Document
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
BaseMessage,
HumanMessage,
SystemMessage,
)
from langchain_core.output_parsers import StrOutputParser
from langchain_core.outputs import ChatGenerationChunk, LLMResult
from langchain_core.prompt_values import StringPromptValue
from langchain_core.prompts import (
ChatPromptTemplate,
@@ -58,6 +57,7 @@ from langchain_core.tracers import RunLog, RunLogPatch
from langsmith import schemas as ls_schemas
from langsmith.client import Client
from langsmith.schemas import FeedbackIngestToken
from pydantic import BaseModel, Field, __version__
from pytest import MonkeyPatch
from pytest_mock import MockerFixture
from typing_extensions import Annotated, TypedDict
@@ -71,16 +71,14 @@ from langserve.callbacks import AsyncEventAggregatorCallback
from langserve.client import RemoteRunnable
from langserve.lzstring import LZString
from langserve.schema import CustomUserType
from tests.unit_tests.utils.stubs import AnyStr
try:
from pydantic.v1 import BaseModel, Field
except ImportError:
from pydantic import BaseModel, Field
from langserve.server import add_routes
from tests.unit_tests.utils.llms import FakeListLLM, GenericFakeChatModel
from tests.unit_tests.utils.serde import recursive_dump
from tests.unit_tests.utils.stubs import _AnyIdAIMessage
from tests.unit_tests.utils.tracer import FakeTracer
PYDANTIC_VERSION = tuple(map(int, __version__.split(".")))
def _decode_eventstream(text: str) -> List[Dict[str, Any]]:
"""Simple decoder for testing purposes.
@@ -421,6 +419,7 @@ async def test_server_astream_events(app: FastAPI) -> None:
"event": "on_chain_start",
"name": "add_one_or_passthrough",
"tags": [],
"parent_ids": [],
},
"type": "data",
},
@@ -430,6 +429,7 @@ async def test_server_astream_events(app: FastAPI) -> None:
"event": "on_chain_stream",
"name": "add_one_or_passthrough",
"tags": [],
"parent_ids": [],
},
"type": "data",
},
@@ -439,6 +439,7 @@ async def test_server_astream_events(app: FastAPI) -> None:
"event": "on_chain_end",
"name": "add_one_or_passthrough",
"tags": [],
"parent_ids": [],
},
"type": "data",
},
@@ -467,7 +468,7 @@ async def test_server_bound_async(app_for_config: FastAPI) -> None:
assert response.status_code == 200
assert response.json()["output"] == {
"tags": ["another-one", "test"],
"configurable": None,
"configurable": {},
}
# Test batch
@@ -477,7 +478,7 @@ async def test_server_bound_async(app_for_config: FastAPI) -> None:
)
assert response.status_code == 200
assert response.json()["output"] == [
{"tags": ["another-one", "test"], "configurable": None}
{"tags": ["another-one", "test"], "configurable": {}}
]
# Test stream
@@ -490,7 +491,7 @@ async def test_server_bound_async(app_for_config: FastAPI) -> None:
response_with_run_id_replaced = _replace_run_id_in_stream_resp(response.text)
assert (
response_with_run_id_replaced
== """event: metadata\r\ndata: {"run_id": "<REPLACED>"}\r\n\r\nevent: data\r\ndata: {"tags":["another-one","test"],"configurable":null}\r\n\r\nevent: end\r\n\r\n""" # noqa: E501
== """event: metadata\r\ndata: {"run_id": "<REPLACED>"}\r\n\r\nevent: data\r\ndata: {"tags":["another-one","test"],"configurable":{}}\r\n\r\nevent: end\r\n\r\n""" # noqa: E501
)
@@ -506,13 +507,18 @@ def test_invoke(sync_remote_runnable: RemoteRunnable) -> None:
# Test tracing
tracer = FakeTracer()
assert sync_remote_runnable.invoke(1, config={"callbacks": [tracer]}) == 2
assert len(tracer.runs) == 1
# Light test to verify that we're picking up information about the server side
# function being invoked via a callback.
assert tracer.runs[0].child_runs[0].name == "RunnableLambda"
assert (
tracer.runs[0].child_runs[0].extra["kwargs"]["name"] == "add_one_or_passthrough"
# Picking up the run from the server side, and client side should also log a run
# from the RemoteRunnable that will have as a child the server side run.
assert len(tracer.runs) == 2
first_run = tracer.runs[0]
remote_runnable_run = (
tracer.runs[0] if first_run.name == "RemoteRunnable" else tracer.runs[1]
)
assert remote_runnable_run.name == "RemoteRunnable"
assert remote_runnable_run.child_runs[0].name == "add_one_or_passthrough"
def test_batch(sync_remote_runnable: RemoteRunnable) -> None:
@@ -574,13 +580,31 @@ async def test_ainvoke(async_remote_runnable: RemoteRunnable) -> None:
# Test tracing
tracer = FakeTracer()
assert await async_remote_runnable.ainvoke(1, config={"callbacks": [tracer]}) == 2
assert len(tracer.runs) == 1
# Light test to verify that we're picking up information about the server side
# function being invoked via a callback.
assert tracer.runs[0].child_runs[0].name == "RunnableLambda"
assert (
tracer.runs[0].child_runs[0].extra["kwargs"]["name"] == "add_one_or_passthrough"
)
# This code has some funkiness from the test code running the client and the
# server in the same process AND the fact that we use contextvars to propagate
# config information.
# The behavior is also different between python < 3.11 and python >= 3.11
# due to asyncio supporting contextvars starting from 3.11.
# check the python version now
if sys.version_info >= (3, 11):
assert len(tracer.runs) == 2, "Failed for python >= 3.11"
first_run = tracer.runs[0]
remote_runnable_run = (
tracer.runs[0] if first_run.name == "RemoteRunnable" else tracer.runs[1]
)
assert remote_runnable_run.name == "RemoteRunnable"
assert remote_runnable_run.child_runs[0].name == "add_one_or_passthrough"
elif sys.version_info < (3, 11):
assert len(tracer.runs) == 1, "Failed for python < 3.11"
remote_runnable = tracer.runs[0]
assert (
remote_runnable.child_runs[0].extra["kwargs"]["name"]
== "add_one_or_passthrough"
)
else:
raise AssertionError(f"Unsupported python version {sys.version_info}")
async def test_abatch(async_remote_runnable: RemoteRunnable) -> None:
@@ -1057,9 +1081,50 @@ async def test_multiple_runnables(event_loop: AbstractEventLoop) -> None:
) == StringPromptValue(text="What is your name? Bob")
async def test_input_validation(
event_loop: AbstractEventLoop, mocker: MockerFixture
) -> None:
async def test_config_keys_validation(mocker: MockerFixture) -> None:
"""This test should not use a RemoteRunnable.
RemoteRunnable runs in the same process as the server during unit tests
and there's unfortunately an interaction between the config contextvars
that makes it difficult to test this behavior correctly using a RemoteRunnable.
Instead the behavior can be tested correctly by making a regular http request
using a FastAPI test client.
"""
async def add_one(x: int) -> int:
"""Add one to simulate a valid function"""
return x + 1
server_runnable = RunnableLambda(func=add_one)
app = FastAPI()
add_routes(
app,
server_runnable,
input_type=int,
config_keys=["metadata"],
)
async with AsyncClient(app=app, base_url="http://localhost:9999") as async_client:
server_runnable_spy = mocker.spy(server_runnable, "ainvoke")
response = await async_client.post(
"/invoke",
json={"input": 1, "config": {"tags": ["hello"], "metadata": {"a": 5}}},
)
# Config should be ignored but default debug information
# will still be added
assert response.status_code == 200
config_seen = server_runnable_spy.call_args[0][1]
assert "metadata" in config_seen
assert "a" in config_seen["metadata"]
assert config_seen["tags"] == []
assert "__useragent" in config_seen["metadata"]
assert "__langserve_version" in config_seen["metadata"]
assert "__langserve_endpoint" in config_seen["metadata"]
assert config_seen["metadata"]["__langserve_endpoint"] == "invoke"
async def test_input_validation(mocker: MockerFixture) -> None:
"""Test client side and server side exceptions."""
async def add_one(x: int) -> int:
@@ -1067,7 +1132,6 @@ async def test_input_validation(
return x + 1
server_runnable = RunnableLambda(func=add_one)
server_runnable2 = RunnableLambda(func=add_one)
app = FastAPI()
add_routes(
@@ -1077,14 +1141,6 @@ async def test_input_validation(
path="/add_one",
)
add_routes(
app,
server_runnable2,
input_type=int,
path="/add_one_config",
config_keys=["tags", "metadata"],
)
async with get_async_remote_runnable(
app, path="/add_one", raise_app_exceptions=False
) as runnable:
@@ -1097,38 +1153,6 @@ async def test_input_validation(
with pytest.raises(httpx.HTTPError):
await runnable.abatch(["hello"])
config = {"tags": ["test"], "metadata": {"a": 5}}
server_runnable_spy = mocker.spy(server_runnable, "ainvoke")
# Verify config is handled correctly
async with get_async_remote_runnable(app, path="/add_one") as runnable1:
# Verify that can be invoked with valid input
# Config ignored for runnable1
assert await runnable1.ainvoke(1, config=config) == 2
# Config should be ignored but default debug information
# will still be added
config_seen = server_runnable_spy.call_args[0][1]
assert "metadata" in config_seen
assert "a" not in config_seen["metadata"]
assert "__useragent" in config_seen["metadata"]
assert "__langserve_version" in config_seen["metadata"]
assert "__langserve_endpoint" in config_seen["metadata"]
assert config_seen["metadata"]["__langserve_endpoint"] == "invoke"
server_runnable2_spy = mocker.spy(server_runnable2, "ainvoke")
async with get_async_remote_runnable(app, path="/add_one_config") as runnable2:
# Config accepted for runnable2
assert await runnable2.ainvoke(1, config=config) == 2
# Config ignored
config_seen = server_runnable2_spy.call_args[0][1]
assert config_seen["tags"] == ["test"]
assert config_seen["metadata"]["a"] == 5
assert "__useragent" in config_seen["metadata"]
assert "__langserve_version" in config_seen["metadata"]
assert "__langserve_endpoint" in config_seen["metadata"]
assert config_seen["metadata"]["__langserve_endpoint"] == "invoke"
async def test_input_validation_with_lc_types(event_loop: AbstractEventLoop) -> None:
"""Test client side and server side exceptions."""
@@ -1412,6 +1436,7 @@ async def test_input_config_output_schemas(event_loop: AbstractEventLoop) -> Non
"properties": {"question": {"title": "Question", "type": "string"}},
"title": "PromptInput",
"type": "object",
"required": ["question"],
}
response = await async_client.get("/prompt_2/input_schema")
@@ -1419,6 +1444,7 @@ async def test_input_config_output_schemas(event_loop: AbstractEventLoop) -> Non
"properties": {"name": {"title": "Name", "type": "string"}},
"title": "PromptInput",
"type": "object",
"required": ["name"],
}
# output schema
@@ -1450,35 +1476,90 @@ async def test_input_config_output_schemas(event_loop: AbstractEventLoop) -> Non
response = await async_client.get("/add_two_custom/config_schema")
assert response.json() == {
"properties": {
"tags": {"items": {"type": "string"}, "title": "Tags", "type": "array"}
"tags": {
"default": None,
"items": {"type": "string"},
"title": "Tags",
"type": "array",
}
},
"title": "add_two_config",
"type": "object",
}
response = await async_client.get("/prompt_2/config_schema")
assert response.json() == {
"definitions": {
"Configurable": {
"properties": {
"template": {
"default": "say {name}",
"description": "The template to use for the prompt",
"title": "Template",
"type": "string",
}
if PYDANTIC_VERSION < (2, 9):
assert response.json() == {
"$defs": {
"Configurable": {
"properties": {
"template": {
"default": "say {name}",
"description": "The "
"template "
"to use "
"for "
"the "
"prompt",
"title": "Template",
"type": "string",
}
},
"title": "Configurable",
"type": "object",
}
},
"properties": {
"configurable": {
"allOf": [{"$ref": "#/$defs/Configurable"}],
"default": None,
},
"title": "Configurable",
"type": "object",
}
},
"properties": {
"configurable": {"$ref": "#/definitions/Configurable"},
"tags": {"items": {"type": "string"}, "title": "Tags", "type": "array"},
},
"title": "RunnableConfigurableFieldsConfig",
"type": "object",
}
"tags": {
"default": None,
"items": {"type": "string"},
"title": "Tags",
"type": "array",
},
},
"title": "RunnableConfigurableFieldsConfig",
"type": "object",
}
else:
assert response.json() == {
"$defs": {
"Configurable": {
"properties": {
"template": {
"default": "say {name}",
"description": "The "
"template "
"to use "
"for "
"the "
"prompt",
"title": "Template",
"type": "string",
}
},
"title": "Configurable",
"type": "object",
}
},
"properties": {
"configurable": {
"$ref": "#/$defs/Configurable",
"default": None,
},
"tags": {
"default": None,
"items": {"type": "string"},
"title": "Tags",
"type": "array",
},
},
"title": "RunnableConfigurableFieldsConfig",
"type": "object",
}
async def test_input_schema_typed_dict() -> None:
@@ -1495,25 +1576,46 @@ async def test_input_schema_typed_dict() -> None:
async with AsyncClient(app=app, base_url="http://localhost:9999") as client:
res = await client.get("/input_schema")
assert res.json() == {
"$ref": "#/definitions/InputType",
"definitions": {
"InputType": {
"properties": {
"bar": {
"items": {"type": "integer"},
"title": "Bar",
"type": "array",
if PYDANTIC_VERSION < (2, 9):
assert res.json() == {
"$defs": {
"InputType": {
"properties": {
"bar": {
"items": {"type": "integer"},
"title": "Bar",
"type": "array",
},
"foo": {"title": "Foo", "type": "string"},
},
"foo": {"title": "Foo", "type": "string"},
},
"required": ["foo", "bar"],
"title": "InputType",
"type": "object",
}
},
"title": "passthrough_dict_input",
}
"required": ["foo", "bar"],
"title": "InputType",
"type": "object",
}
},
"allOf": [{"$ref": "#/$defs/InputType"}],
"title": "passthrough_dict_input",
}
else:
assert res.json() == {
"$defs": {
"InputType": {
"properties": {
"bar": {
"items": {"type": "integer"},
"title": "Bar",
"type": "array",
},
"foo": {"title": "Foo", "type": "string"},
},
"required": ["foo", "bar"],
"title": "InputType",
"type": "object",
}
},
"$ref": "#/$defs/InputType",
"title": "passthrough_dict_input",
}
class StreamingRunnable(Runnable):
@@ -2236,54 +2338,63 @@ async def test_astream_events_simple(async_remote_runnable: RemoteRunnable) -> N
"event": "on_chain_start",
"name": "RunnableSequence",
"tags": [],
"parent_ids": [],
},
{
"data": {},
"event": "on_chain_start",
"name": "add_one",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {"chunk": 2},
"event": "on_chain_stream",
"name": "add_one",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {},
"event": "on_chain_start",
"name": "mul_two",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {"input": 1, "output": 2},
"event": "on_chain_end",
"name": "add_one",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {"chunk": 4},
"event": "on_chain_stream",
"name": "mul_two",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {"chunk": 4},
"event": "on_chain_stream",
"name": "RunnableSequence",
"tags": [],
"parent_ids": [],
},
{
"data": {"input": 2, "output": 4},
"event": "on_chain_end",
"name": "mul_two",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {"output": 4},
"event": "on_chain_end",
"name": "RunnableSequence",
"tags": [],
"parent_ids": [],
},
]
@@ -2380,12 +2491,14 @@ async def test_astream_events_with_serialization(
"event": "on_chain_start",
"name": "/doc_types",
"tags": [],
"parent_ids": [],
},
{
"data": {},
"event": "on_chain_start",
"name": "to_document",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {
@@ -2397,12 +2510,14 @@ async def test_astream_events_with_serialization(
"event": "on_chain_stream",
"name": "to_document",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {},
"event": "on_chain_start",
"name": "from_document",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {
@@ -2415,18 +2530,21 @@ async def test_astream_events_with_serialization(
"event": "on_chain_end",
"name": "to_document",
"tags": ["seq:step:1"],
"parent_ids": [],
},
{
"data": {"chunk": "foo"},
"event": "on_chain_stream",
"name": "from_document",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {"chunk": "foo"},
"event": "on_chain_stream",
"name": "/doc_types",
"tags": [],
"parent_ids": [],
},
{
"data": {
@@ -2439,12 +2557,14 @@ async def test_astream_events_with_serialization(
"event": "on_chain_end",
"name": "from_document",
"tags": ["seq:step:2"],
"parent_ids": [],
},
{
"data": {"output": "foo"},
"event": "on_chain_end",
"name": "/doc_types",
"tags": [],
"parent_ids": [],
},
]
@@ -2460,6 +2580,7 @@ async def test_astream_events_with_serialization(
"event": "on_chain_start",
"name": "/get_pets",
"tags": [],
"parent_ids": [],
},
{
"data": {
@@ -2468,6 +2589,7 @@ async def test_astream_events_with_serialization(
"event": "on_chain_stream",
"name": "/get_pets",
"tags": [],
"parent_ids": [],
},
{
"data": {
@@ -2476,6 +2598,7 @@ async def test_astream_events_with_serialization(
"event": "on_chain_end",
"name": "/get_pets",
"tags": [],
"parent_ids": [],
},
]
@@ -2516,17 +2639,19 @@ async def test_astream_events_with_prompt_model_parser_chain(
)
]
_clean_up_events(events)
assert events == [
assert recursive_dump(events) == [
{
"data": {"input": {"question": "hello"}},
"event": "on_chain_start",
"name": "RunnableSequence",
"parent_ids": [],
"tags": [],
},
{
"data": {"input": {"question": "hello"}},
"event": "on_prompt_start",
"name": "ChatPromptTemplate",
"parent_ids": [],
"tags": ["seq:step:1"],
},
{
@@ -2534,13 +2659,26 @@ async def test_astream_events_with_prompt_model_parser_chain(
"input": {"question": "hello"},
"output": {
"messages": [
SystemMessage(content="You are a cat."),
HumanMessage(content="hello"),
{
"additional_kwargs": {},
"content": "You are a cat.",
"name": None,
"response_metadata": {},
"type": "system",
},
{
"additional_kwargs": {},
"content": "hello",
"name": None,
"response_metadata": {},
"type": "human",
},
]
},
},
"event": "on_prompt_end",
"name": "ChatPromptTemplate",
"parent_ids": [],
"tags": ["seq:step:1"],
},
{
@@ -2548,74 +2686,137 @@ async def test_astream_events_with_prompt_model_parser_chain(
"input": {
"messages": [
[
SystemMessage(content="You are a cat."),
HumanMessage(content="hello"),
{
"additional_kwargs": {},
"content": "You are a cat.",
"name": None,
"response_metadata": {},
"type": "system",
},
{
"additional_kwargs": {},
"content": "hello",
"example": False,
"name": None,
"response_metadata": {},
"type": "human",
},
]
]
}
},
"event": "on_chat_model_start",
"name": "GenericFakeChatModel",
"parent_ids": [],
"tags": ["seq:step:2"],
},
{
"data": {"chunk": AIMessageChunk(content="Hello", id=AnyStr())},
"data": {
"chunk": {
"additional_kwargs": {},
"content": "Hello",
"example": False,
"invalid_tool_calls": [],
"name": None,
"response_metadata": {},
"tool_call_chunks": [],
"tool_calls": [],
"type": "AIMessageChunk",
"usage_metadata": None,
}
},
"event": "on_chat_model_stream",
"name": "GenericFakeChatModel",
"parent_ids": [],
"tags": ["seq:step:2"],
},
{
"data": {},
"event": "on_parser_start",
"name": "StrOutputParser",
"parent_ids": [],
"tags": ["seq:step:3"],
},
{
"data": {"chunk": "Hello"},
"event": "on_parser_stream",
"name": "StrOutputParser",
"parent_ids": [],
"tags": ["seq:step:3"],
},
{
"data": {"chunk": "Hello"},
"event": "on_chain_stream",
"name": "RunnableSequence",
"parent_ids": [],
"tags": [],
},
{
"data": {"chunk": AIMessageChunk(content=" ", id=AnyStr())},
"data": {
"chunk": {
"additional_kwargs": {},
"content": " ",
"example": False,
"invalid_tool_calls": [],
"name": None,
"response_metadata": {},
"tool_call_chunks": [],
"tool_calls": [],
"type": "AIMessageChunk",
"usage_metadata": None,
}
},
"event": "on_chat_model_stream",
"name": "GenericFakeChatModel",
"parent_ids": [],
"tags": ["seq:step:2"],
},
{
"data": {"chunk": " "},
"event": "on_parser_stream",
"name": "StrOutputParser",
"parent_ids": [],
"tags": ["seq:step:3"],
},
{
"data": {"chunk": " "},
"event": "on_chain_stream",
"name": "RunnableSequence",
"parent_ids": [],
"tags": [],
},
{
"data": {"chunk": AIMessageChunk(content="World!", id=AnyStr())},
"data": {
"chunk": {
"additional_kwargs": {},
"content": "World!",
"example": False,
"invalid_tool_calls": [],
"name": None,
"response_metadata": {},
"tool_call_chunks": [],
"tool_calls": [],
"type": "AIMessageChunk",
"usage_metadata": None,
}
},
"event": "on_chat_model_stream",
"name": "GenericFakeChatModel",
"parent_ids": [],
"tags": ["seq:step:2"],
},
{
"data": {"chunk": "World!"},
"event": "on_parser_stream",
"name": "StrOutputParser",
"parent_ids": [],
"tags": ["seq:step:3"],
},
{
"data": {"chunk": "World!"},
"event": "on_chain_stream",
"name": "RunnableSequence",
"parent_ids": [],
"tags": [],
},
{
@@ -2623,43 +2824,76 @@ async def test_astream_events_with_prompt_model_parser_chain(
"input": {
"messages": [
[
SystemMessage(content="You are a cat."),
HumanMessage(content="hello"),
{
"additional_kwargs": {},
"content": "You are a cat.",
"name": None,
"response_metadata": {},
"type": "system",
},
{
"additional_kwargs": {},
"content": "hello",
"example": False,
"name": None,
"response_metadata": {},
"type": "human",
},
]
]
},
"output": LLMResult(
generations=[
"output": {
"generations": [
[
ChatGenerationChunk(
text="Hello World!",
message=AIMessageChunk(
content="Hello World!", id=AnyStr()
),
)
{
"generation_info": None,
"message": {
"additional_kwargs": {},
"content": "Hello World!",
"name": None,
"response_metadata": {},
"type": "AIMessageChunk",
},
"text": "Hello World!",
"type": "ChatGenerationChunk",
}
]
],
llm_output=None,
run=None,
),
"llm_output": None,
"run": None,
},
},
"event": "on_chat_model_end",
"name": "GenericFakeChatModel",
"parent_ids": [],
"tags": ["seq:step:2"],
},
{
"data": {
"input": AIMessageChunk(content="Hello World!", id=AnyStr()),
"input": {
"additional_kwargs": {},
"content": "Hello World!",
"example": False,
"invalid_tool_calls": [],
"name": None,
"response_metadata": {},
"tool_call_chunks": [],
"tool_calls": [],
"type": "AIMessageChunk",
"usage_metadata": None,
},
"output": "Hello World!",
},
"event": "on_parser_end",
"name": "StrOutputParser",
"parent_ids": [],
"tags": ["seq:step:3"],
},
{
"data": {"output": "Hello World!"},
"event": "on_chain_end",
"name": "RunnableSequence",
"parent_ids": [],
"tags": [],
},
]
@@ -2730,6 +2964,8 @@ async def test_path_dependencies() -> None:
)
# TODO(0.3): Fix in langchain-core and re-release for 0.3
@pytest.mark.xfail(reason="Bug in AnyMessage in langchain-core.dev4")
async def test_remote_configurable_remote_runnable() -> None:
"""Test that a configurable a client runnable that's configurable works.
@@ -2775,7 +3011,7 @@ async def test_remote_configurable_remote_runnable() -> None:
add_routes(app, chain)
# Invoke request
async with get_async_remote_runnable(app, raise_app_exceptions=False) as client:
async with get_async_remote_runnable(app, raise_app_exceptions=True) as client:
chain_with_history = RunnableWithMessageHistory(
client,
# Uses the get_by_session_id function defined in the example
@@ -2785,14 +3021,14 @@ async def test_remote_configurable_remote_runnable() -> None:
history_messages_key="history",
)
result = await chain_with_history.ainvoke(
{"question": "hi"}, {"configurable": {"session_id": "1"}}
{"question": "hi", "ability": "foo"}, {"configurable": {"session_id": "1"}}
)
assert result == AIMessage(content="Hello World!", id=AnyStr())
assert result == _AnyIdAIMessage(content="Hello World!")
assert store == {
"1": InMemoryHistory(
messages=[
HumanMessage(content="hi"),
AIMessage(content="Hello World!", id=AnyStr()),
_AnyIdAIMessage(content="Hello World!"),
]
)
}
@@ -3066,7 +3302,8 @@ async def test_token_feedback_endpoint() -> None:
local_app, raise_app_exceptions=True
) as async_client:
response = await async_client.post(
"/token_feedback", json={"token_or_url": "some_url", "score": 3}
"/token_feedback",
json={"token_or_url": "some_url", "score": 3},
)
assert response.status_code == 200
@@ -3077,4 +3314,5 @@ async def test_token_feedback_endpoint() -> None:
"metadata": {"from_langserve": True},
"score": 3,
"value": None,
"correction": None,
}
+2 -7
View File
@@ -5,14 +5,9 @@ import pytest
from fastapi import Request
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import ConfigurableField
from pydantic import BaseModel, ValidationError
from langserve.api_handler import _unpack_request_config
try:
from pydantic.v1 import BaseModel, ValidationError
except ImportError:
from pydantic import BaseModel, ValidationError
from langserve.validation import (
create_batch_request_model,
create_invoke_request_model,
@@ -175,7 +170,7 @@ async def test_invoke_request_with_runnables() -> None:
"configurable": {"template": "goodbye {name}"},
},
)
assert request.input == {"name": "bob"}
assert dict(request.input) == {"name": "bob"}
assert request.config.tags == ["hello"]
assert request.config.run_name == "run"
assert isinstance(request.config.configurable, BaseModel)
+27
View File
@@ -0,0 +1,27 @@
from typing import Any
def recursive_dump(obj: Any) -> Any:
"""Recursively dump the object if encountering any pydantic models."""
if isinstance(obj, dict):
return {
k: recursive_dump(v)
for k, v in obj.items()
if k != "id" # Remove the id field for testing purposes
}
if isinstance(obj, list):
return [recursive_dump(v) for v in obj]
if hasattr(obj, "model_dump"):
# if the object contains an ID field, we'll remove it for testing purposes
d = obj.model_dump()
if "id" in d:
d.pop("id")
return recursive_dump(d)
if hasattr(obj, "dict"):
# if the object contains an ID field, we'll remove it for testing purposes
if hasattr(obj, "id"):
d = obj.dict()
d.pop("id")
return recursive_dump(d)
return recursive_dump(obj.dict())
return obj
+16
View File
@@ -1,6 +1,22 @@
from typing import Any
from langchain_core.messages import AIMessage, AIMessageChunk
class AnyStr(str):
def __eq__(self, other: Any) -> bool:
return isinstance(other, str)
def _AnyIdAIMessage(**kwargs: Any) -> AIMessage:
"""Create ai message with an any id field."""
message = AIMessage(**kwargs)
message.id = AnyStr()
return message
def _AnyIdAIMessageChunk(**kwargs: Any) -> AIMessageChunk:
"""Create ai message with an any id field."""
message = AIMessageChunk(**kwargs)
message.id = AnyStr()
return message
+28 -35
View File
@@ -4,11 +4,11 @@ from typing import Any, Dict, List, Optional, Union
from uuid import UUID
from langchain_core.callbacks.base import AsyncCallbackHandler
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.messages import AIMessage, BaseMessage
from langchain_core.outputs import ChatGenerationChunk, GenerationChunk
from tests.unit_tests.utils.llms import GenericFakeChatModel
from tests.unit_tests.utils.stubs import AnyStr
from tests.unit_tests.utils.stubs import _AnyIdAIMessage, _AnyIdAIMessageChunk
def test_generic_fake_chat_model_invoke() -> None:
@@ -16,11 +16,11 @@ def test_generic_fake_chat_model_invoke() -> None:
infinite_cycle = cycle([AIMessage(content="hello"), AIMessage(content="goodbye")])
model = GenericFakeChatModel(messages=infinite_cycle)
response = model.invoke("meow")
assert response == AIMessage(content="hello", id=AnyStr())
assert response == _AnyIdAIMessage(content="hello")
response = model.invoke("kitty")
assert response == AIMessage(content="goodbye", id=AnyStr())
assert response == _AnyIdAIMessage(content="goodbye")
response = model.invoke("meow")
assert response == AIMessage(content="hello", id=AnyStr())
assert response == _AnyIdAIMessage(content="hello")
async def test_generic_fake_chat_model_ainvoke() -> None:
@@ -28,11 +28,11 @@ async def test_generic_fake_chat_model_ainvoke() -> None:
infinite_cycle = cycle([AIMessage(content="hello"), AIMessage(content="goodbye")])
model = GenericFakeChatModel(messages=infinite_cycle)
response = await model.ainvoke("meow")
assert response == AIMessage(content="hello", id=AnyStr())
assert response == _AnyIdAIMessage(content="hello")
response = await model.ainvoke("kitty")
assert response == AIMessage(content="goodbye", id=AnyStr())
assert response == _AnyIdAIMessage(content="goodbye")
response = await model.ainvoke("meow")
assert response == AIMessage(content="hello", id=AnyStr())
assert response == _AnyIdAIMessage(content="hello")
async def test_generic_fake_chat_model_stream() -> None:
@@ -45,28 +45,26 @@ async def test_generic_fake_chat_model_stream() -> None:
model = GenericFakeChatModel(messages=infinite_cycle)
chunks = [chunk async for chunk in model.astream("meow")]
assert chunks == [
AIMessageChunk(content="hello", id=AnyStr()),
AIMessageChunk(content=" ", id=AnyStr()),
AIMessageChunk(content="goodbye", id=AnyStr()),
_AnyIdAIMessageChunk(content="hello"),
_AnyIdAIMessageChunk(content=" "),
_AnyIdAIMessageChunk(content="goodbye"),
]
chunks = [chunk for chunk in model.stream("meow")]
assert chunks == [
AIMessageChunk(content="hello", id=AnyStr()),
AIMessageChunk(content=" ", id=AnyStr()),
AIMessageChunk(content="goodbye", id=AnyStr()),
_AnyIdAIMessageChunk(content="hello"),
_AnyIdAIMessageChunk(content=" "),
_AnyIdAIMessageChunk(content="goodbye"),
]
# Test streaming of additional kwargs.
# Relying on insertion order of the additional kwargs dict
message = AIMessage(
content="", additional_kwargs={"foo": 42, "bar": 24}, id=AnyStr()
)
message = AIMessage(content="", additional_kwargs={"foo": 42, "bar": 24}, id="1")
model = GenericFakeChatModel(messages=cycle([message]))
chunks = [chunk async for chunk in model.astream("meow")]
assert chunks == [
AIMessageChunk(content="", additional_kwargs={"foo": 42}, id=AnyStr()),
AIMessageChunk(content="", additional_kwargs={"bar": 24}, id=AnyStr()),
_AnyIdAIMessageChunk(content="", additional_kwargs={"foo": 42}),
_AnyIdAIMessageChunk(content="", additional_kwargs={"bar": 24}),
]
message = AIMessage(
@@ -83,29 +81,25 @@ async def test_generic_fake_chat_model_stream() -> None:
chunks = [chunk async for chunk in model.astream("meow")]
assert chunks == [
AIMessageChunk(
_AnyIdAIMessageChunk(
content="",
additional_kwargs={"function_call": {"name": "move_file"}},
id=AnyStr(),
),
AIMessageChunk(
_AnyIdAIMessageChunk(
content="",
additional_kwargs={
"function_call": {"arguments": '{\n "source_path": "foo"'}
},
id=AnyStr(),
),
AIMessageChunk(
_AnyIdAIMessageChunk(
content="",
additional_kwargs={"function_call": {"arguments": ","}},
id=AnyStr(),
),
AIMessageChunk(
_AnyIdAIMessageChunk(
content="",
additional_kwargs={
"function_call": {"arguments": '\n "destination_path": "bar"\n}'}
},
id=AnyStr(),
),
]
@@ -116,7 +110,7 @@ async def test_generic_fake_chat_model_stream() -> None:
else:
accumulate_chunks += chunk
assert accumulate_chunks == AIMessageChunk(
assert accumulate_chunks == _AnyIdAIMessageChunk(
content="",
additional_kwargs={
"function_call": {
@@ -125,7 +119,6 @@ async def test_generic_fake_chat_model_stream() -> None:
'destination_path": "bar"\n}',
}
},
id=AnyStr(),
)
@@ -138,9 +131,9 @@ async def test_generic_fake_chat_model_astream_log() -> None:
]
final = log_patches[-1]
assert final.state["streamed_output"] == [
AIMessageChunk(content="hello", id=AnyStr()),
AIMessageChunk(content=" ", id=AnyStr()),
AIMessageChunk(content="goodbye", id=AnyStr()),
_AnyIdAIMessageChunk(content="hello"),
_AnyIdAIMessageChunk(content=" "),
_AnyIdAIMessageChunk(content="goodbye"),
]
@@ -188,8 +181,8 @@ async def test_callback_handlers() -> None:
# New model
results = list(model.stream("meow", {"callbacks": [MyCustomAsyncHandler(tokens)]}))
assert results == [
AIMessageChunk(content="hello", id=AnyStr()),
AIMessageChunk(content=" ", id=AnyStr()),
AIMessageChunk(content="goodbye", id=AnyStr()),
_AnyIdAIMessageChunk(content="hello"),
_AnyIdAIMessageChunk(content=" "),
_AnyIdAIMessageChunk(content="goodbye"),
]
assert tokens == ["hello", " ", "goodbye"]