This commit is contained in:
2026-04-12 01:02:14 +08:00
parent 509487f155
commit 9b053e302b
14085 changed files with 2680009 additions and 12 deletions

View File

@@ -0,0 +1,11 @@
from ._completions import (
ResponseFormatT as ResponseFormatT,
has_parseable_input,
has_parseable_input as has_parseable_input,
maybe_parse_content as maybe_parse_content,
validate_input_tools as validate_input_tools,
parse_chat_completion as parse_chat_completion,
get_input_tool_by_name as get_input_tool_by_name,
parse_function_tool_arguments as parse_function_tool_arguments,
type_to_response_format_param as type_to_response_format_param,
)

View File

@@ -0,0 +1,288 @@
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any, Iterable, cast
from typing_extensions import TypeVar, TypeGuard, assert_never
import pydantic
from .._tools import PydanticFunctionTool
from ..._types import Omit, omit
from ..._utils import is_dict, is_given
from ..._compat import PYDANTIC_V1, model_parse_json
from ..._models import construct_type_unchecked
from .._pydantic import is_basemodel_type, to_strict_json_schema, is_dataclass_like_type
from ...types.chat import (
ParsedChoice,
ChatCompletion,
ParsedFunction,
ParsedChatCompletion,
ChatCompletionMessage,
ParsedFunctionToolCall,
ParsedChatCompletionMessage,
ChatCompletionToolUnionParam,
ChatCompletionFunctionToolParam,
completion_create_params,
)
from ..._exceptions import LengthFinishReasonError, ContentFilterFinishReasonError
from ...types.shared_params import FunctionDefinition
from ...types.chat.completion_create_params import ResponseFormat as ResponseFormatParam
from ...types.chat.chat_completion_message_function_tool_call import Function
ResponseFormatT = TypeVar(
"ResponseFormatT",
# if it isn't given then we don't do any parsing
default=None,
)
_default_response_format: None = None
log: logging.Logger = logging.getLogger("openai.lib.parsing")
def is_strict_chat_completion_tool_param(
tool: ChatCompletionToolUnionParam,
) -> TypeGuard[ChatCompletionFunctionToolParam]:
"""Check if the given tool is a strict ChatCompletionFunctionToolParam."""
if not tool["type"] == "function":
return False
if tool["function"].get("strict") is not True:
return False
return True
def select_strict_chat_completion_tools(
tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit,
) -> Iterable[ChatCompletionFunctionToolParam] | Omit:
"""Select only the strict ChatCompletionFunctionToolParams from the given tools."""
if not is_given(tools):
return omit
return [t for t in tools if is_strict_chat_completion_tool_param(t)]
def validate_input_tools(
tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit,
) -> Iterable[ChatCompletionFunctionToolParam] | Omit:
if not is_given(tools):
return omit
for tool in tools:
if tool["type"] != "function":
raise ValueError(
f"Currently only `function` tool types support auto-parsing; Received `{tool['type']}`",
)
strict = tool["function"].get("strict")
if strict is not True:
raise ValueError(
f"`{tool['function']['name']}` is not strict. Only `strict` function tools can be auto-parsed"
)
return cast(Iterable[ChatCompletionFunctionToolParam], tools)
def parse_chat_completion(
*,
response_format: type[ResponseFormatT] | completion_create_params.ResponseFormat | Omit,
input_tools: Iterable[ChatCompletionToolUnionParam] | Omit,
chat_completion: ChatCompletion | ParsedChatCompletion[object],
) -> ParsedChatCompletion[ResponseFormatT]:
if is_given(input_tools):
input_tools = [t for t in input_tools]
else:
input_tools = []
choices: list[ParsedChoice[ResponseFormatT]] = []
for choice in chat_completion.choices:
if choice.finish_reason == "length":
raise LengthFinishReasonError(completion=chat_completion)
if choice.finish_reason == "content_filter":
raise ContentFilterFinishReasonError()
message = choice.message
tool_calls: list[ParsedFunctionToolCall] = []
if message.tool_calls:
for tool_call in message.tool_calls:
if tool_call.type == "function":
tool_call_dict = tool_call.to_dict()
tool_calls.append(
construct_type_unchecked(
value={
**tool_call_dict,
"function": {
**cast(Any, tool_call_dict["function"]),
"parsed_arguments": parse_function_tool_arguments(
input_tools=input_tools, function=tool_call.function
),
},
},
type_=ParsedFunctionToolCall,
)
)
elif tool_call.type == "custom":
# warn user that custom tool calls are not callable here
log.warning(
"Custom tool calls are not callable. Ignoring tool call: %s - %s",
tool_call.id,
tool_call.custom.name,
stacklevel=2,
)
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(tool_call)
else:
tool_calls.append(tool_call)
choices.append(
construct_type_unchecked(
type_=ParsedChoice[ResponseFormatT],
value={
**choice.to_dict(),
"message": {
**message.to_dict(),
"parsed": maybe_parse_content(
response_format=response_format,
message=message,
),
"tool_calls": tool_calls if tool_calls else None,
},
},
)
)
return construct_type_unchecked(
type_=ParsedChatCompletion[ResponseFormatT],
value={
**chat_completion.to_dict(),
"choices": choices,
},
)
def get_input_tool_by_name(
*, input_tools: list[ChatCompletionToolUnionParam], name: str
) -> ChatCompletionFunctionToolParam | None:
return next((t for t in input_tools if t["type"] == "function" and t.get("function", {}).get("name") == name), None)
def parse_function_tool_arguments(
*, input_tools: list[ChatCompletionToolUnionParam], function: Function | ParsedFunction
) -> object | None:
input_tool = get_input_tool_by_name(input_tools=input_tools, name=function.name)
if not input_tool:
return None
input_fn = cast(object, input_tool.get("function"))
if isinstance(input_fn, PydanticFunctionTool):
return model_parse_json(input_fn.model, function.arguments)
input_fn = cast(FunctionDefinition, input_fn)
if not input_fn.get("strict"):
return None
return json.loads(function.arguments) # type: ignore[no-any-return]
def maybe_parse_content(
*,
response_format: type[ResponseFormatT] | ResponseFormatParam | Omit,
message: ChatCompletionMessage | ParsedChatCompletionMessage[object],
) -> ResponseFormatT | None:
if has_rich_response_format(response_format) and message.content and not message.refusal:
return _parse_content(response_format, message.content)
return None
def has_parseable_input(
*,
response_format: type | ResponseFormatParam | Omit,
input_tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit,
) -> bool:
if has_rich_response_format(response_format):
return True
for input_tool in input_tools or []:
if is_parseable_tool(input_tool):
return True
return False
def has_rich_response_format(
response_format: type[ResponseFormatT] | ResponseFormatParam | Omit,
) -> TypeGuard[type[ResponseFormatT]]:
if not is_given(response_format):
return False
if is_response_format_param(response_format):
return False
return True
def is_response_format_param(response_format: object) -> TypeGuard[ResponseFormatParam]:
return is_dict(response_format)
def is_parseable_tool(input_tool: ChatCompletionToolUnionParam) -> bool:
if input_tool["type"] != "function":
return False
input_fn = cast(object, input_tool.get("function"))
if isinstance(input_fn, PydanticFunctionTool):
return True
return cast(FunctionDefinition, input_fn).get("strict") or False
def _parse_content(response_format: type[ResponseFormatT], content: str) -> ResponseFormatT:
if is_basemodel_type(response_format):
return cast(ResponseFormatT, model_parse_json(response_format, content))
if is_dataclass_like_type(response_format):
if PYDANTIC_V1:
raise TypeError(f"Non BaseModel types are only supported with Pydantic v2 - {response_format}")
return pydantic.TypeAdapter(response_format).validate_json(content)
raise TypeError(f"Unable to automatically parse response format type {response_format}")
def type_to_response_format_param(
response_format: type | completion_create_params.ResponseFormat | Omit,
) -> ResponseFormatParam | Omit:
if not is_given(response_format):
return omit
if is_response_format_param(response_format):
return response_format
# type checkers don't narrow the negation of a `TypeGuard` as it isn't
# a safe default behaviour but we know that at this point the `response_format`
# can only be a `type`
response_format = cast(type, response_format)
json_schema_type: type[pydantic.BaseModel] | pydantic.TypeAdapter[Any] | None = None
if is_basemodel_type(response_format):
name = response_format.__name__
json_schema_type = response_format
elif is_dataclass_like_type(response_format):
name = response_format.__name__
json_schema_type = pydantic.TypeAdapter(response_format)
else:
raise TypeError(f"Unsupported response_format type - {response_format}")
return {
"type": "json_schema",
"json_schema": {
"schema": to_strict_json_schema(json_schema_type),
"name": name,
"strict": True,
},
}

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, List, Iterable, cast
from typing_extensions import TypeVar, assert_never
import pydantic
from .._tools import ResponsesPydanticFunctionTool
from ..._types import Omit
from ..._utils import is_given
from ..._compat import PYDANTIC_V1, model_parse_json
from ..._models import construct_type_unchecked
from .._pydantic import is_basemodel_type, is_dataclass_like_type
from ._completions import type_to_response_format_param
from ...types.responses import (
Response,
ToolParam,
ParsedContent,
ParsedResponse,
FunctionToolParam,
ParsedResponseOutputItem,
ParsedResponseOutputText,
ResponseFunctionToolCall,
ParsedResponseOutputMessage,
ResponseFormatTextConfigParam,
ParsedResponseFunctionToolCall,
)
from ...types.chat.completion_create_params import ResponseFormat
TextFormatT = TypeVar(
"TextFormatT",
# if it isn't given then we don't do any parsing
default=None,
)
def type_to_text_format_param(type_: type) -> ResponseFormatTextConfigParam:
response_format_dict = type_to_response_format_param(type_)
assert is_given(response_format_dict)
response_format_dict = cast(ResponseFormat, response_format_dict) # pyright: ignore[reportUnnecessaryCast]
assert response_format_dict["type"] == "json_schema"
assert "schema" in response_format_dict["json_schema"]
return {
"type": "json_schema",
"strict": True,
"name": response_format_dict["json_schema"]["name"],
"schema": response_format_dict["json_schema"]["schema"],
}
def parse_response(
*,
text_format: type[TextFormatT] | Omit,
input_tools: Iterable[ToolParam] | Omit | None,
response: Response | ParsedResponse[object],
) -> ParsedResponse[TextFormatT]:
output_list: List[ParsedResponseOutputItem[TextFormatT]] = []
for output in response.output:
if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
if item.type != "output_text":
content_list.append(item)
continue
content_list.append(
construct_type_unchecked(
type_=ParsedResponseOutputText[TextFormatT],
value={
**item.to_dict(),
"parsed": parse_text(item.text, text_format=text_format),
},
)
)
output_list.append(
construct_type_unchecked(
type_=ParsedResponseOutputMessage[TextFormatT],
value={
**output.to_dict(),
"content": content_list,
},
)
)
elif output.type == "function_call":
output_list.append(
construct_type_unchecked(
type_=ParsedResponseFunctionToolCall,
value={
**output.to_dict(),
"parsed_arguments": parse_function_tool_arguments(
input_tools=input_tools, function_call=output
),
},
)
)
elif (
output.type == "computer_call"
or output.type == "file_search_call"
or output.type == "web_search_call"
or output.type == "tool_search_call"
or output.type == "tool_search_output"
or output.type == "reasoning"
or output.type == "compaction"
or output.type == "mcp_call"
or output.type == "mcp_approval_request"
or output.type == "mcp_approval_response"
or output.type == "image_generation_call"
or output.type == "code_interpreter_call"
or output.type == "local_shell_call"
or output.type == "local_shell_call_output"
or output.type == "shell_call"
or output.type == "shell_call_output"
or output.type == "apply_patch_call"
or output.type == "apply_patch_call_output"
or output.type == "mcp_list_tools"
or output.type == "exec"
or output.type == "custom_tool_call"
or output.type == "function_call_output"
or output.type == "computer_call_output"
or output.type == "custom_tool_call_output"
):
output_list.append(output)
elif TYPE_CHECKING: # type: ignore
assert_never(output)
else:
output_list.append(output)
return construct_type_unchecked(
type_=ParsedResponse[TextFormatT],
value={
**response.to_dict(),
"output": output_list,
},
)
def parse_text(text: str, text_format: type[TextFormatT] | Omit) -> TextFormatT | None:
if not is_given(text_format):
return None
if is_basemodel_type(text_format):
return cast(TextFormatT, model_parse_json(text_format, text))
if is_dataclass_like_type(text_format):
if PYDANTIC_V1:
raise TypeError(f"Non BaseModel types are only supported with Pydantic v2 - {text_format}")
return pydantic.TypeAdapter(text_format).validate_json(text)
raise TypeError(f"Unable to automatically parse response format type {text_format}")
def get_input_tool_by_name(*, input_tools: Iterable[ToolParam], name: str) -> FunctionToolParam | None:
for tool in input_tools:
if tool["type"] == "function" and tool.get("name") == name:
return tool
return None
def parse_function_tool_arguments(
*,
input_tools: Iterable[ToolParam] | Omit | None,
function_call: ParsedResponseFunctionToolCall | ResponseFunctionToolCall,
) -> object:
if input_tools is None or not is_given(input_tools):
return None
input_tool = get_input_tool_by_name(input_tools=input_tools, name=function_call.name)
if not input_tool:
return None
tool = cast(object, input_tool)
if isinstance(tool, ResponsesPydanticFunctionTool):
return model_parse_json(tool.model, function_call.arguments)
if not input_tool.get("strict"):
return None
return json.loads(function_call.arguments)