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-21 16:27:59 -04:00
parent 0318bfe9e2
commit a96f946b40
7 changed files with 511 additions and 46 deletions
@@ -0,0 +1,14 @@
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py') as f:
lines = f.readlines()
# Find duplicate 'return NormalizedResponse('
seen = False
new_lines = []
for line in lines:
if line.rstrip() == ' return NormalizedResponse(':
if seen:
continue
seen = True
new_lines.append(line)
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py', 'w', encoding='utf-8', newline='') as f:
f.writelines(new_lines)
print(f'Removed duplicates; {len(new_lines)} lines')
@@ -0,0 +1,19 @@
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py') as f:
lines = f.readlines()
# Find and deduplicate
# The structure should end at ' )' once, not twice
# Find all return NormalizedResponse blocks
import re
# Remove lines that come after the first ' return NormalizedResponse(' and its matching ')'
result = []
in_normalized = False
for line in lines:
if line.rstrip() == ' return NormalizedResponse(':
if in_normalized:
# Skip duplicate
continue
in_normalized = True
result.append(line)
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py', 'w', encoding='utf-8', newline='') as f:
f.writelines(result)
print(f'Deduped; {len(result)} lines')
@@ -0,0 +1,46 @@
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py') as f:
lines = f.readlines()
# Replace lines 139 to end of NormalizedResponse(...) call
# Original block (lines 139-160) - need to fix indentation:
# chunk_usage at 2sp (for chunk body, after for choice ends)
# if chunk_usage at 3sp (wait, that's wrong - it should be at 2sp sibling of chunk_usage)
# usage_input/output at 3sp (inside if)
# return NormalizedResponse at 1sp
# Args at 2sp
new_block = [
' chunk_usage = getattr(chunk, "usage", None)\n',
' if chunk_usage is not None:\n',
' usage_input = int(getattr(chunk_usage, "prompt_tokens", 0) or 0)\n',
' usage_output = int(getattr(chunk_usage, "completion_tokens", 0) or 0)\n',
' tool_calls_typed: tuple[ToolCall, ...] = tuple(\n',
' ToolCall(\n',
' id=acc["id"] or "",\n',
' type=acc["type"],\n',
' function=ToolCallFunction(\n',
' name=acc["function"]["name"] or "",\n',
' arguments=acc["function"]["arguments"] or "{}",\n',
' ),\n',
' )\n',
' for acc in (tool_calls_acc[k] for k in sorted(tool_calls_acc.keys()))\n',
' )\n',
' return NormalizedResponse(\n',
' text="".join(text_parts),\n',
' tool_calls=tool_calls_typed,\n',
' usage=UsageStats(input_tokens=usage_input, output_tokens=usage_output),\n',
' raw_response=None,\n',
' )\n',
]
# Find ' return NormalizedResponse(' end - line with ' )'
end_idx = None
for i in range(138, len(lines)):
if lines[i].rstrip() == ' )':
end_idx = i
break
if end_idx is None:
print('Could not find end')
else:
new_lines = lines[:138] + new_block + lines[end_idx+1:]
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py', 'w', encoding='utf-8', newline='') as f:
f.writelines(new_lines)
print(f'Replaced lines 139-{end_idx+1}; new file has {len(new_lines)} lines')
@@ -0,0 +1,43 @@
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py') as f:
lines = f.readlines()
# Fix the indentation of the chunk_usage block (lines 139-152)
# L139 chunk_usage: 1 space (inside for chunk)
# L140 if chunk_usage: 2 spaces
# L141-142 usage_* body: 3 spaces (inside if)
# L143+ tool_calls_typed: 1 space (sibling of for choice, inside for chunk)
# Replace lines 139-152 with corrected indentation
new_block = [
' chunk_usage = getattr(chunk, "usage", None)\n',
' if chunk_usage is not None:\n',
' usage_input = int(getattr(chunk_usage, "prompt_tokens", 0) or 0)\n',
' usage_output = int(getattr(chunk_usage, "completion_tokens", 0) or 0)\n',
' tool_calls_typed: tuple[ToolCall, ...] = tuple(\n',
' ToolCall(\n',
' id=acc["id"] or "",\n',
' type=acc["type"],\n',
' function=ToolCallFunction(\n',
' name=acc["function"]["name"] or "",\n',
' arguments=acc["function"]["arguments"] or "{}",\n',
' ),\n',
' )\n',
' for acc in (tool_calls_acc[k] for k in sorted(tool_calls_acc.keys()))\n',
' )\n',
' return NormalizedResponse(\n',
]
# Find the end of the block (return NormalizedResponse)
return_idx = None
for i in range(139, len(lines)):
if lines[i].rstrip().startswith(' return NormalizedResponse('):
return_idx = i
break
if return_idx is None:
print('Could not find return NormalizedResponse line')
else:
# Replace from line 139 (index 138) to the return line (exclusive)
new_lines = lines[:138] + new_block + lines[return_idx:]
with open(r'C:\projects\manual_slop_tier2\src\openai_compatible.py', 'w', encoding='utf-8', newline='') as f:
f.writelines(new_lines)
print(f'Fixed lines 139-{return_idx+1}; new file has {len(new_lines)} lines')