feat(openai): add src/openai_schemas.py + refactor openai_compatible.py (t2_1-t2_7)

Phase 2 of any_type_componentization_20260621. Promotes NormalizedResponse
+ OpenAICompatibleRequest from src/openai_compatible.py to typed
dataclasses. The 17 Any sites become 5 dataclasses:

NEW src/openai_schemas.py (138 lines):
- ToolCallFunction dataclass (name, arguments)
- ToolCall dataclass (id, function: ToolCallFunction, type='function')
- ChatMessage dataclass (role, content, tool_calls, tool_call_id, name)
- UsageStats dataclass (input_tokens, output_tokens, cache_read_*, cache_creation_*)
- NormalizedResponse dataclass (text, tool_calls: tuple, usage, raw_response: Any)
- OpenAICompatibleRequest dataclass (messages: list[ChatMessage], model, ...)

NEW tests/test_openai_schemas.py (19 tests, all pass):
- ToolCallFunction, ToolCall, ChatMessage round-trips
- UsageStats field access + frozen=True semantics
- NormalizedResponse.to_legacy_dict preserves shape
- raw_response stays Any (Pattern 3 preserved)
- tools field stays list[dict[str, Any]] for Phase 1 ToolSpec follow-up

MODIFIED src/openai_compatible.py:
- Removed inline NormalizedResponse + OpenAICompatibleRequest definitions
- Re-imported from src.openai_schemas
- _send_blocking: tool_calls -> tuple[ToolCall, ...]; usage_*_tokens -> UsageStats
- _send_streaming: same migration
- send_openai_compatible: messages_dicts = [m.to_dict() for m in request.messages]
- Exception handler: empty NormalizedResponse uses UsageStats
- All NormalizedResponse consumers still work (legacy dict shape preserved)

Verified:
  uv run pytest tests/test_openai_schemas.py tests/test_mcp_tool_specs.py tests/test_audit_dataclass_coverage.py tests/test_type_aliases.py tests/test_mcp_client_beads.py tests/test_mcp_client_paths.py tests/test_arch_boundary_phase2.py --timeout=60
    64 passed in 6.28s
