6a2f2cfa37
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).
133 lines
3.7 KiB
Python
133 lines
3.7 KiB
Python
"""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, field, fields as dc_fields
|
|
from typing import Any, Callable, Optional
|
|
|
|
from src.type_aliases import JsonValue, Metadata
|
|
|
|
|
|
def _from_dict_filter(cls: type, data: Metadata) -> Metadata:
|
|
return {k: v for k, v in data.items() if k in {f.name for f in dc_fields(cls)}}
|
|
|
|
|
|
@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) -> JsonValue:
|
|
return {
|
|
"id": self.id,
|
|
"type": self.type,
|
|
"function": {
|
|
"name": self.function.name,
|
|
"arguments": self.function.arguments,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Metadata) -> "ToolCall":
|
|
fn = ToolCallFunction(**_from_dict_filter(ToolCallFunction, data.get("function", {})))
|
|
return cls(**{**_from_dict_filter(cls, data), "function": fn})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChatMessage:
|
|
role: str
|
|
content: str | list
|
|
tool_calls: Optional[tuple[ToolCall, ...]] = None
|
|
tool_call_id: Optional[str] = None
|
|
name: Optional[str] = None
|
|
|
|
def to_dict(self) -> JsonValue:
|
|
d: JsonValue = {"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
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Metadata) -> "ChatMessage":
|
|
raw_tool_calls = data.get("tool_calls")
|
|
tool_calls = None
|
|
if raw_tool_calls is not None:
|
|
tool_calls = tuple(ToolCall.from_dict(tc) for tc in raw_tool_calls)
|
|
filtered = _from_dict_filter(cls, data)
|
|
if "role" not in filtered:
|
|
filtered["role"] = "assistant"
|
|
if "content" not in filtered:
|
|
filtered["content"] = ""
|
|
return cls(**{**filtered, "tool_calls": tool_calls})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UsageStats:
|
|
input_tokens: int
|
|
output_tokens: int
|
|
cache_read_tokens: int = 0
|
|
cache_creation_tokens: int = 0
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Metadata) -> "UsageStats":
|
|
return cls(**_from_dict_filter(cls, data))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NormalizedResponse:
|
|
text: str
|
|
tool_calls: tuple[ToolCall, ...] = ()
|
|
usage: UsageStats = field(default_factory=lambda: UsageStats(input_tokens=0, output_tokens=0))
|
|
raw_response: Any = None
|
|
|
|
def to_legacy_dict(self) -> JsonValue:
|
|
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[JsonValue]] = None
|
|
tool_choice: str = "auto"
|
|
stream: bool = False
|
|
stream_callback: Optional[Callable[[str], None]] = None
|
|
extra_body: Optional[JsonValue] = None |