""" openai_adapter.py — Concrete LLMAdapter for OpenAI / OpenRouter / Ollama. V3 (Sidecar): adds stream_chat() for sentence-level streaming. Works with any OpenAI-compatible API. Configure via environment variables: OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://openrouter.ai/api/v1 OPENAI_MODEL=gpt-4o-mini """ import os from typing import AsyncGenerator, Optional from openai import AsyncOpenAI, OpenAI from llm_adapter import LLMAdapter class OpenAIAdapter(LLMAdapter): """ Wraps any OpenAI-compatible API as an LLMAdapter. Args: api_key: API key. Defaults to OPENAI_API_KEY env var. base_url: API base URL. Defaults to OPENAI_BASE_URL env var. model: Model name. Defaults to OPENAI_MODEL env var. system_prompt: Default system prompt for all calls. temperature: Sampling temperature (0 = deterministic). max_tokens: Max tokens in response. """ DEFAULT_BASE_URL = "https://api.openai.com/v1" DEFAULT_MODEL = "gpt-4o-mini" def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, system_prompt: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 1024, ): self._api_key = api_key or os.getenv("OPENAI_API_KEY", "") self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL) self._model = model or os.getenv("OPENAI_MODEL", self.DEFAULT_MODEL) self._system_prompt = system_prompt or ( "You are a helpful AI assistant. Be concise and accurate." ) self._temperature = temperature self._max_tokens = max_tokens # Synchronous client (existing blocking interface) self._client = OpenAI(api_key=self._api_key, base_url=self._base_url) # Async client (streaming interface) self._async_client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url) # ------------------------------------------------------------------ # Blocking interface (existing — unchanged) # ------------------------------------------------------------------ def chat( self, prompt: str, system_prompt: Optional[str] = None, ) -> str: """Send prompt to LLM and return response text (blocking).""" sys_msg = system_prompt or self._system_prompt response = self._client.chat.completions.create( model = self._model, messages = [ {"role": "system", "content": sys_msg}, {"role": "user", "content": prompt}, ], temperature = self._temperature, max_tokens = self._max_tokens, ) return response.choices[0].message.content or "" # ------------------------------------------------------------------ # Streaming interface (NEW) # ------------------------------------------------------------------ async def stream_chat( self, prompt: str, system_prompt: Optional[str] = None, ) -> AsyncGenerator[str, None]: """ Yield token chunks from OpenAI as they arrive (true async streaming). """ sys_msg = system_prompt or self._system_prompt stream = await self._async_client.chat.completions.create( model = self._model, messages = [ {"role": "system", "content": sys_msg}, {"role": "user", "content": prompt}, ], temperature = self._temperature, max_tokens = self._max_tokens, stream = True, ) async for chunk in stream: delta = chunk.choices[0].delta if delta and delta.content: yield delta.content def get_model_name(self) -> str: return self._model