참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,19 @@
from .iterable import IterableModel
from .maybe import Maybe
from .partial import Partial
from .citation import CitationMixin
from .simple_type import is_simple_type, ModelAdapter
from .response_list import ListResponse, ResponseList
from . import validators # Backwards compatibility module
__all__ = [ # noqa: F405
"CitationMixin",
"IterableModel",
"ListResponse",
"Maybe",
"Partial",
"ResponseList",
"is_simple_type",
"ModelAdapter",
"validators",
]

View File

@@ -0,0 +1,97 @@
from pydantic import BaseModel, Field, model_validator, ValidationInfo
from collections.abc import Generator
class CitationMixin(BaseModel):
"""
Helpful mixing that can use `validation_context={"context": context}` in `from_response` to find the span of the substring_phrase in the context.
## Usage
```python
from pydantic import BaseModel, Field
from instructor import CitationMixin
class User(BaseModel):
name: str = Field(description="The name of the person")
age: int = Field(description="The age of the person")
role: str = Field(description="The role of the person")
context = "Betty was a student. Jason was a student. Jason is 20 years old"
user = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "user",
"content": "Extract jason from {context}",
},
response_model=User,
validation_context={"context": context},
]
)
for quote in user.substring_quotes:
assert quote in context
print(user.model_dump())
```
## Result
```
{
"name": "Jason Liu",
"age": 20,
"role": "student",
"substring_quotes": [
"Jason was a student",
"Jason is 20 years old",
]
}
```
"""
substring_quotes: list[str] = Field(
description="List of unique and specific substrings of the quote that was used to answer the question.",
)
@model_validator(mode="after") # type: ignore[misc]
def validate_sources(self, info: ValidationInfo) -> "CitationMixin":
"""
For each substring_phrase, find the span of the substring_phrase in the context.
If the span is not found, remove the substring_phrase from the list.
"""
if info.context is None:
return self
# Get the context from the info
text_chunks = info.context.get("context", None)
# Get the spans of the substring_phrase in the context
spans = list(self.get_spans(text_chunks))
# Replace the substring_phrase with the actual substring
self.substring_quotes = [text_chunks[span[0] : span[1]] for span in spans]
return self
def _get_span(
self, quote: str, context: str, errs: int = 5
) -> Generator[tuple[int, int], None, None]:
import regex
minor = quote
major = context
errs_ = 0
s = regex.search(f"({minor}){{e<={errs_}}}", major)
while s is None and errs_ <= errs:
errs_ += 1
s = regex.search(f"({minor}){{e<={errs_}}}", major)
if s is not None:
yield from s.spans()
def get_spans(self, context: str) -> Generator[tuple[int, int], None, None]:
for quote in self.substring_quotes:
yield from self._get_span(quote, context)

View File

