refactor(providers): rename openai responses helpers

This commit is contained in:
Xubin Ren 2026-04-02 05:38:19 +00:00 committed by Xubin Ren
parent ded0967c18
commit cc33057985
6 changed files with 38 additions and 43 deletions

View File

@ -2,7 +2,7 @@
Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
routes to the Responses API (``/responses``). Reuses shared conversion routes to the Responses API (``/responses``). Reuses shared conversion
helpers from :mod:`nanobot.providers.openai_responses_common`. helpers from :mod:`nanobot.providers.openai_responses`.
""" """
from __future__ import annotations from __future__ import annotations
@ -14,7 +14,7 @@ from typing import Any
from openai import AsyncOpenAI from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.openai_responses_common import ( from nanobot.providers.openai_responses import (
consume_sdk_stream, consume_sdk_stream,
convert_messages, convert_messages,
convert_tools, convert_tools,
@ -30,7 +30,7 @@ class AzureOpenAIProvider(LLMProvider):
``base_url = {endpoint}/openai/v1/`` ``base_url = {endpoint}/openai/v1/``
- Calls ``client.responses.create()`` (Responses API) - Calls ``client.responses.create()`` (Responses API)
- Reuses shared message/tool/SSE conversion from - Reuses shared message/tool/SSE conversion from
``openai_responses_common`` ``openai_responses``
""" """
def __init__( def __init__(

View File

@ -13,7 +13,7 @@ from loguru import logger
from oauth_cli_kit import get_token as get_codex_token from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses_common import ( from nanobot.providers.openai_responses import (
consume_sse, consume_sse,
convert_messages, convert_messages,
convert_tools, convert_tools,

View File

@ -1,12 +1,12 @@
"""Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI).""" """Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI)."""
from nanobot.providers.openai_responses_common.converters import ( from nanobot.providers.openai_responses.converters import (
convert_messages, convert_messages,
convert_tools, convert_tools,
convert_user_message, convert_user_message,
split_tool_call_id, split_tool_call_id,
) )
from nanobot.providers.openai_responses_common.parsing import ( from nanobot.providers.openai_responses.parsing import (
FINISH_REASON_MAP, FINISH_REASON_MAP,
consume_sdk_stream, consume_sdk_stream,
consume_sse, consume_sse,

View File

@ -58,8 +58,8 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
def convert_user_message(content: Any) -> dict[str, Any]: def convert_user_message(content: Any) -> dict[str, Any]:
"""Convert a user message's content to Responses API format. """Convert a user message's content to Responses API format.
Handles plain strings, ``text`` blocks ``input_text``, and Handles plain strings, ``text`` blocks -> ``input_text``, and
``image_url`` blocks ``input_image``. ``image_url`` blocks -> ``input_image``.
""" """
if isinstance(content, str): if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]} return {"role": "user", "content": [{"type": "input_text", "text": content}]}

View File

@ -106,8 +106,11 @@ async def consume_sse(
try: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: except Exception:
logger.warning("Failed to parse tool call arguments for '{}': {}", logger.warning(
buf.get("name") or item.get("name"), args_raw[:200]) "Failed to parse tool call arguments for '{}': {}",
buf.get("name") or item.get("name"),
args_raw[:200],
)
args = json_repair.loads(args_raw) args = json_repair.loads(args_raw)
if not isinstance(args, dict): if not isinstance(args, dict):
args = {"raw": args_raw} args = {"raw": args_raw}
@ -129,12 +132,7 @@ async def consume_sse(
def parse_response_output(response: Any) -> LLMResponse: def parse_response_output(response: Any) -> LLMResponse:
"""Parse an SDK ``Response`` object (from ``client.responses.create()``) """Parse an SDK ``Response`` object into an ``LLMResponse``."""
into an ``LLMResponse``.
Works with both Pydantic model objects and plain dicts.
"""
# Normalise to dict
if not isinstance(response, dict): if not isinstance(response, dict):
dump = getattr(response, "model_dump", None) dump = getattr(response, "model_dump", None)
response = dump() if callable(dump) else vars(response) response = dump() if callable(dump) else vars(response)
@ -158,7 +156,6 @@ def parse_response_output(response: Any) -> LLMResponse:
if block.get("type") == "output_text": if block.get("type") == "output_text":
content_parts.append(block.get("text") or "") content_parts.append(block.get("text") or "")
elif item_type == "reasoning": elif item_type == "reasoning":
# Reasoning items may have a summary list with text blocks
for s in item.get("summary") or []: for s in item.get("summary") or []:
if not isinstance(s, dict): if not isinstance(s, dict):
dump = getattr(s, "model_dump", None) dump = getattr(s, "model_dump", None)
@ -172,8 +169,11 @@ def parse_response_output(response: Any) -> LLMResponse:
try: try:
args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
except Exception: except Exception:
logger.warning("Failed to parse tool call arguments for '{}': {}", logger.warning(
item.get("name"), str(args_raw)[:200]) "Failed to parse tool call arguments for '{}': {}",
item.get("name"),
str(args_raw)[:200],
)
args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw
if not isinstance(args, dict): if not isinstance(args, dict):
args = {"raw": args_raw} args = {"raw": args_raw}
@ -211,12 +211,7 @@ async def consume_sdk_stream(
stream: Any, stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``. """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
The SDK yields typed event objects with a ``.type`` attribute and
event-specific fields. Returns
``(content, tool_calls, finish_reason, usage, reasoning_content)``.
"""
content = "" content = ""
tool_calls: list[ToolCallRequest] = [] tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {} tool_call_buffers: dict[str, dict[str, Any]] = {}
@ -261,9 +256,11 @@ async def consume_sdk_stream(
try: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: except Exception:
logger.warning("Failed to parse tool call arguments for '{}': {}", logger.warning(
buf.get("name") or getattr(item, "name", None), "Failed to parse tool call arguments for '{}': {}",
str(args_raw)[:200]) buf.get("name") or getattr(item, "name", None),
str(args_raw)[:200],
)
args = json_repair.loads(args_raw) args = json_repair.loads(args_raw)
if not isinstance(args, dict): if not isinstance(args, dict):
args = {"raw": args_raw} args = {"raw": args_raw}
@ -278,7 +275,6 @@ async def consume_sdk_stream(
resp = getattr(event, "response", None) resp = getattr(event, "response", None)
status = getattr(resp, "status", None) if resp else None status = getattr(resp, "status", None) if resp else None
finish_reason = map_finish_reason(status) finish_reason = map_finish_reason(status)
# Extract usage from the completed response
if resp: if resp:
usage_obj = getattr(resp, "usage", None) usage_obj = getattr(resp, "usage", None)
if usage_obj: if usage_obj:
@ -287,7 +283,6 @@ async def consume_sdk_stream(
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0), "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
} }
# Extract reasoning_content from completed output items
for out_item in getattr(resp, "output", None) or []: for out_item in getattr(resp, "output", None) or []:
if getattr(out_item, "type", None) == "reasoning": if getattr(out_item, "type", None) == "reasoning":
for s in getattr(out_item, "summary", None) or []: for s in getattr(out_item, "summary", None) or []:

View File

@ -1,17 +1,17 @@
"""Tests for the shared openai_responses_common converters and parsers.""" """Tests for the shared openai_responses converters and parsers."""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses_common.converters import ( from nanobot.providers.openai_responses.converters import (
convert_messages, convert_messages,
convert_tools, convert_tools,
convert_user_message, convert_user_message,
split_tool_call_id, split_tool_call_id,
) )
from nanobot.providers.openai_responses_common.parsing import ( from nanobot.providers.openai_responses.parsing import (
consume_sdk_stream, consume_sdk_stream,
map_finish_reason, map_finish_reason,
parse_response_output, parse_response_output,
@ -19,7 +19,7 @@ from nanobot.providers.openai_responses_common.parsing import (
# ====================================================================== # ======================================================================
# converters split_tool_call_id # converters - split_tool_call_id
# ====================================================================== # ======================================================================
@ -44,7 +44,7 @@ class TestSplitToolCallId:
# ====================================================================== # ======================================================================
# converters convert_user_message # converters - convert_user_message
# ====================================================================== # ======================================================================
@ -99,7 +99,7 @@ class TestConvertUserMessage:
# ====================================================================== # ======================================================================
# converters convert_messages # converters - convert_messages
# ====================================================================== # ======================================================================
@ -196,7 +196,7 @@ class TestConvertMessages:
assert "_meta" not in str(item) assert "_meta" not in str(item)
def test_full_conversation_roundtrip(self): def test_full_conversation_roundtrip(self):
"""System + user + assistant(tool_call) + tool correct structure.""" """System + user + assistant(tool_call) + tool -> correct structure."""
msgs = [ msgs = [
{"role": "system", "content": "Be concise."}, {"role": "system", "content": "Be concise."},
{"role": "user", "content": "Weather in SF?"}, {"role": "user", "content": "Weather in SF?"},
@ -218,7 +218,7 @@ class TestConvertMessages:
# ====================================================================== # ======================================================================
# converters convert_tools # converters - convert_tools
# ====================================================================== # ======================================================================
@ -261,7 +261,7 @@ class TestConvertTools:
# ====================================================================== # ======================================================================
# parsing map_finish_reason # parsing - map_finish_reason
# ====================================================================== # ======================================================================
@ -286,7 +286,7 @@ class TestMapFinishReason:
# ====================================================================== # ======================================================================
# parsing parse_response_output # parsing - parse_response_output
# ====================================================================== # ======================================================================
@ -332,7 +332,7 @@ class TestParseResponseOutput:
}], }],
"status": "completed", "usage": {}, "status": "completed", "usage": {},
} }
with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
result = parse_response_output(resp) result = parse_response_output(resp)
assert result.tool_calls[0].arguments == {"raw": "{bad json"} assert result.tool_calls[0].arguments == {"raw": "{bad json"}
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
@ -392,7 +392,7 @@ class TestParseResponseOutput:
# ====================================================================== # ======================================================================
# parsing consume_sdk_stream # parsing - consume_sdk_stream
# ====================================================================== # ======================================================================
@ -515,7 +515,7 @@ class TestConsumeSdkStream:
for e in [ev1, ev2, ev3, ev4]: for e in [ev1, ev2, ev3, ev4]:
yield e yield e
with patch("nanobot.providers.openai_responses_common.parsing.logger") as mock_logger: with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
_, tool_calls, _, _, _ = await consume_sdk_stream(stream()) _, tool_calls, _, _, _ = await consume_sdk_stream(stream())
assert tool_calls[0].arguments == {"raw": "{bad"} assert tool_calls[0].arguments == {"raw": "{bad"}
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()