from __future__ import annotations import json from typing import Any, Iterator from urllib import error, request from .agent_types import ( AssistantTurn, ModelConfig, OutputSchemaConfig, StreamEvent, ToolCall, UsageStats, ) class OpenAICompatError(RuntimeError): """Raised when the local OpenAI-compatible backend returns an invalid response.""" def _join_url(base_url: str, suffix: str) -> str: base = base_url.rstrip('/') return f'{base}/{suffix.lstrip("/")}' def _normalize_content(content: Any) -> str: if content is None: return '' if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for item in content: if isinstance(item, str): parts.append(item) continue if not isinstance(item, dict): parts.append(str(item)) continue if item.get('type') == 'text' and isinstance(item.get('text'), str): parts.append(item['text']) continue if isinstance(item.get('text'), str): parts.append(item['text']) continue parts.append(json.dumps(item, ensure_ascii=True)) return ''.join(parts) return str(content) def _parse_tool_arguments(raw_arguments: Any) -> dict[str, Any]: if raw_arguments is None: return {} if isinstance(raw_arguments, dict): return raw_arguments if isinstance(raw_arguments, str): raw_arguments = raw_arguments.strip() if not raw_arguments: return {} try: parsed = json.loads(raw_arguments) except json.JSONDecodeError as exc: raise OpenAICompatError( f'Invalid tool arguments returned by model: {raw_arguments!r}' ) from exc if not isinstance(parsed, dict): raise OpenAICompatError( f'Tool arguments must decode to an object, got {type(parsed).__name__}' ) return parsed raise OpenAICompatError( f'Unsupported tool arguments payload: {type(raw_arguments).__name__}' ) def _optional_int(value: Any) -> int: if isinstance(value, bool): return 0 if isinstance(value, int): return value if isinstance(value, float): return int(value) if isinstance(value, str): try: return int(value) except ValueError: return 0 return 0 def _parse_usage(payload: Any) -> UsageStats: if not isinstance(payload, dict): return UsageStats() completion_details = payload.get('completion_tokens_details') if not isinstance(completion_details, dict): completion_details = {} return UsageStats( input_tokens=( _optional_int(payload.get('input_tokens')) or _optional_int(payload.get('prompt_tokens')) or _optional_int(payload.get('prompt_eval_count')) ), output_tokens=( _optional_int(payload.get('output_tokens')) or _optional_int(payload.get('completion_tokens')) or _optional_int(payload.get('eval_count')) ), cache_creation_input_tokens=_optional_int( payload.get('cache_creation_input_tokens') ), cache_read_input_tokens=_optional_int(payload.get('cache_read_input_tokens')), reasoning_tokens=( _optional_int(payload.get('reasoning_tokens')) or _optional_int(completion_details.get('reasoning_tokens')) ), ) def _build_response_format( schema: OutputSchemaConfig | None, ) -> dict[str, Any] | None: if schema is None: return None return { 'type': 'json_schema', 'json_schema': { 'name': schema.name, 'schema': schema.schema, 'strict': schema.strict, }, } class OpenAICompatClient: """Minimal OpenAI-compatible chat client for local model servers.""" def __init__(self, config: ModelConfig) -> None: self.config = config def complete( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], *, output_schema: OutputSchemaConfig | None = None, ) -> AssistantTurn: payload = self._request_json( self._build_payload( messages=messages, tools=tools, stream=False, output_schema=output_schema, ) ) choices = payload.get('choices') if not isinstance(choices, list) or not choices: raise OpenAICompatError('Local model backend returned no choices') first_choice = choices[0] if not isinstance(first_choice, dict): raise OpenAICompatError('Local model backend returned malformed choice data') message = first_choice.get('message') if not isinstance(message, dict): raise OpenAICompatError('Local model backend returned no assistant message') content = _normalize_content(message.get('content')) tool_calls = self._parse_tool_calls_from_message(message) finish_reason = first_choice.get('finish_reason') if finish_reason is not None and not isinstance(finish_reason, str): finish_reason = str(finish_reason) return AssistantTurn( content=content, tool_calls=tuple(tool_calls), finish_reason=finish_reason, raw_message=message, usage=_parse_usage(payload.get('usage')), ) def stream( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], *, output_schema: OutputSchemaConfig | None = None, ) -> Iterator[StreamEvent]: payload = self._build_payload( messages=messages, tools=tools, stream=True, output_schema=output_schema, ) req = request.Request( _join_url(self.config.base_url, '/chat/completions'), data=json.dumps(payload).encode('utf-8'), headers={ 'Authorization': f'Bearer {self.config.api_key}', 'Content-Type': 'application/json', }, method='POST', ) try: with request.urlopen(req, timeout=self.config.timeout_seconds) as response: yield StreamEvent(type='message_start') for event_payload in self._iter_sse_payloads(response): yield from self._parse_stream_payload(event_payload) except error.HTTPError as exc: detail = exc.read().decode('utf-8', errors='replace') raise OpenAICompatError( f'HTTP {exc.code} from local model backend: {detail}' ) from exc except error.URLError as exc: raise OpenAICompatError( f'Unable to reach local model backend at {self.config.base_url}: {exc.reason}' ) from exc def _request_json(self, payload: dict[str, Any]) -> dict[str, Any]: body = json.dumps(payload).encode('utf-8') req = request.Request( _join_url(self.config.base_url, '/chat/completions'), data=body, headers={ 'Authorization': f'Bearer {self.config.api_key}', 'Content-Type': 'application/json', }, method='POST', ) try: with request.urlopen(req, timeout=self.config.timeout_seconds) as response: raw = response.read() except error.HTTPError as exc: detail = exc.read().decode('utf-8', errors='replace') raise OpenAICompatError( f'HTTP {exc.code} from local model backend: {detail}' ) from exc except error.URLError as exc: raise OpenAICompatError( f'Unable to reach local model backend at {self.config.base_url}: {exc.reason}' ) from exc try: payload = json.loads(raw.decode('utf-8')) except json.JSONDecodeError as exc: raise OpenAICompatError('Local model backend returned invalid JSON') from exc if not isinstance(payload, dict): raise OpenAICompatError('Local model backend returned malformed JSON payload') return payload def _build_payload( self, *, messages: list[dict[str, Any]], tools: list[dict[str, Any]], stream: bool, output_schema: OutputSchemaConfig | None, ) -> dict[str, Any]: payload: dict[str, Any] = { 'model': self.config.model, 'messages': messages, 'tools': tools, 'tool_choice': 'auto', 'temperature': self.config.temperature, 'stream': stream, } if stream: payload['stream_options'] = {'include_usage': True} response_format = _build_response_format(output_schema) if response_format is not None: payload['response_format'] = response_format return payload def _parse_tool_calls_from_message(self, message: dict[str, Any]) -> list[ToolCall]: tool_calls: list[ToolCall] = [] raw_tool_calls = message.get('tool_calls') if isinstance(raw_tool_calls, list): for idx, raw_call in enumerate(raw_tool_calls): if not isinstance(raw_call, dict): raise OpenAICompatError('Malformed tool call payload from model') function_block = raw_call.get('function') or {} if not isinstance(function_block, dict): raise OpenAICompatError('Malformed tool call function payload from model') name = function_block.get('name') if not isinstance(name, str) or not name: raise OpenAICompatError('Tool call missing function name') call_id = raw_call.get('id') if not isinstance(call_id, str) or not call_id: call_id = f'call_{idx}' arguments = _parse_tool_arguments(function_block.get('arguments')) tool_calls.append(ToolCall(id=call_id, name=name, arguments=arguments)) elif isinstance(message.get('function_call'), dict): function_call = message['function_call'] name = function_call.get('name') if not isinstance(name, str) or not name: raise OpenAICompatError('Function call missing name') arguments = _parse_tool_arguments(function_call.get('arguments')) tool_calls.append(ToolCall(id='call_0', name=name, arguments=arguments)) return tool_calls def _iter_sse_payloads(self, response: Any) -> Iterator[dict[str, Any]]: buffer: list[str] = [] while True: line = response.readline() if not line: break if isinstance(line, bytes): text = line.decode('utf-8', errors='replace') else: text = str(line) stripped = text.strip() if not stripped: if not buffer: continue joined = '\n'.join(buffer) buffer.clear() if joined == '[DONE]': break try: payload = json.loads(joined) except json.JSONDecodeError as exc: raise OpenAICompatError( f'Invalid JSON in streaming response: {joined!r}' ) from exc if not isinstance(payload, dict): raise OpenAICompatError('Malformed SSE payload from model backend') yield payload continue if stripped.startswith('data:'): buffer.append(stripped[5:].strip()) if buffer: joined = '\n'.join(buffer) if joined != '[DONE]': try: payload = json.loads(joined) except json.JSONDecodeError as exc: raise OpenAICompatError( f'Invalid trailing JSON in streaming response: {joined!r}' ) from exc if not isinstance(payload, dict): raise OpenAICompatError('Malformed trailing SSE payload from model backend') yield payload def _parse_stream_payload( self, payload: dict[str, Any], ) -> Iterator[StreamEvent]: usage = _parse_usage(payload.get('usage')) if usage.total_tokens: yield StreamEvent( type='usage', usage=usage, raw_event=payload, ) choices = payload.get('choices') if not isinstance(choices, list): return for choice in choices: if not isinstance(choice, dict): continue delta = choice.get('delta') if not isinstance(delta, dict): delta = {} content = delta.get('content') if isinstance(content, str) and content: yield StreamEvent( type='content_delta', delta=content, raw_event=choice, ) tool_calls = delta.get('tool_calls') if isinstance(tool_calls, list): for raw_tool_call in tool_calls: if not isinstance(raw_tool_call, dict): continue function_block = raw_tool_call.get('function') if not isinstance(function_block, dict): function_block = {} yield StreamEvent( type='tool_call_delta', tool_call_index=( raw_tool_call.get('index') if isinstance(raw_tool_call.get('index'), int) else 0 ), tool_call_id=( raw_tool_call.get('id') if isinstance(raw_tool_call.get('id'), str) else None ), tool_name=( function_block.get('name') if isinstance(function_block.get('name'), str) else None ), arguments_delta=( function_block.get('arguments') if isinstance(function_block.get('arguments'), str) else '' ), raw_event=raw_tool_call, ) finish_reason = choice.get('finish_reason') if finish_reason is not None: if not isinstance(finish_reason, str): finish_reason = str(finish_reason) yield StreamEvent( type='message_stop', finish_reason=finish_reason, raw_event=choice, )