@@ -0,0 +1,681 @@
from collections.abc import AsyncGenerator, Generator, Iterable
from typing import (
Any,
ClassVar,
Optional,
cast,
get_origin,
get_args,
Union,
TYPE_CHECKING,
)
import json
from pydantic import BaseModel, Field, create_model
from ..mode import Mode
from ..utils import extract_json_from_stream, extract_json_from_stream_async
if TYPE_CHECKING:
pass
class IterableBase:
task_type: ClassVar[Optional[type[BaseModel]]] = None
@classmethod
def from_streaming_response(
cls, completion: Iterable[Any], mode: Mode, **kwargs: Any
) -> Generator[BaseModel, None, None]: # noqa: ARG003
json_chunks = cls.extract_json(completion, mode)
if mode in {Mode.MD_JSON, Mode.GEMINI_TOOLS}:
json_chunks = extract_json_from_stream(json_chunks)
if mode in {Mode.VERTEXAI_TOOLS, Mode.MISTRAL_TOOLS}:
response = next(json_chunks)
if not response:
return
json_response = json.loads(response)
if not json_response["tasks"]:
return
for item in json_response["tasks"]:
yield cls.extract_cls_task_type(json.dumps(item), **kwargs)
yield from cls.tasks_from_chunks(json_chunks, **kwargs)
@classmethod
async def from_streaming_response_async(
cls, completion: AsyncGenerator[Any, None], mode: Mode, **kwargs: Any
) -> AsyncGenerator[BaseModel, None]:
json_chunks = cls.extract_json_async(completion, mode)
if mode in {Mode.MD_JSON, Mode.GEMINI_TOOLS}:
json_chunks = extract_json_from_stream_async(json_chunks)
if mode in {Mode.MISTRAL_TOOLS, Mode.VERTEXAI_TOOLS}:
async for item in cls.tasks_from_mistral_chunks(json_chunks, **kwargs):
yield item
else:
async for item in cls.tasks_from_chunks_async(json_chunks, **kwargs):
yield item
@classmethod
async def tasks_from_mistral_chunks(
cls, json_chunks: AsyncGenerator[str, None], **kwargs: Any
) -> AsyncGenerator[BaseModel, None]:
"""Process streaming chunks from Mistral and VertexAI.
Handles the specific JSON format used by these providers when streaming."""
async for chunk in json_chunks:
if not chunk:
continue
json_response = json.loads(chunk)
if not json_response["tasks"]:
continue
for item in json_response["tasks"]:
obj = cls.extract_cls_task_type(json.dumps(item), **kwargs)
yield obj
@classmethod
def tasks_from_chunks(
cls, json_chunks: Iterable[str], **kwargs: Any
) -> Generator[BaseModel, None, None]:
started = False
potential_object = ""
for chunk in json_chunks:
potential_object += chunk
if not started:
if "[" in chunk:
started = True
potential_object = chunk[chunk.find("[") + 1 :]
while True:
task_json, potential_object = cls.get_object(potential_object, 0)
if task_json:
assert cls.task_type is not None
obj = cls.extract_cls_task_type(task_json, **kwargs)
yield obj
else:
break
@classmethod
async def tasks_from_chunks_async(
cls, json_chunks: AsyncGenerator[str, None], **kwargs: Any
) -> AsyncGenerator[BaseModel, None]:
started = False
potential_object = ""
async for chunk in json_chunks:
potential_object += chunk
if not started:
if "[" in chunk:
started = True
potential_object = chunk[chunk.find("[") + 1 :]
while True:
task_json, potential_object = cls.get_object(potential_object, 0)
if task_json:
assert cls.task_type is not None
obj = cls.extract_cls_task_type(task_json, **kwargs)
yield obj
else:
break
@classmethod
def extract_cls_task_type(
cls,
task_json: str,
**kwargs: Any,
):
assert cls.task_type is not None
if get_origin(cls.task_type) is Union:
union_members = get_args(cls.task_type)
for member in union_members:
try:
obj = member.model_validate_json(task_json, **kwargs)
return obj
except Exception:
pass
else:
return cls.task_type.model_validate_json(task_json, **kwargs)
raise ValueError(
f"Failed to extract task type with {task_json} for {cls.task_type}"
)
@staticmethod
def extract_json(
completion: Iterable[Any], mode: Mode
) -> Generator[str, None, None]:
json_started = False
for chunk in completion:
try:
if mode in {Mode.COHERE_TOOLS, Mode.COHERE_JSON_SCHEMA}:
event_type = getattr(chunk, "event_type", None)
if event_type == "text-generation":
if text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif event_type == "tool-calls-chunk":
delta = getattr(chunk, "tool_call_delta", None)
args = getattr(delta, "parameters", None) or getattr(
delta, "text", None
)
if args:
if not json_started:
json_start = min(
(
pos
for pos in (args.find("{"), args.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
elif text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif event_type == "tool-calls-generation":
tool_calls = getattr(chunk, "tool_calls", None)
if tool_calls:
args = json.dumps(tool_calls[0].parameters)
if not json_started:
json_start = min(
(
pos
for pos in (args.find("{"), args.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
elif text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
else:
chunk_type = getattr(chunk, "type", None)
if chunk_type == "content-delta":
delta = getattr(chunk, "delta", None)
message = getattr(delta, "message", None)
content = getattr(message, "content", None)
if text := getattr(content, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (
text.find("{"),
text.find("["),
)
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif chunk_type == "tool-call-delta":
delta = getattr(chunk, "delta", None)
message = getattr(delta, "message", None)
tool_calls = getattr(message, "tool_calls", None)
function = getattr(tool_calls, "function", None)
if args := getattr(function, "arguments", None):
if not json_started:
json_start = min(
(
pos
for pos in (
args.find("{"),
args.find("["),
)
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
if mode == Mode.ANTHROPIC_JSON:
if json_chunk := chunk.delta.text:
yield json_chunk
if mode == Mode.ANTHROPIC_TOOLS:
yield chunk.delta.partial_json
if mode == Mode.GEMINI_JSON:
yield chunk.text
if mode == Mode.VERTEXAI_JSON:
yield chunk.candidates[0].content.parts[0].text
if mode == Mode.VERTEXAI_TOOLS:
yield json.dumps(
chunk.candidates[0].content.parts[0].function_call.args
)
if mode == Mode.MISTRAL_STRUCTURED_OUTPUTS:
yield chunk.data.choices[0].delta.content
if mode == Mode.MISTRAL_TOOLS:
if not chunk.data.choices[0].delta.tool_calls:
continue
yield chunk.data.choices[0].delta.tool_calls[0].function.arguments
if mode in {Mode.GENAI_TOOLS}:
yield json.dumps(
chunk.candidates[0].content.parts[0].function_call.args
)
if mode in {Mode.GENAI_STRUCTURED_OUTPUTS}:
yield chunk.candidates[0].content.parts[0].text
if mode in {Mode.GEMINI_TOOLS}:
resp = chunk.candidates[0].content.parts[0].function_call
resp_dict = type(resp).to_dict(resp) # type:ignore
if "args" in resp_dict:
yield json.dumps(resp_dict["args"])
if mode in {
Mode.RESPONSES_TOOLS,
Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS,
}:
from openai.types.responses import (
ResponseFunctionCallArgumentsDeltaEvent,
)
if isinstance(chunk, ResponseFunctionCallArgumentsDeltaEvent):
yield chunk.delta
elif chunk.choices:
if mode == Mode.FUNCTIONS:
Mode.warn_mode_functions_deprecation()
if json_chunk := chunk.choices[0].delta.function_call.arguments:
yield json_chunk
elif mode in {
Mode.JSON,
Mode.MD_JSON,
Mode.JSON_SCHEMA,
Mode.CEREBRAS_JSON,
Mode.FIREWORKS_JSON,
Mode.PERPLEXITY_JSON,
Mode.WRITER_JSON,
}:
if json_chunk := chunk.choices[0].delta.content:
yield json_chunk
elif mode in {
Mode.TOOLS,
Mode.TOOLS_STRICT,
Mode.FIREWORKS_TOOLS,
Mode.WRITER_TOOLS,
}:
if json_chunk := chunk.choices[0].delta.tool_calls:
if json_chunk[0].function.arguments is not None:
yield json_chunk[0].function.arguments
else:
raise NotImplementedError(
f"Mode {mode} is not supported for MultiTask streaming"
)
except AttributeError:
pass
@staticmethod
async def extract_json_async(
completion: AsyncGenerator[Any, None], mode: Mode
) -> AsyncGenerator[str, None]:
json_started = False
async for chunk in completion:
try:
if mode in {Mode.COHERE_TOOLS, Mode.COHERE_JSON_SCHEMA}:
event_type = getattr(chunk, "event_type", None)
if event_type == "text-generation":
if text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif event_type == "tool-calls-chunk":
delta = getattr(chunk, "tool_call_delta", None)
args = getattr(delta, "parameters", None) or getattr(
delta, "text", None
)
if args:
if not json_started:
json_start = min(
(
pos
for pos in (args.find("{"), args.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
elif text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif event_type == "tool-calls-generation":
tool_calls = getattr(chunk, "tool_calls", None)
if tool_calls:
args = json.dumps(tool_calls[0].parameters)
if not json_started:
json_start = min(
(
pos
for pos in (args.find("{"), args.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
elif text := getattr(chunk, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (text.find("{"), text.find("["))
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
else:
chunk_type = getattr(chunk, "type", None)
if chunk_type == "content-delta":
delta = getattr(chunk, "delta", None)
message = getattr(delta, "message", None)
content = getattr(message, "content", None)
if text := getattr(content, "text", None):
if not json_started:
json_start = min(
(
pos
for pos in (
text.find("{"),
text.find("["),
)
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
text = text[json_start:]
yield text
elif chunk_type == "tool-call-delta":
delta = getattr(chunk, "delta", None)
message = getattr(delta, "message", None)
tool_calls = getattr(message, "tool_calls", None)
function = getattr(tool_calls, "function", None)
if args := getattr(function, "arguments", None):
if not json_started:
json_start = min(
(
pos
for pos in (
args.find("{"),
args.find("["),
)
if pos != -1
),
default=-1,
)
if json_start == -1:
continue
json_started = True
args = args[json_start:]
yield args
if mode == Mode.ANTHROPIC_JSON:
if json_chunk := chunk.delta.text:
yield json_chunk
if mode == Mode.ANTHROPIC_TOOLS:
yield chunk.delta.partial_json
if mode == Mode.VERTEXAI_JSON:
yield chunk.candidates[0].content.parts[0].text
if mode == Mode.VERTEXAI_TOOLS:
yield json.dumps(
chunk.candidates[0].content.parts[0].function_call.args
)
if mode == Mode.MISTRAL_STRUCTURED_OUTPUTS:
yield chunk.data.choices[0].delta.content
if mode == Mode.MISTRAL_TOOLS:
if not chunk.data.choices[0].delta.tool_calls:
continue
yield chunk.data.choices[0].delta.tool_calls[0].function.arguments
if mode == Mode.GENAI_STRUCTURED_OUTPUTS:
yield chunk.text
if mode in {Mode.GENAI_TOOLS}:
yield json.dumps(
chunk.candidates[0].content.parts[0].function_call.args
)
if mode in {
Mode.RESPONSES_TOOLS,
Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS,
}:
from openai.types.responses import (
ResponseFunctionCallArgumentsDeltaEvent,
)
if isinstance(chunk, ResponseFunctionCallArgumentsDeltaEvent):
yield chunk.delta
elif chunk.choices:
if mode == Mode.FUNCTIONS:
Mode.warn_mode_functions_deprecation()
if json_chunk := chunk.choices[0].delta.function_call.arguments:
yield json_chunk
elif mode in {
Mode.JSON,
Mode.MD_JSON,
Mode.JSON_SCHEMA,
Mode.CEREBRAS_JSON,
Mode.FIREWORKS_JSON,
Mode.PERPLEXITY_JSON,
Mode.WRITER_JSON,
}:
if json_chunk := chunk.choices[0].delta.content:
yield json_chunk
elif mode in {
Mode.TOOLS,
Mode.TOOLS_STRICT,
Mode.FIREWORKS_TOOLS,
Mode.WRITER_TOOLS,
}:
if json_chunk := chunk.choices[0].delta.tool_calls:
if json_chunk[0].function.arguments is not None:
yield json_chunk[0].function.arguments
else:
raise NotImplementedError(
f"Mode {mode} is not supported for MultiTask streaming"
)
except AttributeError:
pass
@staticmethod
def get_object(s: str, stack: int) -> tuple[Optional[str], str]:
start_index = s.find("{")
for i, c in enumerate(s):
if c == "{":
stack += 1
if c == "}":
stack -= 1
if stack == 0:
return s[start_index : i + 1], s[i + 2 :]
return None, s
def IterableModel(
subtask_class: type[BaseModel],
name: Optional[str] = None,
description: Optional[str] = None,
) -> type[BaseModel]:
# Import at runtime to avoid circular import
from ..processing.function_calls import OpenAISchema
"""
Dynamically create a IterableModel OpenAISchema that can be used to segment multiple
tasks given a base class. This creates class that can be used to create a toolkit
for a specific task, names and descriptions are automatically generated. However
they can be overridden.
## Usage
```python
from pydantic import BaseModel, Field
from instructor import IterableModel
class User(BaseModel):
name: str = Field(description="The name of the person")
age: int = Field(description="The age of the person")
role: str = Field(description="The role of the person")
MultiUser = IterableModel(User)
```
## Result
```python
class MultiUser(OpenAISchema, MultiTaskBase):
tasks: List[User] = Field(
default_factory=list,
repr=False,
description="Correctly segmented list of `User` tasks",
)
@classmethod
def from_streaming_response(cls, completion) -> Generator[User]:
'''
Parse the streaming response from OpenAI and yield a `User` object
for each task in the response
'''
json_chunks = cls.extract_json(completion)
yield from cls.tasks_from_chunks(json_chunks)
```
Parameters:
subtask_class (Type[OpenAISchema]): The base class to use for the MultiTask
name (Optional[str]): The name of the MultiTask class, if None then the name
of the subtask class is used as `Multi{subtask_class.__name__}`
description (Optional[str]): The description of the MultiTask class, if None
then the description is set to `Correct segmentation of `{subtask_class.__name__}` tasks`
Returns:
schema (OpenAISchema): A new class that can be used to segment multiple tasks
"""
if name is not None:
task_name = name
else:
# Handle `Union[A, B]` / `A | B` task types.
# `types.UnionType` does not have `__name__`, so fall back to a stable name.
task_name = getattr(subtask_class, "__name__", None)
if task_name is None and get_origin(subtask_class) is Union:
members = get_args(subtask_class)
task_name = "Or".join(getattr(m, "__name__", str(m)) for m in members)
if task_name is None:
task_name = str(subtask_class)
name = f"Iterable{task_name}"
list_tasks = (
list[subtask_class], # type: ignore
Field(
default_factory=list,
repr=False,
description=f"Correctly segmented list of `{task_name}` tasks",
),
)
base_models = cast(tuple[type[BaseModel], ...], (OpenAISchema, IterableBase))
new_cls = create_model(
name,
tasks=list_tasks,
__base__=base_models,
)
new_cls = cast(type[IterableBase], new_cls)
# set the class constructor BaseModel
new_cls.task_type = subtask_class
new_cls.__doc__ = (
f"Correct segmentation of `{task_name}` tasks"
if description is None
else description
)
assert issubclass(new_cls, OpenAISchema), (
"The new class should be a subclass of OpenAISchema"
)
return new_cls

View File

@@ -0,0 +1,138 @@
"""
JSON Completeness Tracker for Partial Streaming.
Tracks which parts of accumulated JSON are "closed" (complete) vs "open" (incomplete).
Uses jiter for parsing and a simple heuristic: if a value has a next sibling,
it must be complete (because jiter had to finish parsing it to find the next one).
"""
from __future__ import annotations
from typing import Any
from jiter import from_json
def is_json_complete(json_str: str) -> bool:
"""
Check if a JSON string represents a complete structure.
Uses jiter in strict mode - parsing fails if JSON is incomplete.
"""
if not json_str or not json_str.strip():
return False
try:
from_json(json_str.encode()) # No partial_mode = strict parsing
return True
except ValueError:
return False
class JsonCompleteness:
"""
Track completeness of JSON structures during streaming.
Uses a simple heuristic: if a value has a next sibling in the parsed
structure, it must be complete. For the last sibling, we don't know
until the parent completes - but that's fine because parent validation
will cover it.
Example:
tracker = JsonCompleteness()
# Incomplete - missing closing brace
tracker.analyze('{"name": "Alice", "address": {"city": "NY')
tracker.is_path_complete("") # False - root incomplete
tracker.is_path_complete("name") # True - has next sibling "address"
tracker.is_path_complete("address") # False - last sibling, unknown
# Complete
tracker.analyze('{"name": "Alice"}')
tracker.is_path_complete("") # True - root complete
"""
def __init__(self) -> None:
self._complete_paths: set[str] = set()
def analyze(self, json_str: str) -> None:
"""Analyze a JSON string and determine completeness of each path."""
self._complete_paths = set()
if not json_str or not json_str.strip():
return
# Try strict parsing first - if it succeeds, JSON is complete
try:
parsed = from_json(json_str.encode())
self._mark_all(parsed, "")
return
except ValueError:
pass # JSON is incomplete, continue with partial parsing
# Root incomplete - use sibling heuristic
try:
parsed = from_json(json_str.encode(), partial_mode="trailing-strings")
except ValueError:
return
self._check_siblings(parsed, "")
def _mark_all(self, data: Any, path: str) -> None:
"""Recursively mark path and all children as complete."""
self._complete_paths.add(path)
if isinstance(data, dict):
for key, value in data.items():
child_path = f"{path}.{key}" if path else key
self._mark_all(value, child_path)
elif isinstance(data, list):
for i, item in enumerate(data):
self._mark_all(item, f"{path}[{i}]")
def _check_siblings(self, data: Any, path: str) -> None:
"""
Check completeness using sibling heuristic.
If a value has a next sibling, it's complete (jiter had to finish
parsing it to find the next sibling). Last sibling is unknown.
"""
if isinstance(data, dict):
keys = list(data.keys())
for i, key in enumerate(keys):
child_path = f"{path}.{key}" if path else key
if i < len(keys) - 1:
# Has next sibling → complete
self._mark_all(data[key], child_path)
else:
# Last sibling → recurse to check children
self._check_siblings(data[key], child_path)
elif isinstance(data, list):
for i, item in enumerate(data):
child_path = f"{path}[{i}]"
if i < len(data) - 1:
# Has next sibling → complete
self._mark_all(item, child_path)
else:
# Last sibling → recurse
self._check_siblings(item, child_path)
def is_path_complete(self, path: str) -> bool:
"""
Check if the sub-structure at the given path is complete.
Args:
path: Dot-separated path (e.g., "user.address.city", "items[0]")
Use "" for root object.
Returns:
True if the structure at path is complete (closed), False otherwise.
"""
return path in self._complete_paths
def get_complete_paths(self) -> set[str]:
"""Return all paths that are complete."""
return self._complete_paths.copy()
def is_root_complete(self) -> bool:
"""Check if the root JSON structure is complete."""
return "" in self._complete_paths

View File

@@ -0,0 +1,74 @@
from pydantic import BaseModel, Field, create_model
from typing import Generic, Optional, TypeVar
T = TypeVar("T", bound=BaseModel)
class MaybeBase(BaseModel, Generic[T]):
"""
Extract a result from a model, if any, otherwise set the error and message fields.
"""
result: Optional[T]
error: bool = Field(default=False)
message: Optional[str]
def __bool__(self) -> bool:
return self.result is not None
def Maybe(model: type[T]) -> type[MaybeBase[T]]:
"""
Create a Maybe model for a given Pydantic model. This allows you to return a model that includes fields for `result`, `error`, and `message` for sitatations where the data may not be present in the context.
## Usage
```python
from pydantic import BaseModel, Field
from instructor import Maybe
class User(BaseModel):
name: str = Field(description="The name of the person")
age: int = Field(description="The age of the person")
role: str = Field(description="The role of the person")
MaybeUser = Maybe(User)
```
## Result
```python
class MaybeUser(BaseModel):
result: Optional[User]
error: bool = Field(default=False)
message: Optional[str]
def __bool__(self):
return self.result is not None
```
Parameters:
model (Type[BaseModel]): The Pydantic model to wrap with Maybe.
Returns:
MaybeModel (Type[BaseModel]): A new Pydantic model that includes fields for `result`, `error`, and `message`.
"""
return create_model(
f"Maybe{model.__name__}",
__base__=MaybeBase,
result=(
Optional[model],
Field(
default=None,
description="Correctly extracted result from the model, if any, otherwise None",
),
),
error=(bool, Field(default=False)),
message=(
Optional[str],
Field(
default=None,
description="Error message if no result was found, should be short and concise",
),
),
)

View File

@@ -0,0 +1,173 @@
import sys
import json
from typing import (
Any,
Optional,
TypeVar,
Union,
get_args,
get_origin,
TYPE_CHECKING,
)
from collections.abc import Generator
from pydantic import BaseModel
from collections.abc import Iterable
from ..mode import Mode
if TYPE_CHECKING:
from ..processing.function_calls import OpenAISchema
T = TypeVar("T", bound=OpenAISchema)
else:
# At runtime, we'll bind to BaseModel instead to avoid circular import
T = TypeVar("T", bound=BaseModel)
class ParallelBase:
def __init__(self, *models: type[BaseModel]):
# Note that for everything else we've created a class, but for parallel base it is an instance
assert len(models) > 0, "At least one model is required"
self.models = models
self.registry = {
model.__name__ if hasattr(model, "__name__") else str(model): model
for model in models
}
def from_response(
self,
response: Any,
mode: Mode,
validation_context: Optional[Any] = None,
strict: Optional[bool] = None,
) -> Generator[BaseModel, None, None]:
#! We expect this from the OpenAISchema class, We should address
#! this with a protocol or an abstract class... @jxnlco
assert mode == Mode.PARALLEL_TOOLS, "Mode must be PARALLEL_TOOLS"
for tool_call in response.choices[0].message.tool_calls:
name = tool_call.function.name
arguments = tool_call.function.arguments
yield self.registry[name].model_validate_json(
arguments, context=validation_context, strict=strict
)
class VertexAIParallelBase(ParallelBase):
def from_response(
self,
response: Any,
mode: Mode,
validation_context: Optional[Any] = None,
strict: Optional[bool] = None,
) -> Generator[BaseModel, None, None]:
assert mode == Mode.VERTEXAI_PARALLEL_TOOLS, (
"Mode must be VERTEXAI_PARALLEL_TOOLS"
)
if not response or not response.candidates:
return
for candidate in response.candidates:
if not candidate.content or not candidate.content.parts:
continue
for part in candidate.content.parts:
if hasattr(part, "function_call") and part.function_call is not None:
name = part.function_call.name
arguments = part.function_call.args
if name in self.registry:
# Convert dict to JSON string before validation
json_str = json.dumps(arguments)
yield self.registry[name].model_validate_json(
json_str, context=validation_context, strict=strict
)
if sys.version_info >= (3, 10):
from types import UnionType
def is_union_type(typehint: type[Iterable[T]]) -> bool:
return get_origin(get_args(typehint)[0]) in (Union, UnionType)
else:
def is_union_type(typehint: type[Iterable[T]]) -> bool:
return get_origin(get_args(typehint)[0]) is Union
def get_types_array(typehint: type[Iterable[T]]) -> tuple[type[T], ...]:
should_be_iterable = get_origin(typehint)
if should_be_iterable is not Iterable:
raise TypeError(f"Model should be with Iterable instead of {typehint}")
if is_union_type(typehint):
# works for Iterable[Union[int, str]], Iterable[int | str]
the_types = get_args(get_args(typehint)[0])
return the_types
# works for Iterable[int]
return get_args(typehint)
def handle_parallel_model(typehint: type[Iterable[T]]) -> list[dict[str, Any]]:
# Import at runtime to avoid circular import
from ..processing.function_calls import openai_schema
the_types = get_types_array(typehint)
return [
{"type": "function", "function": openai_schema(model).openai_schema}
for model in the_types
]
def handle_anthropic_parallel_model(
typehint: type[Iterable[T]],
) -> list[dict[str, Any]]:
# Import at runtime to avoid circular import
from ..processing.function_calls import openai_schema
the_types = get_types_array(typehint)
return [openai_schema(model).anthropic_schema for model in the_types]
def ParallelModel(typehint: type[Iterable[T]]) -> ParallelBase:
the_types = get_types_array(typehint)
return ParallelBase(*[model for model in the_types])
def VertexAIParallelModel(typehint: type[Iterable[T]]) -> VertexAIParallelBase:
the_types = get_types_array(typehint)
return VertexAIParallelBase(*[model for model in the_types])
class AnthropicParallelBase(ParallelBase):
def from_response(
self,
response: Any,
mode: Mode,
validation_context: Optional[Any] = None,
strict: Optional[bool] = None,
) -> Generator[BaseModel, None, None]:
assert mode == Mode.ANTHROPIC_PARALLEL_TOOLS, (
"Mode must be ANTHROPIC_PARALLEL_TOOLS"
)
if not response or not hasattr(response, "content"):
return
for content in response.content:
if getattr(content, "type", None) == "tool_use":
name = content.name
arguments = content.input
if name in self.registry:
json_str = json.dumps(arguments)
yield self.registry[name].model_validate_json(
json_str, context=validation_context, strict=strict
)
def AnthropicParallelModel(typehint: type[Iterable[T]]) -> AnthropicParallelBase:
the_types = get_types_array(typehint)
return AnthropicParallelBase(*[model for model in the_types])

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
"""List-like response wrapper.
When a response model returns a list (for example `list[User]`), we still want to
attach the provider's raw response so `create_with_completion()` can return it.
"""
from __future__ import annotations
from typing import Any, Generic, TypeVar
T = TypeVar("T")
class ListResponse(list[T], Generic[T]):
"""A list that preserves the underlying provider response.
This is used when a call returns a list of objects (e.g. `list[User]`), so
`create_with_completion()` can still return `(result, raw_response)` without
crashing on a plain `list`.
"""
_raw_response: Any | None
def __init__(self, iterable=(), _raw_response: Any | None = None): # type: ignore[no-untyped-def]
super().__init__(iterable)
self._raw_response = _raw_response
@classmethod
def from_list(cls, items: list[T], *, raw_response: Any | None) -> ListResponse[T]:
return cls(items, _raw_response=raw_response)
def get_raw_response(self) -> Any | None:
return self._raw_response
def __getitem__(self, key): # type: ignore[no-untyped-def]
value = super().__getitem__(key)
if isinstance(key, slice):
return type(self)(value, _raw_response=self._raw_response)
return value
# Backwards-friendly alias
ResponseList = ListResponse

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from inspect import isclass
import typing
from pydantic import BaseModel, create_model
from enum import Enum
from typing import TYPE_CHECKING
from instructor.dsl.partial import Partial
if TYPE_CHECKING:
pass
T = typing.TypeVar("T")
class AdapterBase(BaseModel):
pass
class ModelAdapter(typing.Generic[T]):
"""
Accepts a response model and returns a BaseModel with the response model as the content.
"""
def __class_getitem__(cls, response_model: type[BaseModel]) -> type[BaseModel]:
# Import at runtime to avoid circular import
from ..processing.function_calls import OpenAISchema
assert is_simple_type(response_model), "Only simple types are supported"
return create_model(
"Response",
content=(response_model, ...),
__doc__="Correctly Formatted and Extracted Response.",
__base__=(AdapterBase, OpenAISchema),
)
def validateIsSubClass(response_model: type):
"""
Temporary guard against issues with generics in Python 3.9
"""
import sys
if sys.version_info < (3, 10):
if len(typing.get_args(response_model)) == 0:
return False
return issubclass(typing.get_args(response_model)[0], BaseModel)
try:
# Add a guard here to prevent issues with GenericAlias
import types
if isinstance(response_model, types.GenericAlias):
return False
except Exception:
pass
return issubclass(response_model, BaseModel)
def is_simple_type(
response_model: type[BaseModel] | str | int | float | bool | typing.Any,
) -> bool:
# ! we're getting mixes between classes and instances due to how we handle some
# ! response model types, we should fix this in later PRs
# Special case for Python 3.9: Directly handle list[Union[int, str]] pattern
import sys
if sys.version_info < (3, 10):
# Check if it's a list type with Union arguments using string representation
if str(response_model).startswith("list[typing.Union[") or "list[Union[" in str(
response_model
):
return True
try:
if isclass(response_model) and validateIsSubClass(response_model):
return False
except TypeError:
# ! In versions < 3.11, typing.Iterable is not a class, so we can't use isclass
# ! for now if `response_model` is an Iterable isclass and issubclass will raise
# ! TypeError, so we need to check if `response_model` is an Iterable
# ! This is a workaround for now, we should fix this in later PRs
return False
# Get the origin of the response model
origin = typing.get_origin(response_model)
# Handle special case for list[int | str], list[Union[int, str]] or similar type patterns
# Identify a list type by checking for various origins it might have
if origin in {typing.Iterable, Partial, list}:
# For list types, check the contents before deciding
if origin is list:
# Extract the inner types from the list
args = typing.get_args(response_model)
if args and len(args) == 1:
inner_arg = args[0]
# Special handling for Union types
inner_origin = typing.get_origin(inner_arg)
# Explicit check for Union types - try different patterns across Python versions
if (
inner_origin is typing.Union
or inner_origin == typing.Union
or str(inner_origin) == "typing.Union"
or str(type(inner_arg)) == "<class 'typing._UnionGenericAlias'>"
):
return True
# Check for Python 3.10+ pipe syntax
if hasattr(inner_arg, "__or__"):
return True
# For simple list with basic types, also return True
if inner_arg in {str, int, float, bool}:
return True
# Check if inner type is a BaseModel - if so, not a simple type
try:
if isclass(inner_arg) and issubclass(inner_arg, BaseModel):
return False
except TypeError:
pass
# If no args or unknown pattern, treat as simple list
return len(args) == 0
# Extract the inner types from the list for other iterable types
args = typing.get_args(response_model)
if args and len(args) == 1:
inner_arg = args[0]
# Special handling for Union types
inner_origin = typing.get_origin(inner_arg)
# Explicit check for Union types - try different patterns across Python versions
if (
inner_origin is typing.Union
or inner_origin == typing.Union
or str(inner_origin) == "typing.Union"
or str(type(inner_arg)) == "<class 'typing._UnionGenericAlias'>"
):
return True
# Check for Python 3.10+ pipe syntax
if hasattr(inner_arg, "__or__"):
return True
# For simple list with basic types, also return True
if inner_arg in {str, int, float, bool}:
return True
# For other iterable patterns, return False (e.g., streaming types)
return False
if response_model in {
str,
int,
float,
bool,
}:
return True
# If the response_model is a simple type like annotated
if origin in {
typing.Annotated,
typing.Literal,
typing.Union,
list, # origin of List[T] is list
}:
return True
if isclass(response_model) and issubclass(response_model, Enum):
return True
return False

View File

@@ -0,0 +1,20 @@
"""Backwards compatibility module for instructor.dsl.validators.
This module provides lazy imports to avoid circular import issues.
"""
def __getattr__(name: str):
"""Lazy import to avoid circular dependencies."""
from ..processing import validators as processing_validators
from .. import validation
# Try processing.validators first
if hasattr(processing_validators, name):
return getattr(processing_validators, name)
# Then try validation module
if hasattr(validation, name):
return getattr(validation, name)
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")