diff options
Diffstat (limited to 'src/agents/main/provider.py')
| -rw-r--r-- | src/agents/main/provider.py | 586 |
1 files changed, 0 insertions, 586 deletions
diff --git a/src/agents/main/provider.py b/src/agents/main/provider.py deleted file mode 100644 index 837810c..0000000 --- a/src/agents/main/provider.py +++ /dev/null @@ -1,586 +0,0 @@ -""" -Provider abstraction layer. -Handles different LLM providers with OpenAI-compatible APIs and reasoning extraction. -""" -import json -import re -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple -from dataclasses import dataclass, field -from openai import OpenAI -from config_loader import ProviderConfig, ModelConfig - - -@dataclass -class ReasoningStep: - """A reasoning step from the model.""" - content: str - type: str = "reasoning" # "reasoning", "tool_call", "tool_result" - tool_name: Optional[str] = None - tool_args: Optional[Dict] = None - tool_result: Optional[str] = None - is_final_response: bool = False - - -@dataclass -class LLMResponse: - """Unified response from any LLM provider.""" - content: Optional[str] = None - reasoning_steps: List[ReasoningStep] = field(default_factory=list) - tool_calls: List[Dict] = field(default_factory=list) - usage: Dict[str, int] = field(default_factory=dict) - raw_response: Any = None - - -class ProviderAdapter(ABC): - """Base class for provider adapters.""" - - def __init__(self, provider_config: ProviderConfig, model_config: ModelConfig): - self.provider_config = provider_config - self.model_config = model_config - - @abstractmethod - def call(self, messages: List[Dict], tools: List[Dict]) -> LLMResponse: - """Make an API call and return a unified response.""" - pass - - @abstractmethod - def extract_reasoning(self, response: Any) -> List[ReasoningStep]: - """Extract reasoning steps from provider response.""" - pass - - def _merge_extra_body(self, extra_body: Dict[str, Any]) -> Dict[str, Any]: - """Merge model extra_body with provider extra_body.""" - merged = self.provider_config.extra_body.copy() - merged.update(extra_body) - return merged - - -class OpenAIProviderAdapter(ProviderAdapter): - """Adapter for OpenAI-compatible APIs (OpenAI, OpenRouter, Together, etc.).""" - - def __init__(self, provider_config: ProviderConfig, model_config: ModelConfig): - super().__init__(provider_config, model_config) - self.client = OpenAI( - base_url=provider_config.base_url, - api_key=provider_config.api_key - ) - - def call(self, messages: List[Dict], tools: List[Dict]) -> LLMResponse: - """Make an API call via OpenAI-compatible endpoint.""" - extra_body = self._merge_extra_body(self.model_config.extra_body) - - response = self.client.chat.completions.create( - model=self.model_config.id, - messages=messages, - tools=tools if tools else None, - temperature=self.model_config.temperature, - max_tokens=self.model_config.max_tokens, - extra_body=extra_body if extra_body else None - ) - - return self._parse_response(response) - - def _parse_response(self, response) -> LLMResponse: - """Parse OpenAI-compatible response.""" - # Extract usage - usage = {} - if response.usage: - usage = { - "prompt_tokens": response.usage.prompt_tokens, - "completion_tokens": response.usage.completion_tokens, - "total_tokens": response.usage.total_tokens - } - - message = response.choices[0].message - - # Extract content and reasoning - content = message.content or "" - reasoning = None - - # Try to get reasoning from different sources - # 1. Check for reasoning_content (OpenRouter) - if hasattr(message, 'reasoning_content') and message.reasoning_content: - reasoning = message.reasoning_content - - # 2. Check for reasoning_details (structured) - elif hasattr(message, 'reasoning_details') and message.reasoning_details: - reasoning = self._extract_from_reasoning_details(message.reasoning_details) - - # 3. Regex fallback for <thinking> tags - else: - thought_match = re.search(r"<(thinking|thought)>(.*?)</\1>", content, re.DOTALL) - if thought_match: - reasoning = thought_match.group(2).strip() - content = content.replace(thought_match.group(0), "").strip() - - # Extract tool calls - tool_calls = [] - has_tool_calls = False - if message.tool_calls: - has_tool_calls = True - tool_calls = [ - { - "id": tc.id, - "type": tc.type, - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments - } - } - for tc in message.tool_calls - ] - - # If there are tool calls, content should be None (reasoning is in the reasoning field) - if has_tool_calls: - content = None - - # Build reasoning steps - reasoning_steps = [] - if reasoning: - reasoning_steps.append(ReasoningStep(content=reasoning, type="reasoning")) - - # Add final response if no tool calls - if not tool_calls and content: - reasoning_steps.append(ReasoningStep( - content=content, - type="reasoning", - is_final_response=True - )) - - return LLMResponse( - content=content, - reasoning_steps=reasoning_steps, - tool_calls=tool_calls, - usage=usage, - raw_response=response - ) - - def _extract_from_reasoning_details(self, reasoning_details: List[Dict]) -> str: - """Extract reasoning text from structured reasoning_details.""" - reasoning_parts = [] - for item in reasoning_details: - if item.get("type") == "reasoning.text": - reasoning_parts.append(item.get("text", "")) - return "\n".join(reasoning_parts).strip() - - def extract_reasoning(self, response: Any) -> List[ReasoningStep]: - """Extract reasoning steps from raw response.""" - parsed = self._parse_response(response) - return parsed.reasoning_steps - - -class GoogleProviderAdapter(ProviderAdapter): - """Adapter for Google's Generative Language API.""" - - def __init__(self, provider_config: ProviderConfig, model_config: ModelConfig): - super().__init__(provider_config, model_config) - # Google uses a different SDK, but we can use OpenAI-compatible endpoint - self.client = OpenAI( - base_url=provider_config.base_url, - api_key=provider_config.api_key - ) - - def call(self, messages: List[Dict], tools: List[Dict]) -> LLMResponse: - """Make an API call via Google Generative Language API.""" - extra_body = self._merge_extra_body(self.model_config.extra_body) - - response = self.client.chat.completions.create( - model=self.model_config.id, - messages=messages, - tools=tools if tools else None, - temperature=self.model_config.temperature, - max_tokens=self.model_config.max_tokens, - extra_body=extra_body if extra_body else None - ) - - return self._parse_response(response) - - def _parse_response(self, response) -> LLMResponse: - """Parse Google-compatible response.""" - usage = {} - if response.usage: - usage = { - "prompt_tokens": response.usage.prompt_tokens, - "completion_tokens": response.usage.completion_tokens, - "total_tokens": response.usage.total_tokens - } - - message = response.choices[0].message - content = message.content or "" - - # Google doesn't typically output structured reasoning in this format - # Just return content - reasoning_steps = [] - if content: - reasoning_steps.append(ReasoningStep( - content=content, - type="reasoning", - is_final_response=True - )) - - tool_calls = [] - if message.tool_calls: - tool_calls = [ - { - "id": tc.id, - "type": tc.type, - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments - } - } - for tc in message.tool_calls - ] - - return LLMResponse( - content=content, - reasoning_steps=reasoning_steps, - tool_calls=tool_calls, - usage=usage, - raw_response=response - ) - - def extract_reasoning(self, response: Any) -> List[ReasoningStep]: - """Extract reasoning steps from raw response.""" - parsed = self._parse_response(response) - return parsed.reasoning_steps - - -class AnthropicProviderAdapter(ProviderAdapter): - """Adapter for Anthropic's direct API.""" - - def __init__(self, provider_config: ProviderConfig, model_config: ModelConfig): - super().__init__(provider_config, model_config) - try: - import anthropic - self.client = anthropic.Anthropic( - api_key=provider_config.api_key - ) - except ImportError: - raise ImportError("anthropic package not installed. Run: pip install anthropic") - - def call(self, messages: List[Dict], tools: List[Dict]) -> LLMResponse: - """Make an API call via Anthropic API.""" - # Convert OpenAI-style messages to Anthropic format - system_prompt = None - anthropic_messages = [] - - for msg in messages: - role = msg.get("role") - content = msg.get("content") - - if role == "system": - system_prompt = content - elif role == "user": - anthropic_messages.append({ - "role": "user", - "content": content - }) - elif role == "assistant": - # Check for tool calls in the message - if msg.get("tool_calls"): - # Anthropic uses content blocks for tool calls - blocks = [{"type": "text", "text": content or ""}] - for tc in msg.get("tool_calls", []): - blocks.append({ - "type": "tool_use", - "id": tc["id"], - "name": tc["function"]["name"], - "input": json.loads(tc["function"]["arguments"]) - }) - anthropic_messages.append({ - "role": "assistant", - "content": blocks - }) - else: - anthropic_messages.append({ - "role": "assistant", - "content": content - }) - elif role == "tool": - # Tool result - anthropic_messages.append({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": msg.get("tool_call_id"), - "content": msg.get("content") - } - ] - }) - - # Build thinking config from extra_body - extra_body = self._merge_extra_body(self.model_config.extra_body) - thinking_config = extra_body.get("thinking", {"type": "enabled", "budget_tokens": 10000}) - - response = self.client.messages.create( - model=self.model_config.id, - max_tokens=self.model_config.max_tokens or 16000, - thinking=thinking_config, - system=system_prompt, - messages=anthropic_messages - ) - - return self._parse_response(response) - - def _parse_response(self, response) -> LLMResponse: - """Parse Anthropic response with content blocks.""" - # Extract usage - usage = {} - if hasattr(response, "usage"): - usage = { - "input_tokens": getattr(response.usage, "input_tokens", 0), - "output_tokens": getattr(response.usage, "output_tokens", 0), - "total_tokens": getattr(response.usage, "input_tokens", 0) + getattr(response.usage, "output_tokens", 0) - } - - # Parse content blocks - reasoning_content = "" - final_content = "" - reasoning_steps = [] - - for block in response.content: - if block.type == "thinking": - # Extract reasoning from thinking block - if hasattr(block, "thinking") and block.thinking: - reasoning_content += block.thinking + "\n" - reasoning_steps.append(ReasoningStep( - content=block.thinking, - type="reasoning" - )) - elif block.type == "text": - final_content += block.text + "\n" - - # Clean up - reasoning_content = reasoning_content.strip() - final_content = final_content.strip() - - # Check for tool use blocks (Anthropic tool use) - tool_calls = [] - for block in response.content: - if block.type == "tool_use": - tool_calls.append({ - "id": block.id, - "type": "function", - "function": { - "name": block.name, - "arguments": json.dumps(block.input) - } - }) - - # Build response - if tool_calls: - # If there are tool calls, content is None - pass - elif final_content: - # Final response - reasoning_steps.append(ReasoningStep( - content=final_content, - type="reasoning", - is_final_response=True - )) - - return LLMResponse( - content=final_content if not tool_calls else None, - reasoning_steps=reasoning_steps, - tool_calls=tool_calls, - usage=usage, - raw_response=response - ) - - def extract_reasoning(self, response: Any) -> List[ReasoningStep]: - """Extract reasoning steps from raw response.""" - parsed = self._parse_response(response) - return parsed.reasoning_steps - - -class MoonshotProviderAdapter(ProviderAdapter): - """Adapter for Moonshot AI (Kimi-K2) using proprietary token format for tool calls.""" - - def __init__(self, provider_config: ProviderConfig, model_config: ModelConfig): - super().__init__(provider_config, model_config) - self.client = OpenAI( - base_url=provider_config.base_url, - api_key=provider_config.api_key - ) - - def call(self, messages: List[Dict], tools: List[Dict]) -> LLMResponse: - """Make an API call via Moonshot AI endpoint.""" - extra_body = self._merge_extra_body(self.model_config.extra_body) - - response = self.client.chat.completions.create( - model=self.model_config.id, - messages=messages, - tools=tools if tools else None, - temperature=self.model_config.temperature, - max_tokens=self.model_config.max_tokens, - extra_body=extra_body if extra_body else None - ) - - return self._parse_response(response) - - def _parse_response(self, response) -> LLMResponse: - """Parse Moonshot AI response with proprietary token format.""" - usage = {} - if response.usage: - usage = { - "prompt_tokens": response.usage.prompt_tokens, - "completion_tokens": response.usage.completion_tokens, - "total_tokens": response.usage.total_tokens - } - - message = response.choices[0].message - content = message.content or "" - - # Extract reasoning from various sources - reasoning = None - - # 1. Check for reasoning_content attribute (OpenRouter structured output) - if hasattr(message, 'reasoning_content') and message.reasoning_content: - reasoning = message.reasoning_content - - # 2. Check for <thinking> tags in content - if not reasoning: - thought_match = re.search(r"<(thinking|thought)>(.*?)</\1>", content, re.DOTALL) - if thought_match: - reasoning = thought_match.group(2).strip() - content = content.replace(thought_match.group(0), "").strip() - - # DEBUG: Check for OpenAI tool_calls first - openai_tool_calls = [] - has_openai_tc = hasattr(message, 'tool_calls') and message.tool_calls - if has_openai_tc: - for tc in message.tool_calls: - openai_tool_calls.append({ - "id": tc.id, - "type": tc.type, - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments - } - }) - - # Extract tool calls from proprietary Kimi-K2 token format - proprietary_tool_calls = self._extract_tool_calls(content) - - # Debug output - has_prop_tc = len(proprietary_tool_calls) > 0 - print(f" [MoonshotAdapter] content_len={len(content)}, has_reasoning={bool(reasoning)}, has_openai_tc={has_openai_tc}, has_proprietary_tc={has_prop_tc}") - - # Use OpenAI tool_calls if available, otherwise use proprietary - tool_calls = openai_tool_calls if openai_tool_calls else proprietary_tool_calls - - # Clean tool calls from content - clean_content = content - if tool_calls: - clean_content = self._remove_tool_tokens(clean_content) - - # Build reasoning steps - reasoning_steps = [] - - # Add reasoning if present (either from reasoning_content or <thinking> tags) - if reasoning: - reasoning_steps.append(ReasoningStep(content=reasoning, type="reasoning")) - - # When there are tool calls, the content is the model's reasoning about tool selection - if tool_calls and clean_content: - reasoning_steps.append(ReasoningStep( - content=clean_content, - type="reasoning" - )) - - # Add final response content if present and no tool calls - if not tool_calls and clean_content: - reasoning_steps.append(ReasoningStep( - content=clean_content, - type="reasoning", - is_final_response=True - )) - - return LLMResponse( - content=clean_content if not tool_calls else None, - reasoning_steps=reasoning_steps, - tool_calls=tool_calls, - usage=usage, - raw_response=response - ) - - def _extract_tool_calls(self, content: str) -> List[Dict]: - """Extract tool calls from Kimi-K2 proprietary token format.""" - if '<|tool_calls_section_begin|>' not in content: - return [] - - # Pattern to match Kimi-K2 tool call format: - # <|tool_call_begin|>functions.read_file:1<|tool_call_argument_begin|>{"file_path": "..."}<|tool_call_end|> - pattern = r"<\|tool_call_begin\|>\s*(?P<tool_call_id>[\w\.]+:\d+)\s*<\|tool_call_argument_begin\|>\s*(?P<function_arguments>.*?)\s*<\|tool_call_end\|>" - - tool_calls = [] - tool_calls_section_match = re.search( - r"<\|tool_calls_section_begin\|>(.*?)<\|tool_calls_section_end\|>", - content, - re.DOTALL - ) - - if not tool_calls_section_match: - return [] - - section_content = tool_calls_section_match.group(1) - - for match in re.finditer(pattern, section_content, re.DOTALL): - function_id = match.group("tool_call_id") - function_args = match.group("function_arguments") - - # Parse function name from ID: functions.read_file:0 -> read_file - function_name = function_id.split('.')[1].split(':')[0] - - # Generate a proper UUID-style ID for compatibility - tool_id = f"tc_{len(tool_calls)}" - - tool_calls.append({ - "id": tool_id, - "type": "function", - "function": { - "name": function_name, - "arguments": function_args - } - }) - - return tool_calls - - def _remove_tool_tokens(self, content: str) -> str: - """Remove proprietary tool call tokens from content.""" - # Remove the entire tool calls section - content = re.sub( - r"<\|tool_calls_section_begin\|>.*?<\|tool_calls_section_end\||>", - "", - content, - flags=re.DOTALL - ) - return content.strip() - - def extract_reasoning(self, response: Any) -> List[ReasoningStep]: - """Extract reasoning steps from raw response.""" - parsed = self._parse_response(response) - return parsed.reasoning_steps - - -def create_provider_adapter(provider_config: ProviderConfig, model_config: ModelConfig) -> ProviderAdapter: - """Factory function to create the appropriate provider adapter.""" - provider_name = provider_config.name.lower() - - adapters = { - "openai": OpenAIProviderAdapter, - "openrouter": OpenAIProviderAdapter, - "together": OpenAIProviderAdapter, - "google": GoogleProviderAdapter, - "anthropic": AnthropicProviderAdapter, - "moonshot": MoonshotProviderAdapter, - } - - adapter_class = adapters.get(provider_name) - if not adapter_class: - raise ValueError(f"Unsupported provider: {provider_name}") - - return adapter_class(provider_config, model_config) |
