"""OpenAI Adapter - OpenAI-compatible API adapter Supports OpenAI, DeepSeek, GLM and other OpenAI-compatible APIs. """ import json import logging from typing import Dict, List, Any, AsyncGenerator from .base import ProviderAdapter from ..llm_response import ParsedDelta, LLMResponse, StreamAccumulator, llm_parser_factory logger = logging.getLogger(__name__) class OpenAIAdapter(ProviderAdapter): """OpenAI-compatible API adapter Supported Providers: - OpenAI (api.openai.com) - DeepSeek (api.deepseek.com) - GLM/Zhipu AI - Any service compatible with OpenAI Chat Completions API Features: - Thinking content (reasoning_content, reasoning) - Tool calls (tool_calls) - Streaming responses (SSE) """ @property def provider_type(self) -> str: return "openai" def __init__(self): self._accumulator = llm_parser_factory() def build_request( self, model: str, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]] = None, **kwargs ) -> tuple[Dict[str, Any], Dict[str, str]]: """Build OpenAI-format request""" api_key = kwargs.get("api_key", "") headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}" } body = { "model": model, "messages": messages, "stream": kwargs.get("stream", True) } # Optional parameters if "temperature" in kwargs: body["temperature"] = kwargs["temperature"] if "max_tokens" in kwargs: body["max_tokens"] = kwargs["max_tokens"] if "top_p" in kwargs: body["top_p"] = kwargs["top_p"] if "frequency_penalty" in kwargs: body["frequency_penalty"] = kwargs["frequency_penalty"] if "presence_penalty" in kwargs: body["presence_penalty"] = kwargs["presence_penalty"] if "stop" in kwargs: body["stop"] = kwargs["stop"] # Tool definitions if tools: body["tools"] = tools # Thinking capability (DeepSeek, etc.) if kwargs.get("thinking_enabled"): body["thinking_enabled"] = True body["thoughts"] = [{"type": "thought", "text": ""}] return body, headers def reset(self): """Reset accumulator for new stream""" self._accumulator.reset() async def parse_stream_chunk( self, raw_chunk: str ) -> AsyncGenerator[ParsedDelta, None]: """Parse OpenAI-format SSE stream""" # Parse SSE line event_type, data_str = self._parse_sse_line(raw_chunk) # Skip empty data if not data_str: return # Handle [DONE] marker if data_str == "[DONE]": self._accumulator.set_complete() yield self._accumulator._create_delta() return try: chunk = json.loads(data_str) except json.JSONDecodeError: logger.warning(f"Failed to parse chunk: {data_str[:100]}") return # Handle errors if event_type == "error" or "error" in chunk: error_content = chunk.get("error", {}).get("message", str(chunk)) logger.error(f"Stream error: {error_content}") yield ParsedDelta() return # Extract usage (usually in the last chunk) usage = chunk.get("usage") if usage: self._accumulator.set_usage(usage) # Parse choices for choice in chunk.get("choices", []): delta = choice.get("delta", {}) # Handle thinking content (DeepSeek, etc.) thinking = delta.get("reasoning_content") or delta.get("reasoning") or "" if thinking: self._accumulator.thinking += thinking self._accumulator.thinking = self._accumulator.thinking # trigger setter # Handle text content content = delta.get("content") or "" if content: # Check for embedded thinking tags thinking_part, clean_text = self._extract_thinking_tags(content) if thinking_part: self._accumulator.thinking += thinking_part if clean_text: self._accumulator.text += clean_text # Tool calls tool_calls = delta.get("tool_calls") if tool_calls: self._accumulator.tool_calls = tool_calls # Check if complete finish_reason = choice.get("finish_reason") if finish_reason: self._accumulator.is_complete = True # Only yield if there's meaningful content if self._accumulator.has_content() or self._accumulator.is_complete: yield self._accumulator._create_delta() def parse_response( self, data: Dict[str, Any] ) -> LLMResponse: """Parse OpenAI-format non-streaming response""" choice = data.get("choices", [{}])[0] message = choice.get("message", {}) content = message.get("content", "") or "" tool_calls = message.get("tool_calls") usage = data.get("usage") # Extract thinking content thinking = "" if content: thinking, clean_content = self._extract_thinking_tags(content) content = clean_content # DeepSeek may put thinking content in separate field if not thinking: thinking = message.get("reasoning_content") or "" return LLMResponse( content=content, thinking=thinking, tool_calls=tool_calls, usage=usage ) def supports_thinking(self) -> bool: return True def supports_tools(self) -> bool: return True def _parse_sse_line(self, line: str) -> tuple: """Parse SSE line""" event_type = None data_str = None for part in line.strip().split('\n'): if part.startswith('event: '): event_type = part[7:].strip() elif part.startswith('data: '): data_str = part[6:].strip() return event_type, data_str def _extract_thinking_tags(self, content: str) -> tuple: """Extract thinking tags from content Supported formats: - Standard: ... """ thinking_parts = [] clean_parts = [] i = 0 while i < len(content): remaining = content[i:].lower() # Standard format if remaining.startswith(""): end_tag = "" start = i + 7 # len("") end = content.find(end_tag, start) if end != -1: thinking_parts.append(content[start:end]) i = end + len(end_tag) continue # Regular character clean_parts.append(content[i]) i += 1 return "".join(thinking_parts), "".join(clean_parts)