817 lines
30 KiB
Python
817 lines
30 KiB
Python
# type: ignore
|
|
import json
|
|
import logging
|
|
import re
|
|
from functools import wraps
|
|
from typing import Annotated, Any, Optional, TypeVar, cast
|
|
from openai.types.chat import ChatCompletion
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
TypeAdapter,
|
|
create_model,
|
|
)
|
|
|
|
from ..core.exceptions import (
|
|
IncompleteOutputException,
|
|
ResponseParsingError,
|
|
ConfigurationError,
|
|
)
|
|
from ..mode import Mode
|
|
from ..utils import (
|
|
classproperty,
|
|
extract_json_from_codeblock,
|
|
)
|
|
from .schema import (
|
|
generate_openai_schema,
|
|
generate_anthropic_schema,
|
|
generate_gemini_schema,
|
|
)
|
|
|
|
|
|
T = TypeVar("T")
|
|
Model = TypeVar("Model", bound=BaseModel)
|
|
|
|
logger = logging.getLogger("instructor")
|
|
|
|
# No schema cache
|
|
|
|
|
|
# Utility functions for common JSON parsing operations
|
|
def _handle_incomplete_output(completion: Any) -> None:
|
|
"""Check if a completion was incomplete and raise appropriate exception."""
|
|
if (
|
|
hasattr(completion, "choices")
|
|
and completion.choices[0].finish_reason == "length"
|
|
):
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
|
|
# Handle Anthropic format
|
|
if hasattr(completion, "stop_reason") and completion.stop_reason == "max_tokens":
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
|
|
|
|
def _extract_text_content(completion: Any) -> str:
|
|
"""Extract text content from various completion formats."""
|
|
# OpenAI format
|
|
if hasattr(completion, "choices"):
|
|
return completion.choices[0].message.content or ""
|
|
|
|
# Simple text format
|
|
if hasattr(completion, "text"):
|
|
return completion.text
|
|
|
|
# Anthropic format
|
|
if hasattr(completion, "content"):
|
|
text_blocks = [c for c in completion.content if c.type == "text"]
|
|
if text_blocks:
|
|
return text_blocks[0].text
|
|
|
|
# Bedrock format
|
|
if isinstance(completion, dict) and "output" in completion:
|
|
try:
|
|
return completion.get("output").get("message").get("content")[0].get("text")
|
|
except (AttributeError, IndexError):
|
|
pass
|
|
|
|
return ""
|
|
|
|
|
|
def _validate_model_from_json(
|
|
cls: type[Any],
|
|
json_str: str,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> Any:
|
|
"""Validate model from JSON string with appropriate error handling."""
|
|
try:
|
|
if hasattr(cls, "model_validate_json"):
|
|
if strict:
|
|
return cls.model_validate_json(
|
|
json_str, context=validation_context, strict=True
|
|
)
|
|
# Allow control characters
|
|
parsed = json.loads(json_str, strict=False)
|
|
return cls.model_validate(parsed, context=validation_context, strict=False)
|
|
|
|
adapter = TypeAdapter(cls)
|
|
if strict:
|
|
return adapter.validate_json(
|
|
json_str, context=validation_context, strict=True
|
|
)
|
|
parsed = json.loads(json_str, strict=False)
|
|
return adapter.validate_python(parsed, context=validation_context, strict=False)
|
|
except json.JSONDecodeError as e:
|
|
logger.debug(f"JSON decode error: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.debug(f"Model validation error: {e}")
|
|
raise
|
|
|
|
|
|
class OpenAISchema(BaseModel):
|
|
# Ignore classproperty, since Pydantic doesn't understand it like it would a normal property.
|
|
model_config = ConfigDict(ignored_types=(classproperty,))
|
|
|
|
@classproperty
|
|
def openai_schema(cls) -> dict[str, Any]:
|
|
"""
|
|
Return the schema in the format of OpenAI's schema as jsonschema
|
|
|
|
Note:
|
|
Its important to add a docstring to describe how to best use this class, it will be included in the description attribute and be part of the prompt.
|
|
|
|
Returns:
|
|
model_json_schema (dict): A dictionary in the format of OpenAI's schema as jsonschema
|
|
"""
|
|
return generate_openai_schema(cls)
|
|
|
|
@classproperty
|
|
def anthropic_schema(cls) -> dict[str, Any]:
|
|
# Generate the Anthropic schema based on the OpenAI schema to avoid redundant schema generation
|
|
return generate_anthropic_schema(cls)
|
|
|
|
@classproperty
|
|
def gemini_schema(cls) -> Any:
|
|
# This is kept for backward compatibility but deprecated
|
|
return generate_gemini_schema(cls)
|
|
|
|
@classmethod
|
|
def from_response(
|
|
cls,
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
mode: Mode = Mode.TOOLS,
|
|
) -> BaseModel:
|
|
"""Execute the function from the response of an openai chat completion
|
|
|
|
Parameters:
|
|
completion (openai.ChatCompletion): The response from an openai chat completion
|
|
strict (bool): Whether to use strict json parsing
|
|
mode (Mode): The openai completion mode
|
|
|
|
Returns:
|
|
cls (OpenAISchema): An instance of the class
|
|
"""
|
|
|
|
if mode in {Mode.ANTHROPIC_TOOLS, Mode.ANTHROPIC_REASONING_TOOLS}:
|
|
return cls.parse_anthropic_tools(completion, validation_context, strict)
|
|
|
|
if mode == Mode.ANTHROPIC_JSON:
|
|
return cls.parse_anthropic_json(completion, validation_context, strict)
|
|
|
|
if mode == Mode.BEDROCK_JSON:
|
|
return cls.parse_bedrock_json(completion, validation_context, strict)
|
|
|
|
if mode == Mode.BEDROCK_TOOLS:
|
|
return cls.parse_bedrock_tools(completion, validation_context, strict)
|
|
|
|
if mode in {Mode.VERTEXAI_TOOLS, Mode.GEMINI_TOOLS}:
|
|
return cls.parse_vertexai_tools(completion, validation_context)
|
|
|
|
if mode == Mode.VERTEXAI_JSON:
|
|
return cls.parse_vertexai_json(completion, validation_context, strict)
|
|
|
|
if mode == Mode.COHERE_TOOLS:
|
|
return cls.parse_cohere_tools(completion, validation_context, strict)
|
|
|
|
if mode == Mode.GEMINI_JSON:
|
|
return cls.parse_gemini_json(completion, validation_context, strict)
|
|
|
|
if mode == Mode.GENAI_STRUCTURED_OUTPUTS:
|
|
return cls.parse_genai_structured_outputs(
|
|
completion, validation_context, strict
|
|
)
|
|
|
|
if mode == Mode.GEMINI_TOOLS:
|
|
return cls.parse_gemini_tools(completion, validation_context, strict)
|
|
|
|
if mode == Mode.GENAI_TOOLS:
|
|
return cls.parse_genai_tools(completion, validation_context, strict)
|
|
|
|
if mode == Mode.COHERE_JSON_SCHEMA:
|
|
return cls.parse_cohere_json_schema(completion, validation_context, strict)
|
|
|
|
if mode == Mode.WRITER_TOOLS:
|
|
return cls.parse_writer_tools(completion, validation_context, strict)
|
|
|
|
if mode == Mode.WRITER_JSON:
|
|
return cls.parse_writer_json(completion, validation_context, strict)
|
|
|
|
if mode in {Mode.RESPONSES_TOOLS, Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS}:
|
|
return cls.parse_responses_tools(
|
|
completion,
|
|
validation_context,
|
|
strict,
|
|
)
|
|
|
|
if not completion.choices:
|
|
# This helps catch errors from OpenRouter
|
|
if hasattr(completion, "error"):
|
|
raise ResponseParsingError(
|
|
f"LLM provider returned error: {completion.error}",
|
|
mode=str(mode),
|
|
raw_response=completion,
|
|
)
|
|
|
|
raise ResponseParsingError(
|
|
"No completion choices found in LLM response",
|
|
mode=str(mode),
|
|
raw_response=completion,
|
|
)
|
|
|
|
if completion.choices[0].finish_reason == "length":
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
|
|
if mode == Mode.FUNCTIONS:
|
|
Mode.warn_mode_functions_deprecation()
|
|
return cls.parse_functions(completion, validation_context, strict)
|
|
|
|
if mode == Mode.MISTRAL_STRUCTURED_OUTPUTS:
|
|
return cls.parse_mistral_structured_outputs(
|
|
completion, validation_context, strict
|
|
)
|
|
|
|
if mode in {
|
|
Mode.TOOLS,
|
|
Mode.MISTRAL_TOOLS,
|
|
Mode.TOOLS_STRICT,
|
|
Mode.CEREBRAS_TOOLS,
|
|
Mode.FIREWORKS_TOOLS,
|
|
}:
|
|
return cls.parse_tools(completion, validation_context, strict)
|
|
|
|
if mode in {
|
|
Mode.JSON,
|
|
Mode.JSON_SCHEMA,
|
|
Mode.MD_JSON,
|
|
Mode.JSON_O1,
|
|
Mode.CEREBRAS_JSON,
|
|
Mode.FIREWORKS_JSON,
|
|
Mode.PERPLEXITY_JSON,
|
|
Mode.OPENROUTER_STRUCTURED_OUTPUTS,
|
|
}:
|
|
return cls.parse_json(completion, validation_context, strict)
|
|
|
|
raise ConfigurationError(
|
|
f"Invalid or unsupported mode: {mode}. This mode may not be implemented for response parsing."
|
|
)
|
|
|
|
@classmethod
|
|
def parse_genai_structured_outputs(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
from google.genai import types
|
|
|
|
if (
|
|
hasattr(completion, "candidates")
|
|
and completion.candidates
|
|
and completion.candidates[0].finish_reason == types.FinishReason.MAX_TOKENS
|
|
):
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
|
|
return cls.model_validate_json(
|
|
completion.text, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_genai_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
from google.genai import types
|
|
|
|
assert isinstance(completion, types.GenerateContentResponse)
|
|
assert len(completion.candidates) == 1
|
|
|
|
# Filter out thought parts (parts with thought: true)
|
|
parts = completion.candidates[0].content.parts
|
|
non_thought_parts = [
|
|
part for part in parts if not (hasattr(part, "thought") and part.thought)
|
|
]
|
|
|
|
assert len(non_thought_parts) == 1, (
|
|
f"Instructor does not support multiple function calls, use List[Model] instead"
|
|
)
|
|
function_call = non_thought_parts[0].function_call
|
|
assert function_call is not None, (
|
|
f"Please return your response as a function call with the schema {cls.openai_schema} and the name {cls.openai_schema['name']}"
|
|
)
|
|
|
|
assert function_call.name == cls.openai_schema["name"]
|
|
return cls.model_validate(
|
|
obj=function_call.args, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_cohere_json_schema(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
):
|
|
# Handle both V1 and V2 response structures
|
|
if hasattr(completion, "text"):
|
|
# V1 format: direct text access
|
|
text = completion.text
|
|
elif hasattr(completion, "message") and hasattr(completion.message, "content"):
|
|
# V2 format: nested structure (message.content[].text)
|
|
# V2 responses may have multiple content items (thinking, text, etc.)
|
|
content_items = completion.message.content
|
|
if content_items and len(content_items) > 0:
|
|
# Find the text content item (skip thinking/other types)
|
|
# TODO handle these other content types
|
|
text = None
|
|
for item in content_items:
|
|
if (
|
|
hasattr(item, "type")
|
|
and item.type == "text"
|
|
and hasattr(item, "text")
|
|
):
|
|
text = item.text
|
|
break
|
|
|
|
if text is None:
|
|
raise ResponseParsingError(
|
|
"Cohere V2 response has no text content item",
|
|
mode="COHERE_JSON_SCHEMA",
|
|
raw_response=completion,
|
|
)
|
|
else:
|
|
raise ResponseParsingError(
|
|
"Cohere V2 response has no content",
|
|
mode="COHERE_JSON_SCHEMA",
|
|
raw_response=completion,
|
|
)
|
|
else:
|
|
raise ResponseParsingError(
|
|
f"Unsupported Cohere response format. Expected 'text' (V1) or "
|
|
f"'message.content[].text' (V2), got: {type(completion)}",
|
|
mode="COHERE_JSON_SCHEMA",
|
|
raw_response=completion,
|
|
)
|
|
|
|
return cls.model_validate_json(text, context=validation_context, strict=strict)
|
|
|
|
@classmethod
|
|
def parse_anthropic_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
from anthropic.types import Message
|
|
|
|
if isinstance(completion, Message) and completion.stop_reason == "max_tokens":
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
|
|
# Anthropic returns arguments as a dict, dump to json for model validation below
|
|
tool_calls = [
|
|
json.dumps(c.input) for c in completion.content if c.type == "tool_use"
|
|
] # TODO update with anthropic specific types
|
|
|
|
tool_calls_validator = TypeAdapter(
|
|
Annotated[list[Any], Field(min_length=1, max_length=1)]
|
|
)
|
|
tool_call = tool_calls_validator.validate_python(tool_calls)[0]
|
|
|
|
return cls.model_validate_json(
|
|
tool_call, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_anthropic_json(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
from anthropic.types import Message
|
|
|
|
last_block = None
|
|
|
|
if hasattr(completion, "choices"):
|
|
completion = completion.choices[0]
|
|
if completion.finish_reason == "length":
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
text = completion.message.content
|
|
else:
|
|
assert isinstance(completion, Message)
|
|
if completion.stop_reason == "max_tokens":
|
|
raise IncompleteOutputException(last_completion=completion)
|
|
# Find the last text block in the completion
|
|
# this is because the completion is a list of blocks
|
|
# and the last block is the one that contains the text ideally
|
|
# this could happen due to things like multiple tool calls
|
|
# read: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/web-search-tool#response
|
|
text_blocks = [c for c in completion.content if c.type == "text"]
|
|
last_block = text_blocks[-1]
|
|
text = last_block.text
|
|
|
|
extra_text = extract_json_from_codeblock(text)
|
|
|
|
if strict:
|
|
model = cls.model_validate_json(
|
|
extra_text, context=validation_context, strict=True
|
|
)
|
|
else:
|
|
# Allow control characters to pass through by using the non-strict JSON parser.
|
|
parsed = json.loads(extra_text, strict=False)
|
|
# Pydantic non-strict: https://docs.pydantic.dev/latest/concepts/strict_mode/
|
|
model = cls.model_validate(parsed, context=validation_context, strict=False)
|
|
|
|
return model
|
|
|
|
@classmethod
|
|
def parse_bedrock_json(
|
|
cls: type[BaseModel],
|
|
completion: Any,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
if isinstance(completion, dict):
|
|
# OpenAI will send the first content to be 'reasoningText', and then 'text'
|
|
content = completion["output"]["message"]["content"]
|
|
text_content = next((c for c in content if "text" in c), None)
|
|
if not text_content:
|
|
raise ResponseParsingError(
|
|
"Unexpected format. No text content found in Bedrock response.",
|
|
mode="BEDROCK_JSON",
|
|
raw_response=completion,
|
|
)
|
|
text = text_content["text"]
|
|
match = re.search(r"```?json(.*?)```?", text, re.DOTALL)
|
|
if match:
|
|
text = match.group(1).strip()
|
|
|
|
text = re.sub(r"```?json|\\n", "", text).strip()
|
|
else:
|
|
text = completion.text
|
|
return cls.model_validate_json(text, context=validation_context, strict=strict)
|
|
|
|
@classmethod
|
|
def parse_bedrock_tools(
|
|
cls: type[BaseModel],
|
|
completion: Any,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
if isinstance(completion, dict):
|
|
# Extract the tool use from Bedrock response
|
|
message = completion.get("output", {}).get("message", {})
|
|
content = message.get("content", [])
|
|
|
|
# Find the tool use content block
|
|
for content_block in content:
|
|
if "toolUse" in content_block:
|
|
tool_use = content_block["toolUse"]
|
|
assert tool_use.get("name") == cls.__name__, (
|
|
f"Tool name mismatch: expected {cls.__name__}, got {tool_use.get('name')}"
|
|
)
|
|
return cls.model_validate(
|
|
tool_use.get("input", {}),
|
|
context=validation_context,
|
|
strict=strict,
|
|
)
|
|
|
|
raise ResponseParsingError(
|
|
"No tool use found in Bedrock response",
|
|
mode="BEDROCK_TOOLS",
|
|
raw_response=completion,
|
|
)
|
|
else:
|
|
# Fallback for other response formats
|
|
return cls.model_validate_json(
|
|
completion.text, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_gemini_json(
|
|
cls: type[BaseModel],
|
|
completion: Any,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
try:
|
|
text = completion.text
|
|
except ValueError:
|
|
logger.debug(
|
|
f"Error response: {completion.result.candidates[0].finish_reason}\n\n{completion.result.candidates[0].safety_ratings}"
|
|
)
|
|
|
|
try:
|
|
extra_text = extract_json_from_codeblock(text) # type: ignore
|
|
except UnboundLocalError:
|
|
raise ResponseParsingError(
|
|
"Unable to extract JSON from completion text. The response may have been blocked or empty.",
|
|
mode="GEMINI_JSON",
|
|
raw_response=completion,
|
|
) from None
|
|
|
|
if strict:
|
|
return cls.model_validate_json(
|
|
extra_text, context=validation_context, strict=True
|
|
)
|
|
else:
|
|
# Allow control characters.
|
|
parsed = json.loads(extra_text, strict=False)
|
|
# Pydantic non-strict: https://docs.pydantic.dev/latest/concepts/strict_mode/
|
|
return cls.model_validate(parsed, context=validation_context, strict=False)
|
|
|
|
@classmethod
|
|
def parse_vertexai_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
) -> BaseModel:
|
|
tool_call = completion.candidates[0].content.parts[0].function_call.args # type: ignore
|
|
model = {}
|
|
for field in tool_call: # type: ignore
|
|
model[field] = tool_call[field]
|
|
# We enable strict=False because the conversion from protobuf -> dict often results in types like ints being cast to floats, as a result in order for model.validate to work we need to disable strict mode.
|
|
return cls.model_validate(model, context=validation_context, strict=False)
|
|
|
|
@classmethod
|
|
def parse_vertexai_json(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
return cls.model_validate_json(
|
|
completion.text, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_cohere_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
"""
|
|
Parse Cohere tools response.
|
|
|
|
Supports:
|
|
- V1 native tool calls: completion.tool_calls[0].parameters
|
|
- V2 native tool calls: completion.message.tool_calls[0].function.arguments (JSON string)
|
|
- V1 text-based: completion.text (prompt-based approach)
|
|
- V2 text-based: completion.message.content[].text (prompt-based approach)
|
|
"""
|
|
# First, check for native Cohere tool calls (V1 and V2)
|
|
# V1: completion.tool_calls with tc.parameters (dict)
|
|
if hasattr(completion, "tool_calls") and completion.tool_calls:
|
|
# V1 tool call format
|
|
tool_call = completion.tool_calls[0]
|
|
# Parameters in V1 are already a dict
|
|
return cls.model_validate(
|
|
tool_call.parameters, context=validation_context, strict=strict
|
|
)
|
|
|
|
# V2: completion.message.tool_calls with tc.function.arguments (JSON string)
|
|
if (
|
|
hasattr(completion, "message")
|
|
and hasattr(completion.message, "tool_calls")
|
|
and completion.message.tool_calls
|
|
):
|
|
# V2 tool call format
|
|
tool_call = completion.message.tool_calls[0]
|
|
# Arguments in V2 are a JSON string
|
|
import json
|
|
|
|
arguments = json.loads(tool_call.function.arguments)
|
|
return cls.model_validate(
|
|
arguments, context=validation_context, strict=strict
|
|
)
|
|
|
|
# Fallback to text-based extraction (current prompt-based approach)
|
|
# Handle both V1 and V2 text response structures
|
|
if hasattr(completion, "text"):
|
|
# V1 format: direct text access
|
|
text = completion.text
|
|
elif hasattr(completion, "message") and hasattr(completion.message, "content"):
|
|
# V2 format: nested structure (message.content[].text)
|
|
# V2 responses may have multiple content items (thinking, text, etc.)
|
|
content_items = completion.message.content
|
|
if content_items and len(content_items) > 0:
|
|
# Find the text content item (skip thinking/other types)
|
|
text = None
|
|
for item in content_items:
|
|
if (
|
|
hasattr(item, "type")
|
|
and item.type == "text"
|
|
and hasattr(item, "text")
|
|
):
|
|
text = item.text
|
|
break
|
|
|
|
if text is None:
|
|
raise ResponseParsingError(
|
|
"Cohere V2 response has no text content item",
|
|
mode="COHERE_TOOLS",
|
|
raw_response=completion,
|
|
)
|
|
else:
|
|
raise ResponseParsingError(
|
|
"Cohere V2 response has no content",
|
|
mode="COHERE_TOOLS",
|
|
raw_response=completion,
|
|
)
|
|
else:
|
|
raise ResponseParsingError(
|
|
f"Unsupported Cohere response format. Expected tool_calls or text content. "
|
|
f"Got: {type(completion)}",
|
|
mode="COHERE_TOOLS",
|
|
raw_response=completion,
|
|
)
|
|
|
|
# Extract JSON from text (for prompt-based approach)
|
|
extra_text = extract_json_from_codeblock(text)
|
|
return cls.model_validate_json(
|
|
extra_text, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_writer_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
message = completion.choices[0].message
|
|
tool_calls = message.tool_calls if message.tool_calls else "{}"
|
|
assert len(tool_calls) == 1, (
|
|
"Instructor does not support multiple tool calls, use List[Model] instead"
|
|
)
|
|
assert tool_calls[0].function.name == cls.openai_schema["name"], (
|
|
"Tool name does not match"
|
|
)
|
|
loaded_args = json.loads(tool_calls[0].function.arguments)
|
|
return cls.model_validate_json(
|
|
json.dumps(loaded_args) if isinstance(loaded_args, dict) else loaded_args,
|
|
context=validation_context,
|
|
strict=strict,
|
|
)
|
|
|
|
@classmethod
|
|
def parse_writer_json(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
_handle_incomplete_output(completion)
|
|
|
|
message = completion.choices[0].message.content or ""
|
|
json_content = extract_json_from_codeblock(message)
|
|
|
|
if strict:
|
|
return cls.model_validate_json(
|
|
json_content, context=validation_context, strict=True
|
|
)
|
|
else:
|
|
parsed = json.loads(json_content, strict=False)
|
|
return cls.model_validate(parsed, context=validation_context, strict=False)
|
|
|
|
@classmethod
|
|
def parse_functions(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
message = completion.choices[0].message
|
|
assert (
|
|
message.function_call.name == cls.openai_schema["name"] # type: ignore[index]
|
|
), "Function name does not match"
|
|
return cls.model_validate_json(
|
|
message.function_call.arguments, # type: ignore[attr-defined]
|
|
context=validation_context,
|
|
strict=strict,
|
|
)
|
|
|
|
@classmethod
|
|
def parse_responses_tools(
|
|
cls: type[BaseModel],
|
|
completion: Any,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
|
|
tool_call_message = None
|
|
for message in completion.output:
|
|
if isinstance(message, ResponseFunctionToolCall):
|
|
if message.name == cls.openai_schema["name"]:
|
|
tool_call_message = message
|
|
break
|
|
if not tool_call_message:
|
|
raise ResponseParsingError(
|
|
f"Required tool call '{cls.openai_schema['name']}' not found in response",
|
|
mode="RESPONSES_TOOLS",
|
|
raw_response=completion,
|
|
)
|
|
|
|
return cls.model_validate_json(
|
|
tool_call_message.arguments, # type: ignore[attr-defined]
|
|
context=validation_context,
|
|
strict=strict,
|
|
)
|
|
|
|
@classmethod
|
|
def parse_tools(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
message = completion.choices[0].message
|
|
# this field seems to be missing when using instructor with some other tools (e.g. litellm)
|
|
# trying to fix this by adding a check
|
|
|
|
if hasattr(message, "refusal"):
|
|
assert message.refusal is None, (
|
|
f"Unable to generate a response due to {message.refusal}"
|
|
)
|
|
assert len(message.tool_calls or []) == 1, (
|
|
f"Instructor does not support multiple tool calls, use List[Model] instead"
|
|
)
|
|
tool_call = message.tool_calls[0] # type: ignore
|
|
assert (
|
|
tool_call.function.name == cls.openai_schema["name"] # type: ignore[index]
|
|
), "Tool name does not match"
|
|
return cls.model_validate_json(
|
|
tool_call.function.arguments, # type: ignore
|
|
context=validation_context,
|
|
strict=strict,
|
|
)
|
|
|
|
@classmethod
|
|
def parse_mistral_structured_outputs(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
if not completion.choices or len(completion.choices) > 1:
|
|
raise ConfigurationError(
|
|
"Instructor does not support multiple tool calls in MISTRAL_STRUCTURED_OUTPUTS mode. "
|
|
"Use list[Model] instead to handle multiple items."
|
|
)
|
|
|
|
message = completion.choices[0].message
|
|
|
|
return cls.model_validate_json(
|
|
message.content, context=validation_context, strict=strict
|
|
)
|
|
|
|
@classmethod
|
|
def parse_json(
|
|
cls: type[BaseModel],
|
|
completion: ChatCompletion,
|
|
validation_context: Optional[dict[str, Any]] = None,
|
|
strict: Optional[bool] = None,
|
|
) -> BaseModel:
|
|
"""Parse JSON mode responses using the optimized extraction and validation."""
|
|
# Check for incomplete output
|
|
_handle_incomplete_output(completion)
|
|
|
|
# Extract text from the response
|
|
message = _extract_text_content(completion)
|
|
if not message:
|
|
# Fallback for OpenAI format if _extract_text_content doesn't handle it
|
|
message = completion.choices[0].message.content or ""
|
|
|
|
# Extract JSON from the text
|
|
json_content = extract_json_from_codeblock(message)
|
|
|
|
# Validate the model from the JSON
|
|
return _validate_model_from_json(cls, json_content, validation_context, strict)
|
|
|
|
|
|
def openai_schema(cls: type[BaseModel]) -> OpenAISchema:
|
|
"""
|
|
Wrap a Pydantic model class to add OpenAISchema functionality.
|
|
"""
|
|
if not issubclass(cls, BaseModel):
|
|
raise ConfigurationError(
|
|
f"response_model must be a Pydantic BaseModel subclass, got {type(cls).__name__}"
|
|
)
|
|
|
|
# Create the wrapped model
|
|
schema = wraps(cls, updated=())(
|
|
create_model(
|
|
cls.__name__ if hasattr(cls, "__name__") else str(cls),
|
|
__base__=(cls, OpenAISchema),
|
|
)
|
|
)
|
|
|
|
return cast(OpenAISchema, schema)
|