Files
AI/참고/instructor-main/instructor/providers/openai/utils.py
2026-05-12 19:40:31 +09:00

622 lines
19 KiB
Python

"""OpenAI-specific utilities.
This module contains utilities specific to the OpenAI provider,
including reask functions, response handlers, and message formatting.
"""
from __future__ import annotations
import json
from textwrap import dedent
from typing import Any, cast
from openai import pydantic_function_tool
from ...dsl.parallel import ParallelModel, handle_parallel_model
from ...core.exceptions import ConfigurationError
from ...mode import Mode
from ...utils.core import dump_message, merge_consecutive_messages
from ...processing.schema import generate_openai_schema
def _is_stream_response(response: Any) -> bool:
"""Check if response is a Stream object rather than a ChatCompletion.
Stream objects don't have 'choices' attribute and can't be used
for detailed reask messages that reference the response content.
"""
return response is None or not hasattr(response, "choices")
def _filter_responses_tool_calls(output_items: list[Any]) -> list[Any]:
"""Return response output items that represent tool calls."""
tool_calls: list[Any] = []
for item in output_items:
item_type = getattr(item, "type", None)
if item_type in {"function_call", "tool_call"}:
tool_calls.append(item)
continue
if item_type is None and hasattr(item, "arguments"):
tool_calls.append(item)
return tool_calls
def _format_responses_tool_call_details(tool_call: Any) -> str:
"""Format tool call name/id details for reask messages."""
tool_name = getattr(tool_call, "name", None)
tool_id = (
getattr(tool_call, "id", None)
or getattr(tool_call, "call_id", None)
or getattr(tool_call, "tool_call_id", None)
)
details: list[str] = []
if tool_name:
details.append(f"name={tool_name}")
if tool_id:
details.append(f"id={tool_id}")
if not details:
return ""
return f" (tool call {', '.join(details)})"
def reask_tools(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
failed_attempts: list[Any] | None = None, # noqa: ARG001
):
"""
Handle reask for OpenAI tools mode when validation fails.
Kwargs modifications:
- Adds: "messages" (tool response messages indicating validation errors)
"""
kwargs = kwargs.copy()
# Handle Stream objects which don't have choices attribute
# This happens when streaming mode is used with retries
if _is_stream_response(response):
kwargs["messages"].append(
{
"role": "user",
"content": (
f"Validation Error found:\n{exception}\n"
"Recall the function correctly, fix the errors"
),
}
)
return kwargs
reask_msgs = [dump_message(response.choices[0].message)]
for tool_call in response.choices[0].message.tool_calls:
reask_msgs.append(
{
"role": "tool", # type: ignore
"tool_call_id": tool_call.id,
"name": tool_call.function.name,
"content": (
f"Validation Error found:\n{exception}\nRecall the function correctly, fix the errors"
),
}
)
kwargs["messages"].extend(reask_msgs)
return kwargs
def reask_responses_tools(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
failed_attempts: list[Any] | None = None, # noqa: ARG001
):
"""
Handle reask for OpenAI responses tools mode when validation fails.
Kwargs modifications:
- Adds: "messages" (user messages with validation errors)
"""
kwargs = kwargs.copy()
# Handle Stream objects which don't have output attribute
if response is None or not hasattr(response, "output"):
kwargs["messages"].append(
{
"role": "user",
"content": (
f"Validation Error found:\n{exception}\n"
"Recall the function correctly, fix the errors"
),
}
)
return kwargs
reask_messages = []
for tool_call in _filter_responses_tool_calls(response.output):
details = _format_responses_tool_call_details(tool_call)
reask_messages.append(
{
"role": "user", # type: ignore
"content": (
f"Validation Error found:\n{exception}\n"
"Recall the function correctly, fix the errors with "
f"{tool_call.arguments}{details}"
),
}
)
kwargs["messages"].extend(reask_messages)
return kwargs
def reask_md_json(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
failed_attempts: list[Any] | None = None, # noqa: ARG001
):
"""
Handle reask for OpenAI JSON modes when validation fails.
Kwargs modifications:
- Adds: "messages" (user message requesting JSON correction)
"""
kwargs = kwargs.copy()
# Handle Stream objects which don't have choices attribute
if _is_stream_response(response):
kwargs["messages"].append(
{
"role": "user",
"content": f"Correct your JSON ONLY RESPONSE, based on the following errors:\n{exception}",
}
)
return kwargs
reask_msgs = [dump_message(response.choices[0].message)]
reask_msgs.append(
{
"role": "user",
"content": f"Correct your JSON ONLY RESPONSE, based on the following errors:\n{exception}",
}
)
kwargs["messages"].extend(reask_msgs)
return kwargs
def reask_default(
kwargs: dict[str, Any],
response: Any,
exception: Exception,
failed_attempts: list[Any] | None = None, # noqa: ARG001
):
"""
Handle reask for OpenAI default mode when validation fails.
Kwargs modifications:
- Adds: "messages" (user message requesting function correction)
"""
kwargs = kwargs.copy()
# Handle Stream objects which don't have choices attribute
if _is_stream_response(response):
kwargs["messages"].append(
{
"role": "user",
"content": (
f"Recall the function correctly, fix the errors, exceptions found\n{exception}"
),
}
)
return kwargs
reask_msgs = [dump_message(response.choices[0].message)]
reask_msgs.append(
{
"role": "user",
"content": (
f"Recall the function correctly, fix the errors, exceptions found\n{exception}"
),
}
)
kwargs["messages"].extend(reask_msgs)
return kwargs
# Response handlers
def handle_parallel_tools(
response_model: type[Any], new_kwargs: dict[str, Any]
) -> tuple[type[Any], dict[str, Any]]:
"""
Handle OpenAI parallel tools mode for concurrent function calls.
This mode enables making multiple independent function calls in a single request,
useful for batch processing or when you need to extract multiple structured outputs
simultaneously. The response_model should be a list/iterable type or use the
ParallelModel wrapper.
Example usage:
# Define models for parallel extraction
class PersonInfo(BaseModel):
name: str
age: int
class EventInfo(BaseModel):
date: str
location: str
# Use with PARALLEL_TOOLS mode
result = client.chat.completions.create(
model="gpt-4",
response_model=[PersonInfo, EventInfo],
mode=instructor.Mode.PARALLEL_TOOLS,
messages=[{"role": "user", "content": "Extract person and event info..."}]
)
Kwargs modifications:
- Adds: "tools" (multiple function schemas from parallel model)
- Adds: "tool_choice" ("auto" to allow model to choose which tools to call)
- Validates: stream=False (streaming not supported in parallel mode)
"""
if new_kwargs.get("stream", False):
raise ConfigurationError(
"stream=True is not supported when using PARALLEL_TOOLS mode"
)
new_kwargs["tools"] = handle_parallel_model(response_model)
new_kwargs["tool_choice"] = "auto"
return cast(type[Any], ParallelModel(typehint=response_model)), new_kwargs
def handle_functions(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI functions mode (deprecated).
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Adds: "functions" (list with function schema)
- Adds: "function_call" (forced function call)
"""
Mode.warn_mode_functions_deprecation()
if response_model is None:
return None, new_kwargs
new_kwargs["functions"] = [generate_openai_schema(response_model)]
new_kwargs["function_call"] = {
"name": generate_openai_schema(response_model)["name"]
}
return response_model, new_kwargs
def handle_tools_strict(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI strict tools mode.
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Adds: "tools" (list with strict function schema)
- Adds: "tool_choice" (forced function call)
"""
if response_model is None:
return None, new_kwargs
response_model_schema = pydantic_function_tool(response_model)
response_model_schema["function"]["strict"] = True
new_kwargs["tools"] = [response_model_schema]
new_kwargs["tool_choice"] = {
"type": "function",
"function": {"name": response_model_schema["function"]["name"]},
}
return response_model, new_kwargs
def handle_tools(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI tools mode.
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Adds: "tools" (list with function schema)
- Adds: "tool_choice" (forced function call)
"""
if response_model is None:
return None, new_kwargs
new_kwargs["tools"] = [
{
"type": "function",
"function": generate_openai_schema(response_model),
}
]
new_kwargs["tool_choice"] = {
"type": "function",
"function": {"name": generate_openai_schema(response_model)["name"]},
}
return response_model, new_kwargs
def handle_responses_tools(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI responses tools mode.
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Adds: "tools" (list with function schema)
- Adds: "tool_choice" (forced function call)
- Adds: "max_output_tokens" (converted from max_tokens)
"""
# Handle max_tokens to max_output_tokens conversion for RESPONSES_TOOLS modes
if new_kwargs.get("max_tokens") is not None:
new_kwargs["max_output_tokens"] = new_kwargs.pop("max_tokens")
# If response_model is None, just return without setting up tools
if response_model is None:
return None, new_kwargs
schema = pydantic_function_tool(response_model)
del schema["function"]["strict"]
tool_definition = {
"type": "function",
"name": schema["function"]["name"],
"parameters": schema["function"]["parameters"],
}
if "description" in schema["function"]:
tool_definition["description"] = schema["function"]["description"]
else:
tool_definition["description"] = (
f"Correctly extracted `{response_model.__name__}` with all "
f"the required parameters with correct types"
)
new_kwargs["tools"] = [tool_definition]
new_kwargs["tool_choice"] = {
"type": "function",
"name": tool_definition["name"],
}
return response_model, new_kwargs
def handle_responses_tools_with_inbuilt_tools(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI responses tools with inbuilt tools mode.
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Adds: "tools" (list with function schema)
- Adds: "tool_choice" (forced function call)
- Adds: "max_output_tokens" (converted from max_tokens)
"""
# Handle max_tokens to max_output_tokens conversion for RESPONSES_TOOLS modes
if new_kwargs.get("max_tokens") is not None:
new_kwargs["max_output_tokens"] = new_kwargs.pop("max_tokens")
# If response_model is None, just return without setting up tools
if response_model is None:
return None, new_kwargs
schema = pydantic_function_tool(response_model)
del schema["function"]["strict"]
tool_definition = {
"type": "function",
"name": schema["function"]["name"],
"parameters": schema["function"]["parameters"],
}
if "description" in schema["function"]:
tool_definition["description"] = schema["function"]["description"]
else:
tool_definition["description"] = (
f"Correctly extracted `{response_model.__name__}` with all "
f"the required parameters with correct types"
)
if not new_kwargs.get("tools"):
new_kwargs["tools"] = [tool_definition]
new_kwargs["tool_choice"] = {
"type": "function",
"name": generate_openai_schema(response_model)["name"],
}
else:
new_kwargs["tools"].append(tool_definition)
return response_model, new_kwargs
def handle_json_o1(
response_model: type[Any] | None, new_kwargs: dict[str, Any]
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI o1 JSON mode.
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Modifies: "messages" (appends user message with JSON schema)
- Validates: No system messages allowed for O1 models
"""
roles = [message["role"] for message in new_kwargs.get("messages", [])]
if "system" in roles:
raise ValueError("System messages are not supported For the O1 models")
if response_model is None:
return None, new_kwargs
message = dedent(
f"""
Understand the content and provide
the parsed objects in json that match the following json_schema:\n
{json.dumps(response_model.model_json_schema(), indent=2, ensure_ascii=False)}
Make sure to return an instance of the JSON, not the schema itself
"""
)
new_kwargs["messages"].append(
{
"role": "user",
"content": message,
},
)
return response_model, new_kwargs
def handle_json_modes(
response_model: type[Any] | None, new_kwargs: dict[str, Any], mode: Mode
) -> tuple[type[Any] | None, dict[str, Any]]:
"""
Handle OpenAI JSON modes (JSON, MD_JSON, JSON_SCHEMA).
Kwargs modifications:
- When response_model is None: No modifications
- When response_model is provided:
- Mode.JSON_SCHEMA: Adds "response_format" with json_schema
- Mode.JSON: Adds "response_format" with type="json_object", modifies system message
- Mode.MD_JSON: Appends user message for markdown JSON response
"""
if response_model is None:
return None, new_kwargs
# Use a neutral prompt that doesn't impose a persona
# This allows the JSON mode to work with character-based applications
# See: https://github.com/instructor-ai/instructor/issues/1514
message = dedent(
f"""
Parse the content and return a JSON object matching this schema:
{json.dumps(response_model.model_json_schema(), indent=2, ensure_ascii=False)}
Return a valid JSON instance, not the schema definition."""
)
if mode == Mode.JSON:
new_kwargs["response_format"] = {"type": "json_object"}
elif mode == Mode.JSON_SCHEMA:
new_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": response_model.__name__,
"schema": response_model.model_json_schema(),
},
}
elif mode == Mode.MD_JSON:
new_kwargs["messages"].append(
{
"role": "user",
"content": "Return the correct JSON response within a ```json codeblock. not the JSON_SCHEMA",
},
)
new_kwargs["messages"] = merge_consecutive_messages(new_kwargs["messages"])
if mode != Mode.JSON_SCHEMA:
if new_kwargs["messages"][0]["role"] != "system":
new_kwargs["messages"].insert(
0,
{
"role": "system",
"content": message,
},
)
elif isinstance(new_kwargs["messages"][0]["content"], str):
new_kwargs["messages"][0]["content"] += f"\n\n{message}"
elif isinstance(new_kwargs["messages"][0]["content"], list):
new_kwargs["messages"][0]["content"][0]["text"] += f"\n\n{message}"
else:
raise ValueError(
"Invalid message format, must be a string or a list of messages"
)
return response_model, new_kwargs
def handle_openrouter_structured_outputs(
response_model: type[Any], new_kwargs: dict[str, Any]
) -> tuple[type[Any], dict[str, Any]]:
"""
Handle OpenRouter structured outputs mode.
Kwargs modifications:
- Adds: "response_format" (json_schema with strict mode enabled)
"""
schema = response_model.model_json_schema()
schema["additionalProperties"] = False
new_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": response_model.__name__,
"schema": schema,
"strict": True,
},
}
return response_model, new_kwargs
# Handler registry for OpenAI
OPENAI_HANDLERS = {
Mode.TOOLS: {
"reask": reask_tools,
"response": handle_tools,
},
Mode.TOOLS_STRICT: {
"reask": reask_tools,
"response": handle_tools_strict,
},
Mode.FUNCTIONS: {
"reask": reask_default,
"response": handle_functions,
},
Mode.JSON: {
"reask": reask_md_json,
"response": lambda rm, nk: handle_json_modes(rm, nk, Mode.JSON),
},
Mode.MD_JSON: {
"reask": reask_md_json,
"response": lambda rm, nk: handle_json_modes(rm, nk, Mode.MD_JSON),
},
Mode.JSON_SCHEMA: {
"reask": reask_md_json,
"response": lambda rm, nk: handle_json_modes(rm, nk, Mode.JSON_SCHEMA),
},
Mode.JSON_O1: {
"reask": reask_md_json,
"response": handle_json_o1,
},
Mode.PARALLEL_TOOLS: {
"reask": reask_tools,
"response": handle_parallel_tools,
},
Mode.RESPONSES_TOOLS: {
"reask": reask_responses_tools,
"response": handle_responses_tools,
},
Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS: {
"reask": reask_responses_tools,
"response": handle_responses_tools_with_inbuilt_tools,
},
Mode.OPENROUTER_STRUCTURED_OUTPUTS: {
"reask": reask_md_json,
"response": handle_openrouter_structured_outputs,
},
}