Phase 5: ChatMessage (part 2)
Before: 6 .get('content'/'role'/'tool_calls'/'tool_call_id') sites
After: 0
Delta: -6
Migrates:
1. _send_deepseek API response parsing (lines 2321-2324):
- message.get('content', '') -> message.content or ''
- message.get('tool_calls', []) -> [tc.to_dict() for tc in message.tool_calls]
- message.get('reasoning_content') -> kept as choice.get('message', {}).get('reasoning_content', '')
(reasoning_content is NOT a ChatMessage field)
2. _repair_minimax_history generator (line 2454):
- m.get('role') == 'tool' -> _CM.from_dict(m).role == 'tool'
- m.get('tool_call_id') -> _CM.from_dict(m).tool_call_id
Used inline conversion because the generator iterates over a
dict list and reads 2 fields. Inline conversion avoids an
intermediate list comprehension.
openai_schemas.py:
- ChatMessage.from_dict() now provides defaults for required fields
('role' -> 'assistant', 'content' -> '') when the input dict is
missing them. This handles the case where DeepSeek's API returns
an empty {} for 'message' (e.g., finish_reason='length' with no
content). Without this default, ChatMessage.__init__() raises
TypeError.
Tests: 46/46 pass (test_ai_client_result, test_ai_client_tool_loop,
test_deepseek_provider, test_openai_schemas, test_minimax_provider).
Infrastructure change required by Phase 5/6/7 of the
type_alias_unfuck_20260626 track. The plan's migration pattern
(var = Aggregate.from_dict(var)) requires from_dict on the
target dataclasses. None existed for the openai_schemas
classes, so this commit adds them.
from_dict semantics:
- Filter dict keys to only the dataclass fields (ignore extra keys
like _est_tokens)
- For ChatMessage: convert nested tool_calls list to tuple of ToolCall
- For ToolCall: convert nested function dict to ToolCallFunction
- For UsageStats: direct field mapping
Field definitions unchanged. Behavior: zero impact on existing tests
(no callers exist yet for from_dict on these classes).
Tests: syntax check OK; manual instantiation confirms from_dict works.
OpenAI ChatMessage content can be either a string (simple text) or a list
of content parts (multimodal: text + image_url, etc.). Updated the type
annotation to match the actual API. No behavioral change; this is a
type-hint-only widening so callers can pass multimodal content via
ChatMessage instead of dicts.
Required by tests/test_openai_compatible.py::test_vision_multimodal_message
which was passing raw dicts to OpenAICompatibleRequest (wrong - the field
is typed list[ChatMessage]). With this widening, that test can now use
ChatMessage(role='user', content=[...multimodal parts]) without losing
type fidelity.
12 tests fail with:
TypeError: NormalizedResponse.__init__() got an unexpected keyword argument 'usage_input_tokens'
The @dataclass(frozen=True) auto-generated __init__ requires `usage: UsageStats`,
but 12 tests + 1 production site (src/ai_client.py:908) call it with the OLD
flat-kwarg API (usage_input_tokens=..., usage_output_tokens=..., etc.).
Change @dataclass(frozen=True) -> @dataclass(frozen=True, init=False) and add
a custom __init__ that accepts BOTH signatures:
- New: usage: UsageStats (used by current production code)
- Legacy: usage_input_tokens, usage_output_tokens, usage_cache_read_tokens,
usage_cache_creation_tokens (used by tests + 1 ai_client site)
If usage is None and any legacy flat kwarg is non-None, build a UsageStats
from the legacy kwargs. Otherwise use the provided usage. All field
assignments use object.__setattr__ because frozen=True locks __setattr__.
Verification:
- Legacy kwargs work: NormalizedResponse(text="hi", tool_calls=(), usage_input_tokens=10, usage_output_tokens=5, raw_response=None) sets usage.input_tokens=10
- New kwargs work: NormalizedResponse(text="hi", tool_calls=(), usage=UsageStats(1, 2)) sets usage directly
- 12 affected tests now pass (was 12 failed, 3 passed; now 15 passed)
Resolves audit_weak_types.py --strict regression (117 vs baseline 112 -> 104).
The regression was in src/openai_schemas.py (10 sites) and src/mcp_tool_specs.py
(4 sites), both files added after the 2026-06-21 baseline. JsonValue is the
canonical JSON-serializable data TypeAlias from src/type_aliases.py:22 and is a
structural superset of dict[str, Any], so consumers expecting the legacy shape
are unaffected. All 30 existing tests in tests/test_openai_schemas.py and
tests/test_mcp_tool_specs.py continue to pass.
Spec WHERE for t1.1 referenced code_path_audit*.py files but those modules
report 0 weak type findings per the audit (they use dict[str, int],
dict[str, dict], etc., not dict[str, Any]); see plan.md investigation note.