Source code for litestar_mcp.agent.models

"""Model client abstractions and Google GenAI SDK integration."""

from dataclasses import dataclass, field
from importlib import import_module
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
from uuid import uuid4

from litestar_mcp.core.exceptions import MissingDependencyError

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Sequence

    import httpx2

    from litestar_mcp.agent.spec import AgentMessage
    from litestar_mcp.core.tools import Tool

ModelEventType = Literal["delta", "thought", "tool_call", "usage"]

__all__ = (
    "GoogleGenAIClient",
    "MockModelClient",
    "ModelClient",
    "ModelDelta",
    "ModelEventType",
)


[docs] @dataclass(slots=True) class ModelDelta: """Incremental chunk emitted by a ModelClient during an agent turn.""" event_type: "ModelEventType" text: "str | None" = None thought: "str | None" = None tool_name: "str | None" = None call_id: "str | None" = None arguments: "dict[str, Any] | None" = None prompt_tokens: "int | None" = None completion_tokens: "int | None" = None provider_metadata: "dict[str, dict[str, Any]]" = field(default_factory=dict) thought_tokens: "int | None" = None
@runtime_checkable class ModelClient(Protocol): """Asynchronous protocol implemented by all LLM model adapters.""" def stream_turn( self, *, system_instruction: "str | None", messages: "Sequence[AgentMessage]", tools: "Sequence[Tool]", ) -> "AsyncIterator[ModelDelta]": """Stream an agent turn yielding text, thoughts, tool calls, and usage deltas.""" ...
[docs] class MockModelClient: """Deterministic scriptable model client for fast offline unit tests."""
[docs] def __init__(self, responses: "list[list[ModelDelta]] | None" = None) -> None: """Initialize mock client with optional response queue.""" self.responses: list[list[ModelDelta]] = responses or [] self.call_history: list[dict[str, Any]] = []
[docs] async def stream_turn( self, *, system_instruction: "str | None", messages: "Sequence[AgentMessage]", tools: "Sequence[Tool]", ) -> "AsyncIterator[ModelDelta]": """Record turn inputs and yield queued scripted responses.""" self.call_history.append( { "system_instruction": system_instruction, "messages": list(messages), "tools": list(tools), } ) if self.responses: for delta in self.responses.pop(0): yield delta
def _build_contents( messages: "Sequence[AgentMessage]", types: "Any", ) -> "tuple[list[str], list[Any]]": """Translate history into Gemini contents, returning folded system texts and non-empty contents.""" system_texts: list[str] = [] contents: list[Any] = [] provider_ids: dict[str, str | None] = {} for message in messages: if message.role == "system": if message.content: system_texts.append(message.content) continue parts: list[Any] = [] if message.content: parts.append(types.Part(text=message.content)) for call in message.tool_calls: google = call.provider_metadata.get("google", {}) provider_ids[call.call_id] = google.get("call_id") parts.append( types.Part( function_call=types.FunctionCall( id=google.get("call_id"), name=call.name, args=call.arguments, ), thought_signature=google.get("thought_signature"), ) ) for result in message.tool_results: response = {"error": result.content} if result.is_error else {"result": result.content} parts.append( types.Part( function_response=types.FunctionResponse( id=provider_ids.get(result.call_id), name=result.name, response=response, ) ) ) if parts: role = "model" if message.role == "assistant" else "user" contents.append(types.Content(role=role, parts=parts)) return system_texts, contents def _build_function_declarations(tools: "Sequence[Tool]", types: "Any") -> "list[Any]": """Convert tools into Google GenAI FunctionDeclarations using parameters_json_schema.""" declarations: list[Any] = [] for t in tools: params = t.parameters if hasattr(t, "parameters") else {} if isinstance(params, dict) and params.get("properties"): declarations.append( types.FunctionDeclaration( name=t.name, description=t.description or "", parameters_json_schema=params, ) ) else: declarations.append( types.FunctionDeclaration( name=t.name, description=t.description or "", ) ) return declarations def _parse_part_deltas(part: "Any") -> "list[ModelDelta]": """Extract thought, text, and tool_call deltas from a single candidate part.""" part_deltas: list[ModelDelta] = [] thought_attr = getattr(part, "thought", False) part_text = getattr(part, "text", None) fn_call = getattr(part, "function_call", None) if thought_attr is True and isinstance(part_text, str) and part_text: part_deltas.append(ModelDelta(event_type="thought", text=part_text, thought=part_text)) elif isinstance(thought_attr, str) and thought_attr: part_deltas.append(ModelDelta(event_type="thought", text=thought_attr, thought=thought_attr)) elif isinstance(part_text, str) and part_text and fn_call is None: part_deltas.append(ModelDelta(event_type="delta", text=part_text)) if fn_call is not None: call_id = getattr(fn_call, "id", None) or uuid4().hex thought_sig = getattr(part, "thought_signature", None) part_deltas.append( ModelDelta( event_type="tool_call", tool_name=fn_call.name, call_id=call_id, arguments=dict(fn_call.args or {}), provider_metadata={ "google": { "call_id": getattr(fn_call, "id", None), "thought_signature": thought_sig, } }, ) ) return part_deltas def _parse_chunk_deltas(chunk: "Any") -> "tuple[list[ModelDelta], Any | None]": """Parse text, thoughts, and tool calls from a stream chunk, returning usage metadata.""" deltas: list[ModelDelta] = [] candidates = getattr(chunk, "candidates", None) or [] has_parts = False for candidate in candidates: content = getattr(candidate, "content", None) if content is None: continue parts = getattr(content, "parts", None) or [] for part in parts: has_parts = True deltas.extend(_parse_part_deltas(part)) if not has_parts: chunk_text = getattr(chunk, "text", None) if isinstance(chunk_text, str) and chunk_text: deltas.append(ModelDelta(event_type="delta", text=chunk_text)) usage = getattr(chunk, "usage_metadata", None) return deltas, usage
[docs] class GoogleGenAIClient: """First-party Google GenAI model client wrapping google.genai with httpx2 support."""
[docs] def __init__( self, model: "str" = "gemini-3.8-flash", *, api_key: "str | None" = None, vertexai: "bool | None" = None, project: "str | None" = None, location: "str | None" = None, thinking_budget: "int | None" = None, thinking_level: "str | None" = None, include_thoughts: "bool | None" = None, temperature: "float | None" = None, http_client: "httpx2.AsyncClient | None" = None, http_options: "Any | None" = None, client: "Any | None" = None, ) -> None: """Initialize Google GenAI client with configuration.""" self.model = model self.api_key = api_key self.vertexai = vertexai self.project = project self.location = location self.thinking_budget = thinking_budget self.thinking_level = thinking_level self.include_thoughts = include_thoughts self.temperature = temperature self.http_client = http_client self.http_options = http_options self._client = client
@property def provider_name(self) -> "str": """Return canonical OpenTelemetry provider name.""" return "gcp.vertex_ai" if self.vertexai else "gcp.gemini" def _build_http_options(self, types_module: "Any") -> "Any | None": """Build google.genai.types.HttpOptions wired with httpx2.AsyncClient when configured.""" if self.http_options is not None: return self.http_options if self.http_client is not None: http_options_cls = getattr(types_module, "HttpOptions", None) if http_options_cls is not None: return http_options_cls(httpx_async_client=self.http_client) return None def _ensure_client(self) -> "tuple[Any, Any]": """Lazily load google.genai and initialize client.""" if self._client is not None: types_module = import_module("google.genai.types") return self._client, types_module try: genai_module = import_module("google.genai") types_module = import_module("google.genai.types") except ImportError as exc: raise MissingDependencyError( package="google-genai", extra="google-genai", ) from exc client_kwargs: dict[str, Any] = {} if self.api_key is not None: client_kwargs["api_key"] = self.api_key if self.vertexai is not None: client_kwargs["vertexai"] = self.vertexai if self.project is not None: client_kwargs["project"] = self.project if self.location is not None: client_kwargs["location"] = self.location resolved_http_options = self._build_http_options(types_module) if resolved_http_options is not None: client_kwargs["http_options"] = resolved_http_options self._client = genai_module.Client(**client_kwargs) return self._client, types_module
[docs] async def stream_turn( self, *, system_instruction: "str | None", messages: "Sequence[AgentMessage]", tools: "Sequence[Tool]", ) -> "AsyncIterator[ModelDelta]": """Stream an agent turn using Google GenAI SDK.""" client, types = self._ensure_client() system_texts, contents = _build_contents(messages, types) function_declarations = _build_function_declarations(tools, types) genai_tools = [types.Tool(function_declarations=function_declarations)] if function_declarations else None folded_instruction: str | None = None instruction_parts = [s for s in [system_instruction, *system_texts] if s] if instruction_parts: folded_instruction = "\n\n".join(instruction_parts) config_kwargs: dict[str, Any] = {} if folded_instruction: config_kwargs["system_instruction"] = folded_instruction if genai_tools: config_kwargs["tools"] = genai_tools if self.temperature is not None: config_kwargs["temperature"] = self.temperature thinking_kwargs: dict[str, Any] = {} if self.thinking_budget is not None: thinking_kwargs["thinking_budget"] = self.thinking_budget if self.thinking_level is not None: thinking_kwargs["thinking_level"] = self.thinking_level if self.include_thoughts is not None: thinking_kwargs["include_thoughts"] = self.include_thoughts if thinking_kwargs and hasattr(types, "ThinkingConfig"): config_kwargs["thinking_config"] = types.ThinkingConfig(**thinking_kwargs) config = types.GenerateContentConfig(**config_kwargs) response_stream = await client.aio.models.generate_content_stream( model=self.model, contents=contents, config=config, ) last_usage: Any = None async for chunk in response_stream: deltas, usage = _parse_chunk_deltas(chunk) if usage is not None: last_usage = usage for delta in deltas: yield delta if last_usage is not None: yield ModelDelta( event_type="usage", prompt_tokens=getattr(last_usage, "prompt_token_count", None), completion_tokens=getattr(last_usage, "candidates_token_count", None), thought_tokens=getattr(last_usage, "thoughts_token_count", None), )