summaryrefslogtreecommitdiff
path: root/src/agents/main/provider.py
diff options
context:
space:
mode:
authorCaptainJack2491 <jayrupnakawala@gmail.com>2025-12-30 15:48:36 +0000
committerCaptainJack2491 <jayrupnakawala@gmail.com>2025-12-30 15:48:36 +0000
commit46ee0a5914d8248976c7e2308ce16f32f1bd4783 (patch)
tree9c960925445939db376781211a39cbdd8e89d318 /src/agents/main/provider.py
parent51aaea9f0cb3b77c6b80ad57a68b7b5a2c1faa71 (diff)
major commit
updated the program to use config.yaml made the runner to handel interleaved thinking added anthropic support and a lot more
Diffstat (limited to 'src/agents/main/provider.py')
-rw-r--r--src/agents/main/provider.py586
1 files changed, 586 insertions, 0 deletions
diff --git a/src/agents/main/provider.py b/src/agents/main/provider.py
new file mode 100644
index 0000000..837810c
--- /dev/null
+++ b/src/agents/main/provider.py
@@ -0,0 +1,586 @@
+"""
+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)