from __future__ import annotations from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Any from openai import AsyncOpenAI, OpenAI from supply_agent.config import Settings from supply_agent.types import Message, Role, ToolCall, ToolDefinition if TYPE_CHECKING: from supply_agent.logging.logger import AgentLogger class LLMClient: """OpenRouter LLM client using the OpenAI-compatible API.""" def __init__( self, settings: Settings, logger: AgentLogger | None = None, *, reasoning_effort: str | None = "medium", ) -> None: self.settings = settings self.model = settings.openrouter_model self.logger = logger # OpenRouter reasoning effort: low / medium / high / max / xhigh. # None or "" disables the parameter. self.reasoning_effort = reasoning_effort extra_headers: dict[str, str] = {} if settings.openrouter_site_url: extra_headers["HTTP-Referer"] = settings.openrouter_site_url if settings.openrouter_site_name: extra_headers["X-Title"] = settings.openrouter_site_name self._client = OpenAI( api_key=settings.openrouter_api_key, base_url=settings.openrouter_base_url, default_headers=extra_headers or None, ) self._async_client = AsyncOpenAI( api_key=settings.openrouter_api_key, base_url=settings.openrouter_base_url, default_headers=extra_headers or None, ) def _extra_body(self) -> dict[str, Any] | None: """OpenRouter-only params (e.g. Claude reasoning effort).""" effort = (self.reasoning_effort or "").strip() if not effort: return None return {"reasoning": {"effort": effort}} def chat( self, messages: list[Message], tools: list[ToolDefinition] | None = None, temperature: float | None = None, *, iteration: int = 0, ) -> Message: """Send a chat completion request and return the assistant message.""" temp = temperature if temperature is not None else self.settings.agent_temperature if self.logger: self.logger.log_llm_input(iteration, self.model, messages, tools, temp) kwargs: dict[str, Any] = { "model": self.model, "messages": [m.to_api_dict() for m in messages], "tools": [t.to_api_dict() for t in tools] if tools else None, "temperature": temp, } extra_body = self._extra_body() if extra_body: kwargs["extra_body"] = extra_body response = self._client.chat.completions.create(**kwargs) raw_message = response.choices[0].message result = self._parse_response(raw_message) if self.logger: self.logger.log_llm_output(iteration, result, raw_response=response) return result async def achat( self, messages: list[Message], tools: list[ToolDefinition] | None = None, temperature: float | None = None, *, iteration: int = 0, ) -> Message: """Async chat completion.""" temp = temperature if temperature is not None else self.settings.agent_temperature if self.logger: self.logger.log_llm_input(iteration, self.model, messages, tools, temp) kwargs: dict[str, Any] = { "model": self.model, "messages": [m.to_api_dict() for m in messages], "tools": [t.to_api_dict() for t in tools] if tools else None, "temperature": temp, } extra_body = self._extra_body() if extra_body: kwargs["extra_body"] = extra_body response = await self._async_client.chat.completions.create(**kwargs) raw_message = response.choices[0].message result = self._parse_response(raw_message) if self.logger: self.logger.log_llm_output(iteration, result, raw_response=response) return result def stream( self, messages: list[Message], tools: list[ToolDefinition] | None = None, temperature: float | None = None, *, iteration: int = 0, ) -> Iterator[str]: """Stream text content chunks from the model.""" temp = temperature if temperature is not None else self.settings.agent_temperature if self.logger: self.logger.log_llm_input(iteration, self.model, messages, tools, temp) kwargs: dict[str, Any] = { "model": self.model, "messages": [m.to_api_dict() for m in messages], "tools": [t.to_api_dict() for t in tools] if tools else None, "temperature": temp, "stream": True, } extra_body = self._extra_body() if extra_body: kwargs["extra_body"] = extra_body stream = self._client.chat.completions.create(**kwargs) chunks: list[str] = [] for chunk in stream: delta = chunk.choices[0].delta if delta.content: chunks.append(delta.content) yield delta.content if self.logger: full_content = "".join(chunks) self.logger.log_llm_output( iteration, Message(role=Role.ASSISTANT, content=full_content), ) async def astream( self, messages: list[Message], tools: list[ToolDefinition] | None = None, temperature: float | None = None, *, iteration: int = 0, ) -> AsyncIterator[str]: """Async stream text content chunks.""" temp = temperature if temperature is not None else self.settings.agent_temperature if self.logger: self.logger.log_llm_input(iteration, self.model, messages, tools, temp) kwargs: dict[str, Any] = { "model": self.model, "messages": [m.to_api_dict() for m in messages], "tools": [t.to_api_dict() for t in tools] if tools else None, "temperature": temp, "stream": True, } extra_body = self._extra_body() if extra_body: kwargs["extra_body"] = extra_body stream = await self._async_client.chat.completions.create(**kwargs) chunks: list[str] = [] async for chunk in stream: delta = chunk.choices[0].delta if delta.content: chunks.append(delta.content) yield delta.content if self.logger: full_content = "".join(chunks) self.logger.log_llm_output( iteration, Message(role=Role.ASSISTANT, content=full_content), ) def _parse_response(self, choice_message: Any) -> Message: tool_calls = None if choice_message.tool_calls: tool_calls = [ ToolCall( id=tc.id, name=tc.function.name, arguments=tc.function.arguments, ) for tc in choice_message.tool_calls ] reasoning = getattr(choice_message, "reasoning", None) if reasoning is not None and not isinstance(reasoning, str): reasoning = str(reasoning) return Message( role=Role.ASSISTANT, content=choice_message.content, tool_calls=tool_calls, reasoning=reasoning, ) def set_model(self, model: str) -> None: """Switch to a different model at runtime.""" self.model = model