"""OpenAI provider implementation. OpenAI is the industry standard for AI APIs. Models are loaded dynamically from the API. """ from typing import Optional, List import json import urllib.request import urllib.error from .base import AIProvider, AIProviderError class OpenAIProvider(AIProvider): """OpenAI provider using their Chat Completions API. Also compatible with OpenAI-compatible APIs like: - BytePlus/ByteDance (Kimi K2.5) - LocalAI - LM Studio - vLLM - Together AI - Any OpenAI-compatible endpoint """ NAME = "openai" REQUIRES_API_KEY = True DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions" DEFAULT_MODELS_URL = "https://api.openai.com/v1/models" # Models to exclude (not suitable for chat/text generation) EXCLUDED_PATTERNS = [ 'embedding', 'whisper', 'tts', 'dall-e', 'image', 'instruct', 'realtime', 'audio', 'moderation', 'search', 'code-search', 'text-similarity', 'babbage', 'davinci', 'curie', 'ada', 'transcribe' ] # Recommended models for chat (in priority order) RECOMMENDED_PREFIXES = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'] @staticmethod def _is_reasoning_model(model: str) -> bool: """True for OpenAI reasoning models (o-series + non-chat gpt-5+). These use a stricter API contract than chat models: - Must use ``max_completion_tokens`` instead of ``max_tokens`` - ``temperature`` is not accepted (only the default is supported) Chat-optimized variants (``gpt-5-chat-latest``, ``gpt-5.1-chat-latest``, etc.) keep the classic contract and are NOT flagged here. """ m = model.lower() # o1, o3, o4, o5 ... (o...) if len(m) >= 2 and m[0] == 'o' and m[1].isdigit(): return True # gpt-5, gpt-5-mini, gpt-5.1, gpt-5.2-pro ... EXCEPT *-chat-latest if m.startswith('gpt-5') and '-chat' not in m: return True return False def list_models(self) -> List[str]: """List available models for chat completions. Two modes: - Official OpenAI (no custom base_url): restrict to GPT chat models, excluding embedding/whisper/tts/dall-e/instruct/legacy variants. - OpenAI-compatible endpoint (LiteLLM, MLX, LM Studio, vLLM, LocalAI, Ollama-proxy, etc.): the "gpt" substring check is dropped so user-served models (e.g. ``mlx-community/Llama-3.1-8B``, ``Qwen3-32B``, ``mistralai/...``) show up. EXCLUDED_PATTERNS still applies — embeddings/whisper/tts aren't chat-capable on any backend. Returns: List of model IDs suitable for chat completions. """ if not self.api_key: return [] is_custom_endpoint = bool(self.base_url) try: # Determine models URL from base_url if set if self.base_url: base = self.base_url.rstrip('/') if not base.endswith('/v1'): base = f"{base}/v1" models_url = f"{base}/models" else: models_url = self.DEFAULT_MODELS_URL req = urllib.request.Request( models_url, headers={'Authorization': f'Bearer {self.api_key}'}, method='GET' ) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode('utf-8')) models = [] for model in data.get('data', []): model_id = model.get('id', '') if not model_id: continue model_lower = model_id.lower() # Official OpenAI: restrict to GPT chat models. Custom # endpoints serve arbitrarily named models, so this # substring check would drop every valid result there. if not is_custom_endpoint and 'gpt' not in model_lower: continue # Exclude non-chat models on every backend. if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS): continue models.append(model_id) # Sort with recommended models first (only meaningful for OpenAI # official; on custom endpoints the prefixes rarely match, so # entries fall through to alphabetical order, which is fine). def sort_key(m): m_lower = m.lower() for i, prefix in enumerate(self.RECOMMENDED_PREFIXES): if m_lower.startswith(prefix): return (i, m) return (len(self.RECOMMENDED_PREFIXES), m) return sorted(models, key=sort_key) except Exception as e: print(f"[OpenAIProvider] Failed to list models: {e}") return [] def _get_api_url(self) -> str: """Get the API URL, using custom base_url if provided.""" if self.base_url: # Ensure the URL ends with the correct path base = self.base_url.rstrip('/') if not base.endswith('/chat/completions'): if not base.endswith('/v1'): base = f"{base}/v1" base = f"{base}/chat/completions" return base return self.DEFAULT_API_URL def generate(self, system_prompt: str, user_message: str, max_tokens: int = 200) -> Optional[str]: """Generate a response using OpenAI's API or compatible endpoint. Args: system_prompt: System instructions user_message: User message to process max_tokens: Maximum response length Returns: Generated text or None if failed Raises: AIProviderError: If API key is missing or request fails """ if not self.api_key: raise AIProviderError("API key required for OpenAI") payload = { 'model': self.model, 'messages': [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_message}, ], } # Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a # different parameter contract: max_completion_tokens instead of # max_tokens, and no temperature field. Sending the classic chat # parameters to them produces HTTP 400 Bad Request. # # They also spend output budget on internal reasoning by default, # which empties the user-visible reply when max_tokens is small # (like the ~200 we use for notifications). reasoning_effort # 'minimal' keeps that internal reasoning to a minimum so the # entire budget is available for the translation, which is # exactly what this pipeline wants. OpenAI documents 'minimal', # 'low', 'medium', 'high' — 'minimal' is the right setting for a # straightforward translate+explain task. if self._is_reasoning_model(self.model): payload['max_completion_tokens'] = max_tokens payload['reasoning_effort'] = 'minimal' else: payload['max_tokens'] = max_tokens payload['temperature'] = 0.3 headers = { 'Content-Type': 'application/json', 'Authorization': f'Bearer {self.api_key}', } api_url = self._get_api_url() result = self._make_request(api_url, payload, headers) try: return result['choices'][0]['message']['content'].strip() except (KeyError, IndexError) as e: raise AIProviderError(f"Unexpected response format: {e}")