"""GenericOpenAIProvider — unified OpenAI-compatible provider for all 8 compat vendors. Covers: Groq, xAI/Grok, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat, Ollama, LMStudio (D-16/D-17/D-18). Key design decisions: - Subclasses OpenAIProvider to inherit singleton _client and _truncate (D-07/D-13). - Conditionally passes response_format={"type":"json_object"} based on supports_json_mode flag (D-01) — Gemini preset sets this to False (D-02). - Always parses the raw response with parse_classification / parse_suggestions imported from ai.utils (D-02 last-resort fallback contract — NEVER redefine locally; CLAUDE.md shared module map rule). """ from __future__ import annotations from ai.openai_provider import OpenAIProvider from ai.utils import parse_classification, parse_suggestions # D-02 contract class GenericOpenAIProvider(OpenAIProvider): """OpenAI-compatible provider that enforces JSON mode on every call. Named presets (Groq, xAI, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat, Ollama, LMStudio) are factory shortcuts in ai/__init__.py that pass the known base_url default for each vendor. supports_json_mode=False routes the provider through the parse_classification() fallback path without sending response_format — used for Gemini (D-02). """ supports_json_mode: bool = True # class-level default; overridden per instance def __init__( self, api_key: str, model: str, base_url: str | None, context_chars: int = 8000, supports_json_mode: bool = True, ): super().__init__( api_key=api_key, model=model, base_url=base_url, context_chars=context_chars, ) # Instance-level flag (may differ from class-level default) self.supports_json_mode = supports_json_mode async def classify( self, document_text: str, existing_topics: list[str], system_prompt: str, ): topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" user_msg = ( f"Existing topics: [{topics_str}]\n\n" f"Document text:\n{self._truncate(document_text)}" ) create_kwargs = dict( model=self._model, max_tokens=1024, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}, ], ) if self.supports_json_mode: # D-01: enforce structured JSON output on all supporting providers create_kwargs["response_format"] = {"type": "json_object"} # else: Gemini preset — omit response_format, fall back to parse_classification() response = await self._client.chat.completions.create(**create_kwargs) raw = response.choices[0].message.content or "" # D-02: parse_classification is the last-resort fallback — always called return parse_classification(raw) async def suggest_topics( self, document_text: str, system_prompt: str, ) -> list[str]: user_msg = ( "Suggest 3-5 topic names for this document. " "Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n" f"Document text:\n{self._truncate(document_text)}" ) create_kwargs = dict( model=self._model, max_tokens=256, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}, ], ) if self.supports_json_mode: create_kwargs["response_format"] = {"type": "json_object"} response = await self._client.chat.completions.create(**create_kwargs) raw = response.choices[0].message.content or "" # D-02: parse_suggestions is the last-resort fallback — always called return parse_suggestions(raw) # health_check is inherited from OpenAIProvider — no override needed