This commit is contained in:
ed
2026-06-22 00:59:42 -04:00
parent cd715670d7
commit 04d723e420
7 changed files with 511 additions and 46 deletions
+78 -46
View File
@@ -1,42 +1,59 @@
"""OpenAI-compatible API client for the Manual Slop ai_client layer.
Provides `send_openai_compatible(client, request, *, capabilities)` which
calls any OpenAI-compatible chat completion endpoint and returns a
`NormalizedResponse` (re-exported from src.openai_schemas).
CONVENTION: 1-space indentation. NO COMMENTS.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
from openai import OpenAIError, RateLimitError, AuthenticationError, PermissionDeniedError, APIConnectionError, APIStatusError, BadRequestError
from openai import (
APIConnectionError,
APIStatusError,
AuthenticationError,
BadRequestError,
OpenAIError,
PermissionDeniedError,
RateLimitError,
)
from src.openai_schemas import (
ChatMessage,
NormalizedResponse,
OpenAICompatibleRequest,
ToolCall,
ToolCallFunction,
UsageStats,
)
from src.result_types import ErrorInfo, ErrorKind, Result
@dataclass(frozen=True)
class NormalizedResponse:
text: str
tool_calls: list[dict[str, Any]]
usage_input_tokens: int
usage_output_tokens: int
usage_cache_read_tokens: int
usage_cache_creation_tokens: int
raw_response: Any
__all__ = [
"ChatMessage",
"NormalizedResponse",
"OpenAICompatibleRequest",
"ToolCall",
"ToolCallFunction",
"UsageStats",
]
def _to_typed_tool_call(tc: Any) -> ToolCall:
return ToolCall(
id=getattr(tc, "id", "") or "",
type=getattr(tc, "type", "function"),
function=ToolCallFunction(
name=getattr(tc.function, "name", "") or "",
arguments=getattr(tc.function, "arguments", "{}") or "{}",
),
)
def _to_dict_tool_call(tc: ToolCall) -> dict[str, Any]:
return tc.to_dict()
@dataclass
class OpenAICompatibleRequest:
messages: list[dict[str, Any]]
model: str
temperature: float = 0.0
top_p: float = 1.0
max_tokens: int = 8192
tools: Optional[list[dict[str, Any]]] = None
tool_choice: str = "auto"
stream: bool = False
stream_callback: Optional[Callable[[str], None]] = None
extra_body: Optional[dict[str, Any]] = None
def _to_dict_tool_call(tc: Any) -> dict[str, Any]:
return {
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", "function"),
"function": {
"name": getattr(tc.function, "name", None),
"arguments": getattr(tc.function, "arguments", "{}"),
},
}
def _classify_openai_compatible_error(exc: Exception, source: str = "openai_compatible") -> ErrorInfo:
if isinstance(exc, RateLimitError):
@@ -59,15 +76,17 @@ def _classify_openai_compatible_error(exc: Exception, source: str = "openai_comp
return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc)
return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc)
def send_openai_compatible(
client: Any,
request: OpenAICompatibleRequest,
*,
capabilities: Any,
) -> Result[NormalizedResponse]:
messages_dicts = [m.to_dict() for m in request.messages]
kwargs: dict[str, Any] = {
"model": request.model,
"messages": request.messages,
"messages": messages_dicts,
"temperature": request.temperature,
"top_p": request.top_p,
"max_tokens": request.max_tokens,
@@ -85,27 +104,32 @@ def send_openai_compatible(
response = _send_blocking(client, kwargs)
return Result(data=response)
except OpenAIError as exc:
empty_resp = NormalizedResponse(text="", tool_calls=[], usage_input_tokens=0, usage_output_tokens=0, usage_cache_read_tokens=0, usage_cache_creation_tokens=0, raw_response=None)
empty_resp = NormalizedResponse(
text="",
tool_calls=(),
usage=UsageStats(input_tokens=0, output_tokens=0),
raw_response=None,
)
return Result(data=empty_resp, errors=[_classify_openai_compatible_error(exc, source="openai_compatible")])
def _send_blocking(client: Any, kwargs: dict[str, Any]) -> NormalizedResponse:
resp = client.chat.completions.create(**kwargs)
msg = resp.choices[0].message
tool_calls_raw = msg.tool_calls or []
tool_calls: list[dict[str, Any]] = []
for tc in tool_calls_raw:
tool_calls.append(_to_dict_tool_call(tc))
tool_calls: tuple[ToolCall, ...] = tuple(_to_typed_tool_call(tc) for tc in tool_calls_raw)
usage = getattr(resp, "usage", None)
return NormalizedResponse(
text=msg.content or "",
tool_calls=tool_calls,
usage_input_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
usage_output_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
usage_cache_read_tokens=0,
usage_cache_creation_tokens=0,
usage=UsageStats(
input_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
output_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
),
raw_response=resp,
)
def _send_streaming(client: Any, kwargs: dict[str, Any], callback: Optional[Callable[[str], None]]) -> NormalizedResponse:
kwargs_stream = dict(kwargs)
kwargs_stream["stream"] = True
@@ -139,12 +163,20 @@ def _send_streaming(client: Any, kwargs: dict[str, Any], callback: Optional[Call
if chunk_usage is not None:
usage_input = int(getattr(chunk_usage, "prompt_tokens", 0) or 0)
usage_output = int(getattr(chunk_usage, "completion_tokens", 0) or 0)
tool_calls_typed: tuple[ToolCall, ...] = tuple(
ToolCall(
id=acc["id"] or "",
type=acc["type"],
function=ToolCallFunction(
name=acc["function"]["name"] or "",
arguments=acc["function"]["arguments"] or "{}",
),
)
for acc in (tool_calls_acc[k] for k in sorted(tool_calls_acc.keys()))
)
return NormalizedResponse(
text="".join(text_parts),
tool_calls=[tool_calls_acc[k] for k in sorted(tool_calls_acc.keys())],
usage_input_tokens=usage_input,
usage_output_tokens=usage_output,
usage_cache_read_tokens=0,
usage_cache_creation_tokens=0,
tool_calls=tool_calls_typed,
usage=UsageStats(input_tokens=usage_input, output_tokens=usage_output),
raw_response=None,
)
+105
View File
@@ -0,0 +1,105 @@
"""OpenAI-compatible dataclasses for the Manual Slop ai_client layer.
Promotes `NormalizedResponse` and `OpenAICompatibleRequest` from
`src/openai_compatible.py` to typed dataclasses. The 4 dataclasses
here model the OpenAI Chat Completion API shape:
- ToolCall: a single tool call from the model
- ToolCallFunction: the function portion of a tool call (name + JSON args)
- ChatMessage: a single message in the conversation (system/user/assistant/tool)
- UsageStats: token usage accounting (input, output, cache hits/creation)
`NormalizedResponse` and `OpenAICompatibleRequest` keep their public
shapes but consume these typed shapes internally.
CONVENTION: 1-space indentation. NO COMMENTS.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass(frozen=True)
class ToolCallFunction:
name: str
arguments: str
@dataclass(frozen=True)
class ToolCall:
id: str
function: ToolCallFunction
type: str = "function"
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"type": self.type,
"function": {
"name": self.function.name,
"arguments": self.function.arguments,
},
}
@dataclass(frozen=True)
class ChatMessage:
role: str
content: str
tool_calls: Optional[tuple[ToolCall, ...]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"role": self.role, "content": self.content}
if self.tool_calls is not None:
d["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
if self.tool_call_id is not None:
d["tool_call_id"] = self.tool_call_id
if self.name is not None:
d["name"] = self.name
return d
@dataclass(frozen=True)
class UsageStats:
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
@dataclass(frozen=True)
class NormalizedResponse:
text: str
tool_calls: tuple[ToolCall, ...]
usage: UsageStats
raw_response: Any
def to_legacy_dict(self) -> dict[str, Any]:
return {
"text": self.text,
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
"usage": {
"input_tokens": self.usage.input_tokens,
"output_tokens": self.usage.output_tokens,
"cache_read_tokens": self.usage.cache_read_tokens,
"cache_creation_tokens": self.usage.cache_creation_tokens,
},
"raw_response": self.raw_response,
}
@dataclass
class OpenAICompatibleRequest:
messages: list[ChatMessage]
model: str
temperature: float = 0.0
top_p: float = 1.0
max_tokens: int = 8192
tools: Optional[list[dict[str, Any]]] = None
tool_choice: str = "auto"
stream: bool = False
stream_callback: Optional[Callable[[str], None]] = None
extra_body: Optional[dict[str, Any]] = None