devorch 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
providers/__init__.py ADDED
@@ -0,0 +1,130 @@
1
+ from providers.anthropic import AnthropicProvider
2
+ from providers.base import LLMProvider, ModelInfo
3
+ from providers.custom import CustomProvider
4
+ from providers.deepseek import DeepSeekProvider
5
+ from providers.gemini import GeminiProvider
6
+ from providers.github_copilot import GitHubCopilotProvider
7
+ from providers.groq import GroqProvider
8
+ from providers.kimi import KimiProvider
9
+ from providers.lmstudio import LMStudioProvider
10
+ from providers.local import LocalProvider
11
+ from providers.mistral import MistralProvider
12
+ from providers.openai import OpenAIProvider
13
+ from providers.openrouter import OpenRouterProvider
14
+ from providers.together import TogetherProvider
15
+
16
+ # All available providers
17
+ PROVIDERS: dict[str, type[LLMProvider]] = {
18
+ "openai": OpenAIProvider,
19
+ "anthropic": AnthropicProvider,
20
+ "gemini": GeminiProvider,
21
+ "local": LocalProvider,
22
+ "openrouter": OpenRouterProvider,
23
+ "mistral": MistralProvider,
24
+ "groq": GroqProvider,
25
+ "lmstudio": LMStudioProvider,
26
+ "together": TogetherProvider,
27
+ "github_copilot": GitHubCopilotProvider,
28
+ "deepseek": DeepSeekProvider,
29
+ "kimi": KimiProvider,
30
+ "custom": CustomProvider,
31
+ }
32
+
33
+ # Provider descriptions for help
34
+ PROVIDER_INFO = {
35
+ "openai": "OpenAI - GPT-4o, GPT-4, etc.",
36
+ "anthropic": "Anthropic - Claude 3.5, Claude 3, etc.",
37
+ "gemini": "Google - Gemini Pro, Flash, etc.",
38
+ "local": "Ollama - Local models (llama, mistral, etc.)",
39
+ "openrouter": "OpenRouter - Access 100+ models via one API",
40
+ "mistral": "Mistral AI - Mistral Large, Medium, Codestral",
41
+ "groq": "Groq - Ultra-fast inference (Llama, Mixtral)",
42
+ "lmstudio": "LM Studio - Run local models with UI",
43
+ "together": "Together AI - Open source models at scale",
44
+ "github_copilot": "GitHub Copilot - Multiple models via GitHub subscription",
45
+ "deepseek": "DeepSeek - Powerful reasoning and coding models",
46
+ "kimi": "Kimi (Moonshot) - Long context models up to 128K",
47
+ "custom": "Custom - Any OpenAI-compatible API endpoint",
48
+ }
49
+
50
+ # Environment variable names for API keys
51
+ PROVIDER_ENV_VARS = {
52
+ "openai": "OPENAI_API_KEY",
53
+ "anthropic": "ANTHROPIC_API_KEY",
54
+ "gemini": "GOOGLE_API_KEY",
55
+ "local": None,
56
+ "openrouter": "OPENROUTER_API_KEY",
57
+ "mistral": "MISTRAL_API_KEY",
58
+ "groq": "GROQ_API_KEY",
59
+ "lmstudio": None,
60
+ "together": "TOGETHER_API_KEY",
61
+ "github_copilot": "GITHUB_TOKEN",
62
+ "deepseek": "DEEPSEEK_API_KEY",
63
+ "kimi": "MOONSHOT_API_KEY",
64
+ "custom": "CUSTOM_API_KEY",
65
+ }
66
+
67
+
68
+ def get_provider(
69
+ name: str, model: str | None = None, api_key: str | None = None, **kwargs
70
+ ) -> LLMProvider:
71
+ """
72
+ Factory function to get a provider instance.
73
+
74
+ Args:
75
+ name: Provider name (openai, anthropic, gemini, local)
76
+ model: Model name (optional, uses provider default)
77
+ api_key: API key (optional, uses env var)
78
+ **kwargs: Additional provider-specific arguments (e.g., base_url for local)
79
+
80
+ Returns:
81
+ An LLMProvider instance
82
+
83
+ Raises:
84
+ ValueError: If provider name is unknown
85
+ """
86
+ if name not in PROVIDERS:
87
+ available = ", ".join(PROVIDERS.keys())
88
+ raise ValueError(f"Unknown provider: {name}. Available: {available}")
89
+
90
+ provider_class = PROVIDERS[name]
91
+
92
+ init_kwargs = {}
93
+ if model:
94
+ init_kwargs["model"] = model
95
+ if api_key:
96
+ init_kwargs["api_key"] = api_key
97
+ init_kwargs.update(kwargs)
98
+
99
+ return provider_class(**init_kwargs)
100
+
101
+
102
+ def get_default_models(provider_name: str) -> list[str]:
103
+ """Get default model list for a provider."""
104
+ if provider_name not in PROVIDERS:
105
+ return []
106
+ return PROVIDERS[provider_name].DEFAULT_MODELS
107
+
108
+
109
+ __all__ = [
110
+ "LLMProvider",
111
+ "ModelInfo",
112
+ "OpenAIProvider",
113
+ "AnthropicProvider",
114
+ "GeminiProvider",
115
+ "LocalProvider",
116
+ "OpenRouterProvider",
117
+ "MistralProvider",
118
+ "GroqProvider",
119
+ "LMStudioProvider",
120
+ "TogetherProvider",
121
+ "GitHubCopilotProvider",
122
+ "DeepSeekProvider",
123
+ "KimiProvider",
124
+ "CustomProvider",
125
+ "get_provider",
126
+ "get_default_models",
127
+ "PROVIDERS",
128
+ "PROVIDER_INFO",
129
+ "PROVIDER_ENV_VARS",
130
+ ]
providers/anthropic.py ADDED
@@ -0,0 +1,127 @@
1
+ from anthropic import Anthropic
2
+
3
+ from providers.base import LLMProvider, ModelInfo
4
+ from schemas.message import LLMResponse, Message, ToolCall
5
+
6
+
7
+ class AnthropicProvider(LLMProvider):
8
+ """Anthropic/Claude provider with tool use support."""
9
+
10
+ name = "anthropic"
11
+
12
+ DEFAULT_MODELS = [
13
+ "claude-sonnet-4-20250514",
14
+ "claude-opus-4-20250514",
15
+ "claude-3-5-sonnet-20241022",
16
+ "claude-3-5-haiku-20241022",
17
+ "claude-3-opus-20240229",
18
+ "claude-3-sonnet-20240229",
19
+ "claude-3-haiku-20240307",
20
+ ]
21
+
22
+ def __init__(self, model: str = "claude-sonnet-4-20250514", api_key: str | None = None):
23
+ self.client = Anthropic(api_key=api_key)
24
+ self.model = model
25
+
26
+ def list_models(self) -> list[ModelInfo]:
27
+ """Return available Claude models."""
28
+ # Anthropic doesn't have a models list API, use defaults
29
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
30
+
31
+ def generate(
32
+ self,
33
+ messages: list[Message],
34
+ tools: list | None = None,
35
+ stream: bool = False,
36
+ ) -> LLMResponse:
37
+ # Extract system message (Anthropic handles it separately)
38
+ system_content = None
39
+ formatted_messages = []
40
+
41
+ for msg in messages:
42
+ if msg.role == "system":
43
+ system_content = msg.content
44
+ elif msg.role == "tool":
45
+ # Anthropic expects tool results as user messages with tool_result blocks
46
+ formatted_messages.append(
47
+ {
48
+ "role": "user",
49
+ "content": [
50
+ {
51
+ "type": "tool_result",
52
+ "tool_use_id": msg.tool_call_id or msg.name,
53
+ "content": msg.content,
54
+ }
55
+ ],
56
+ }
57
+ )
58
+ elif msg.role == "assistant":
59
+ # Check if this was a tool-calling message (has metadata with tool_use_blocks)
60
+ if msg.metadata and msg.metadata.get("tool_use_blocks"):
61
+ # Reconstruct assistant message with tool_use blocks
62
+ content = msg.metadata["tool_use_blocks"]
63
+ else:
64
+ content = msg.content
65
+ formatted_messages.append({"role": "assistant", "content": content})
66
+ else:
67
+ # User messages
68
+ formatted_messages.append({"role": msg.role, "content": msg.content})
69
+
70
+ # Format tools for Anthropic (uses input_schema instead of parameters)
71
+ formatted_tools = None
72
+ if tools:
73
+ formatted_tools = []
74
+ for tool in tools:
75
+ formatted_tools.append(
76
+ {
77
+ "name": tool["name"],
78
+ "description": tool["description"],
79
+ "input_schema": tool.get(
80
+ "parameters", {"type": "object", "properties": {}}
81
+ ),
82
+ }
83
+ )
84
+
85
+ # Build request kwargs
86
+ kwargs = {
87
+ "model": self.model,
88
+ "max_tokens": 4096,
89
+ "messages": formatted_messages,
90
+ }
91
+ if system_content:
92
+ kwargs["system"] = system_content
93
+ if formatted_tools:
94
+ kwargs["tools"] = formatted_tools
95
+
96
+ response = self.client.messages.create(**kwargs)
97
+
98
+ # Parse response - Anthropic returns content as a list of blocks
99
+ tool_calls = []
100
+ text_content = ""
101
+ tool_use_blocks = [] # Store for reconstructing assistant message later
102
+
103
+ for block in response.content:
104
+ if block.type == "text":
105
+ text_content += block.text
106
+ elif block.type == "tool_use":
107
+ tool_call = ToolCall(
108
+ name=block.name,
109
+ arguments=block.input, # Already a dict, not JSON string
110
+ id=block.id,
111
+ )
112
+ tool_calls.append(tool_call)
113
+ # Store the block for message reconstruction in history
114
+ tool_use_blocks.append(
115
+ {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
116
+ )
117
+
118
+ content = text_content if text_content else "Calling tool..."
119
+
120
+ # Store tool_use_blocks in metadata for message reconstruction
121
+ metadata = {"tool_use_blocks": tool_use_blocks} if tool_use_blocks else None
122
+
123
+ return LLMResponse(
124
+ message=Message(role="assistant", content=content, metadata=metadata),
125
+ tool_calls=tool_calls if tool_calls else None,
126
+ raw=response,
127
+ )
providers/base.py ADDED
@@ -0,0 +1,51 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+
4
+ from schemas.message import LLMResponse, Message
5
+
6
+
7
+ @dataclass
8
+ class ModelInfo:
9
+ """Information about an available model."""
10
+
11
+ id: str
12
+ name: str
13
+ context_length: int | None = None
14
+ description: str | None = None
15
+
16
+
17
+ class LLMProvider(ABC):
18
+ """
19
+ Base interface for all LLM providers.
20
+ """
21
+
22
+ name: str
23
+ model: str
24
+
25
+ # Default models for this provider (can be overridden)
26
+ DEFAULT_MODELS: list[str] = []
27
+
28
+ @abstractmethod
29
+ def generate(
30
+ self,
31
+ messages: list[Message],
32
+ tools: list | None = None,
33
+ stream: bool = False,
34
+ ) -> LLMResponse:
35
+ """
36
+ Generate a response from the model.
37
+ """
38
+ pass
39
+
40
+ def list_models(self) -> list[ModelInfo]:
41
+ """
42
+ List available models for this provider.
43
+ Override in subclasses to fetch from API.
44
+ Returns default models if not overridden.
45
+ """
46
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
47
+
48
+ @classmethod
49
+ def get_default_models(cls) -> list[str]:
50
+ """Get list of default model IDs."""
51
+ return cls.DEFAULT_MODELS
providers/custom.py ADDED
@@ -0,0 +1,186 @@
1
+ """
2
+ Custom provider for OpenAI-compatible APIs.
3
+
4
+ Allows users to connect to any OpenAI-compatible endpoint with custom configuration.
5
+ Perfect for self-hosted models, custom endpoints, or providers not officially supported.
6
+ """
7
+
8
+ import json
9
+ import os
10
+
11
+ from openai import OpenAI
12
+
13
+ from providers.base import LLMProvider, ModelInfo
14
+ from schemas.message import LLMResponse, Message, ToolCall
15
+
16
+
17
+ class CustomProvider(LLMProvider):
18
+ """
19
+ Generic OpenAI-compatible provider.
20
+
21
+ Can be configured to work with any API that implements the OpenAI chat completions format.
22
+
23
+ Examples:
24
+ - Self-hosted vLLM, TGI, or llama.cpp servers
25
+ - Custom model endpoints
26
+ - Provider-specific endpoints not officially supported
27
+ """
28
+
29
+ name = "custom"
30
+
31
+ DEFAULT_MODELS = [] # User must specify model
32
+
33
+ def __init__(
34
+ self,
35
+ model: str,
36
+ base_url: str,
37
+ api_key: str | None = None,
38
+ default_models: list[str] | None = None,
39
+ ):
40
+ """
41
+ Initialize custom provider.
42
+
43
+ Args:
44
+ model: Model name/ID to use
45
+ base_url: Base URL for the API endpoint (e.g., "http://localhost:8000/v1")
46
+ api_key: API key (optional, some self-hosted models don't need it)
47
+ default_models: List of available models (optional)
48
+ """
49
+ if not base_url:
50
+ raise ValueError("base_url is required for custom provider")
51
+
52
+ if not model:
53
+ raise ValueError("model is required for custom provider")
54
+
55
+ # Use provided API key or "dummy" for self-hosted models that don't need auth
56
+ key = api_key or os.getenv("CUSTOM_API_KEY") or "dummy"
57
+
58
+ self.client = OpenAI(
59
+ api_key=key,
60
+ base_url=base_url,
61
+ )
62
+ self.model = model
63
+ self.base_url = base_url
64
+
65
+ # Update default models if provided
66
+ if default_models:
67
+ self.DEFAULT_MODELS = default_models
68
+
69
+ def list_models(self) -> list[ModelInfo]:
70
+ """
71
+ List available models.
72
+
73
+ Tries to fetch from API, falls back to configured defaults.
74
+ """
75
+ try:
76
+ response = self.client.models.list()
77
+ models = []
78
+ for model in response.data:
79
+ models.append(
80
+ ModelInfo(
81
+ id=model.id,
82
+ name=model.id,
83
+ )
84
+ )
85
+ return models if models else [ModelInfo(id=self.model, name=self.model)]
86
+ except Exception:
87
+ # If API doesn't support model listing, return configured model
88
+ if self.DEFAULT_MODELS:
89
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
90
+ return [ModelInfo(id=self.model, name=self.model)]
91
+
92
+ def generate(
93
+ self,
94
+ messages: list[Message],
95
+ tools: list | None = None,
96
+ stream: bool = False,
97
+ ) -> LLMResponse:
98
+ # Format messages for OpenAI-compatible API
99
+ formatted_messages = []
100
+ for msg in messages:
101
+ if msg.role == "tool":
102
+ formatted_msg = {
103
+ "role": "tool",
104
+ "content": msg.content,
105
+ "tool_call_id": msg.tool_call_id or msg.name,
106
+ }
107
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
108
+ # Preserve tool_calls in assistant messages
109
+ formatted_msg = {
110
+ "role": "assistant",
111
+ "content": msg.content or "",
112
+ "tool_calls": msg.metadata["tool_calls"],
113
+ }
114
+ else:
115
+ formatted_msg = {"role": msg.role, "content": msg.content}
116
+ formatted_messages.append(formatted_msg)
117
+
118
+ # Format tools for OpenAI-compatible API
119
+ formatted_tools = None
120
+ if tools:
121
+ formatted_tools = []
122
+ for tool in tools:
123
+ formatted_tools.append(
124
+ {
125
+ "type": "function",
126
+ "function": {
127
+ "name": tool["name"],
128
+ "description": tool["description"],
129
+ "parameters": tool.get(
130
+ "parameters", {"type": "object", "properties": {}}
131
+ ),
132
+ },
133
+ }
134
+ )
135
+
136
+ # Make the API call
137
+ try:
138
+ response = self.client.chat.completions.create(
139
+ model=self.model,
140
+ messages=formatted_messages,
141
+ tools=formatted_tools if formatted_tools else None,
142
+ temperature=0.0,
143
+ )
144
+
145
+ choice = response.choices[0]
146
+ message = choice.message
147
+
148
+ tool_calls = []
149
+ if message.tool_calls:
150
+ for tc in message.tool_calls:
151
+ tool_calls.append(
152
+ ToolCall(
153
+ id=tc.id,
154
+ name=tc.function.name,
155
+ arguments=json.loads(tc.function.arguments),
156
+ )
157
+ )
158
+
159
+ return LLMResponse(
160
+ content=message.content or "",
161
+ tool_calls=tool_calls,
162
+ metadata={
163
+ "model": response.model,
164
+ "usage": {
165
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
166
+ "completion_tokens": (
167
+ response.usage.completion_tokens if response.usage else 0
168
+ ),
169
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
170
+ },
171
+ "tool_calls": [
172
+ {
173
+ "id": tc.id,
174
+ "type": "function",
175
+ "function": {
176
+ "name": tc.function.name,
177
+ "arguments": tc.function.arguments,
178
+ },
179
+ }
180
+ for tc in (message.tool_calls or [])
181
+ ],
182
+ },
183
+ )
184
+
185
+ except Exception as e:
186
+ raise RuntimeError(f"Error calling custom API at {self.base_url}: {e}") from e
providers/deepseek.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ DeepSeek AI provider.
3
+
4
+ DeepSeek provides powerful reasoning and coding models with OpenAI-compatible API.
5
+ """
6
+
7
+ import json
8
+
9
+ from openai import OpenAI
10
+
11
+ from providers.base import LLMProvider, ModelInfo
12
+ from schemas.message import LLMResponse, Message, ToolCall
13
+
14
+
15
+ class DeepSeekProvider(LLMProvider):
16
+ name = "deepseek"
17
+
18
+ DEFAULT_MODELS = [
19
+ "deepseek-chat",
20
+ "deepseek-coder",
21
+ "deepseek-reasoner",
22
+ ]
23
+
24
+ def __init__(
25
+ self,
26
+ model: str = "deepseek-chat",
27
+ api_key: str | None = None,
28
+ base_url: str = "https://api.deepseek.com",
29
+ ):
30
+ """
31
+ Initialize DeepSeek provider.
32
+
33
+ Args:
34
+ model: Model to use (deepseek-chat, deepseek-coder, deepseek-reasoner)
35
+ api_key: DeepSeek API key (defaults to DEEPSEEK_API_KEY env var)
36
+ base_url: Base URL for the API
37
+ """
38
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
39
+ self.model = model
40
+
41
+ def list_models(self) -> list[ModelInfo]:
42
+ """Fetch available models from DeepSeek API."""
43
+ try:
44
+ response = self.client.models.list()
45
+ models = []
46
+ for model in response.data:
47
+ models.append(
48
+ ModelInfo(
49
+ id=model.id,
50
+ name=model.id,
51
+ description=getattr(model, "description", None),
52
+ )
53
+ )
54
+ return models if models else self._get_default_models()
55
+ except Exception:
56
+ # Fallback to default models if API call fails
57
+ return self._get_default_models()
58
+
59
+ def _get_default_models(self) -> list[ModelInfo]:
60
+ """Get hardcoded default models as fallback."""
61
+ return [
62
+ ModelInfo(
63
+ id="deepseek-chat",
64
+ name="DeepSeek Chat",
65
+ context_length=64000,
66
+ description="General-purpose conversational model",
67
+ ),
68
+ ModelInfo(
69
+ id="deepseek-coder",
70
+ name="DeepSeek Coder",
71
+ context_length=64000,
72
+ description="Specialized coding model",
73
+ ),
74
+ ModelInfo(
75
+ id="deepseek-reasoner",
76
+ name="DeepSeek Reasoner",
77
+ context_length=64000,
78
+ description="Advanced reasoning model (R1 series)",
79
+ ),
80
+ ]
81
+
82
+ def generate(
83
+ self,
84
+ messages: list[Message],
85
+ tools: list | None = None,
86
+ stream: bool = False,
87
+ ) -> LLMResponse:
88
+ # Format messages for OpenAI-compatible API
89
+ formatted_messages = []
90
+ for msg in messages:
91
+ if msg.role == "tool":
92
+ formatted_msg = {
93
+ "role": "tool",
94
+ "content": msg.content,
95
+ "tool_call_id": msg.tool_call_id or msg.name,
96
+ }
97
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
98
+ formatted_msg = {
99
+ "role": "assistant",
100
+ "content": msg.content or "",
101
+ "tool_calls": msg.metadata["tool_calls"],
102
+ }
103
+ else:
104
+ formatted_msg = {"role": msg.role, "content": msg.content}
105
+ formatted_messages.append(formatted_msg)
106
+
107
+ # Format tools
108
+ formatted_tools = None
109
+ if tools:
110
+ formatted_tools = []
111
+ for tool in tools:
112
+ formatted_tools.append(
113
+ {
114
+ "type": "function",
115
+ "function": {
116
+ "name": tool["name"],
117
+ "description": tool["description"],
118
+ "parameters": tool.get(
119
+ "parameters", {"type": "object", "properties": {}}
120
+ ),
121
+ },
122
+ }
123
+ )
124
+
125
+ response = self.client.chat.completions.create(
126
+ model=self.model,
127
+ messages=formatted_messages,
128
+ tools=formatted_tools,
129
+ temperature=0.0,
130
+ )
131
+
132
+ choice = response.choices[0]
133
+ message = choice.message
134
+
135
+ tool_calls = []
136
+ if message.tool_calls:
137
+ for tc in message.tool_calls:
138
+ tool_calls.append(
139
+ ToolCall(
140
+ id=tc.id,
141
+ name=tc.function.name,
142
+ arguments=json.loads(tc.function.arguments),
143
+ )
144
+ )
145
+
146
+ return LLMResponse(
147
+ content=message.content or "",
148
+ tool_calls=tool_calls,
149
+ metadata={
150
+ "model": response.model,
151
+ "usage": {
152
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
153
+ "completion_tokens": (
154
+ response.usage.completion_tokens if response.usage else 0
155
+ ),
156
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
157
+ },
158
+ "tool_calls": [
159
+ {
160
+ "id": tc.id,
161
+ "type": "function",
162
+ "function": {
163
+ "name": tc.function.name,
164
+ "arguments": tc.function.arguments,
165
+ },
166
+ }
167
+ for tc in (message.tool_calls or [])
168
+ ],
169
+ },
170
+ )