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/kimi.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Kimi (Moonshot AI) provider.
3
+
4
+ Moonshot AI's Kimi model with extremely long context (200K+ tokens).
5
+ Uses OpenAI-compatible API.
6
+ """
7
+
8
+ import json
9
+
10
+ from openai import OpenAI
11
+
12
+ from providers.base import LLMProvider, ModelInfo
13
+ from schemas.message import LLMResponse, Message, ToolCall
14
+
15
+
16
+ class KimiProvider(LLMProvider):
17
+ name = "kimi"
18
+
19
+ DEFAULT_MODELS = [
20
+ "moonshot-v1-8k",
21
+ "moonshot-v1-32k",
22
+ "moonshot-v1-128k",
23
+ ]
24
+
25
+ def __init__(
26
+ self,
27
+ model: str = "moonshot-v1-32k",
28
+ api_key: str | None = None,
29
+ base_url: str = "https://api.moonshot.cn/v1",
30
+ ):
31
+ """
32
+ Initialize Kimi provider.
33
+
34
+ Args:
35
+ model: Model to use (moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k)
36
+ api_key: Moonshot API key (defaults to MOONSHOT_API_KEY env var)
37
+ base_url: Base URL for the API
38
+ """
39
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
40
+ self.model = model
41
+
42
+ def list_models(self) -> list[ModelInfo]:
43
+ """Fetch available models from Moonshot AI API."""
44
+ try:
45
+ response = self.client.models.list()
46
+ models = []
47
+ for model in response.data:
48
+ # Extract context length from model ID if possible
49
+ context = None
50
+ if "8k" in model.id.lower():
51
+ context = 8000
52
+ elif "32k" in model.id.lower():
53
+ context = 32000
54
+ elif "128k" in model.id.lower():
55
+ context = 128000
56
+
57
+ models.append(
58
+ ModelInfo(
59
+ id=model.id,
60
+ name=model.id,
61
+ context_length=context,
62
+ description=getattr(model, "description", None),
63
+ )
64
+ )
65
+ return models if models else self._get_default_models()
66
+ except Exception:
67
+ # Fallback to default models if API call fails
68
+ return self._get_default_models()
69
+
70
+ def _get_default_models(self) -> list[ModelInfo]:
71
+ """Get hardcoded default models as fallback."""
72
+ return [
73
+ ModelInfo(
74
+ id="moonshot-v1-8k",
75
+ name="Kimi 8K",
76
+ context_length=8000,
77
+ description="Fast model with 8K context",
78
+ ),
79
+ ModelInfo(
80
+ id="moonshot-v1-32k",
81
+ name="Kimi 32K",
82
+ context_length=32000,
83
+ description="Balanced model with 32K context",
84
+ ),
85
+ ModelInfo(
86
+ id="moonshot-v1-128k",
87
+ name="Kimi 128K",
88
+ context_length=128000,
89
+ description="Long context model with 128K tokens",
90
+ ),
91
+ ]
92
+
93
+ def generate(
94
+ self,
95
+ messages: list[Message],
96
+ tools: list | None = None,
97
+ stream: bool = False,
98
+ ) -> LLMResponse:
99
+ # Format messages for OpenAI-compatible API
100
+ formatted_messages = []
101
+ for msg in messages:
102
+ if msg.role == "tool":
103
+ formatted_msg = {
104
+ "role": "tool",
105
+ "content": msg.content,
106
+ "tool_call_id": msg.tool_call_id or msg.name,
107
+ }
108
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
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
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
+ response = self.client.chat.completions.create(
137
+ model=self.model,
138
+ messages=formatted_messages,
139
+ tools=formatted_tools,
140
+ temperature=0.0,
141
+ )
142
+
143
+ choice = response.choices[0]
144
+ message = choice.message
145
+
146
+ tool_calls = []
147
+ if message.tool_calls:
148
+ for tc in message.tool_calls:
149
+ tool_calls.append(
150
+ ToolCall(
151
+ id=tc.id,
152
+ name=tc.function.name,
153
+ arguments=json.loads(tc.function.arguments),
154
+ )
155
+ )
156
+
157
+ return LLMResponse(
158
+ content=message.content or "",
159
+ tool_calls=tool_calls,
160
+ metadata={
161
+ "model": response.model,
162
+ "usage": {
163
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
164
+ "completion_tokens": (
165
+ response.usage.completion_tokens if response.usage else 0
166
+ ),
167
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
168
+ },
169
+ "tool_calls": [
170
+ {
171
+ "id": tc.id,
172
+ "type": "function",
173
+ "function": {
174
+ "name": tc.function.name,
175
+ "arguments": tc.function.arguments,
176
+ },
177
+ }
178
+ for tc in (message.tool_calls or [])
179
+ ],
180
+ },
181
+ )
providers/lmstudio.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ LM Studio Provider - Local models via OpenAI-compatible API
3
+ https://lmstudio.ai/
4
+ """
5
+
6
+ import json
7
+
8
+ import httpx
9
+
10
+ from providers.base import LLMProvider, ModelInfo
11
+ from schemas.message import LLMResponse, Message, ToolCall
12
+
13
+
14
+ class LMStudioProvider(LLMProvider):
15
+ """
16
+ LM Studio provider for running local models.
17
+ Uses OpenAI-compatible API format.
18
+ """
19
+
20
+ name = "lmstudio"
21
+
22
+ DEFAULT_MODELS = [
23
+ "local-model", # LM Studio uses the loaded model
24
+ ]
25
+
26
+ def __init__(
27
+ self,
28
+ model: str = "local-model",
29
+ api_key: str | None = None, # Not needed for local
30
+ base_url: str = "http://localhost:1234/v1",
31
+ ):
32
+ self.model = model
33
+ self.api_key = api_key or "lm-studio" # Placeholder
34
+ self.base_url = base_url.rstrip("/")
35
+ self.client = httpx.Client(timeout=300.0) # Longer timeout for local
36
+
37
+ def _get_headers(self) -> dict:
38
+ return {
39
+ "Authorization": f"Bearer {self.api_key}",
40
+ "Content-Type": "application/json",
41
+ }
42
+
43
+ def list_models(self) -> list[ModelInfo]:
44
+ """Fetch loaded models from LM Studio."""
45
+ try:
46
+ response = self.client.get(f"{self.base_url}/models", headers=self._get_headers())
47
+ response.raise_for_status()
48
+ data = response.json()
49
+
50
+ models = []
51
+ for model in data.get("data", []):
52
+ models.append(
53
+ ModelInfo(
54
+ id=model.get("id"),
55
+ name=model.get("id"),
56
+ )
57
+ )
58
+
59
+ return models if models else [ModelInfo(id="local-model", name="Local Model")]
60
+
61
+ except Exception:
62
+ return [ModelInfo(id="local-model", name="Local Model (LM Studio)")]
63
+
64
+ def generate(
65
+ self,
66
+ messages: list[Message],
67
+ tools: list | None = None,
68
+ stream: bool = False,
69
+ ) -> LLMResponse:
70
+ # Format messages for LM Studio API
71
+ formatted_messages = []
72
+ for msg in messages:
73
+ if msg.role == "tool":
74
+ formatted_msg = {
75
+ "role": "tool",
76
+ "content": msg.content,
77
+ "tool_call_id": msg.tool_call_id or msg.name,
78
+ }
79
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
80
+ # Preserve tool_calls in assistant messages
81
+ formatted_msg = {
82
+ "role": "assistant",
83
+ "content": msg.content or "",
84
+ "tool_calls": msg.metadata["tool_calls"],
85
+ }
86
+ else:
87
+ formatted_msg = {"role": msg.role, "content": msg.content}
88
+ formatted_messages.append(formatted_msg)
89
+
90
+ # Format tools (if model supports it)
91
+ formatted_tools = None
92
+ if tools:
93
+ formatted_tools = []
94
+ for tool in tools:
95
+ formatted_tools.append(
96
+ {
97
+ "type": "function",
98
+ "function": {
99
+ "name": tool["name"],
100
+ "description": tool["description"],
101
+ "parameters": tool.get(
102
+ "parameters", {"type": "object", "properties": {}}
103
+ ),
104
+ },
105
+ }
106
+ )
107
+
108
+ payload = {
109
+ "model": self.model,
110
+ "messages": formatted_messages,
111
+ "temperature": 0.0,
112
+ }
113
+
114
+ if formatted_tools:
115
+ payload["tools"] = formatted_tools
116
+
117
+ response = self.client.post(
118
+ f"{self.base_url}/chat/completions", headers=self._get_headers(), json=payload
119
+ )
120
+ response.raise_for_status()
121
+ data = response.json()
122
+
123
+ choice = data["choices"][0]
124
+ message = choice["message"]
125
+
126
+ tool_calls = []
127
+ if message.get("tool_calls"):
128
+ for tc in message["tool_calls"]:
129
+ args = (
130
+ json.loads(tc["function"]["arguments"])
131
+ if tc["function"].get("arguments")
132
+ else {}
133
+ )
134
+ tool_call = ToolCall(
135
+ name=tc["function"]["name"],
136
+ arguments=args,
137
+ id=tc.get("id", tc["function"]["name"]),
138
+ )
139
+ tool_calls.append(tool_call)
140
+
141
+ content = message.get("content") or "Calling tool..."
142
+
143
+ return LLMResponse(
144
+ message=Message(role="assistant", content=content),
145
+ tool_calls=tool_calls if tool_calls else None,
146
+ raw=data,
147
+ )
providers/local.py ADDED
@@ -0,0 +1,214 @@
1
+ import json
2
+
3
+ import httpx
4
+ from openai import OpenAI
5
+
6
+ from providers.base import LLMProvider, ModelInfo
7
+ from schemas.message import LLMResponse, Message, ToolCall
8
+
9
+
10
+ class LocalProvider(LLMProvider):
11
+ """Local LLM provider using Ollama's OpenAI-compatible API."""
12
+
13
+ name = "local"
14
+
15
+ # Models known to support function calling well
16
+ TOOL_CAPABLE_MODELS = [
17
+ "llama3.1",
18
+ "llama3.2",
19
+ "qwen2.5:7b",
20
+ "qwen2.5:14b",
21
+ "qwen2.5:32b",
22
+ "qwen2.5-coder",
23
+ "mistral",
24
+ "mixtral",
25
+ "command-r",
26
+ "firefunction",
27
+ ]
28
+
29
+ DEFAULT_MODELS = [
30
+ "llama3.1",
31
+ "llama3.2",
32
+ "llama3",
33
+ "codellama",
34
+ "mistral",
35
+ "mixtral",
36
+ "gemma2",
37
+ "qwen2.5",
38
+ "qwen2.5-coder",
39
+ "deepseek-coder-v2",
40
+ ]
41
+
42
+ def __init__(
43
+ self,
44
+ model: str | None = None,
45
+ base_url: str = "http://localhost:11434/v1",
46
+ api_key: str | None = None,
47
+ ):
48
+ self.base_url = base_url
49
+
50
+ # Ollama's OpenAI-compatible endpoint
51
+ self.client = OpenAI(
52
+ base_url=base_url,
53
+ api_key=api_key or "ollama", # Placeholder, Ollama ignores this
54
+ )
55
+
56
+ # Auto-detect model if not specified
57
+ if model:
58
+ self.model = model
59
+ else:
60
+ self.model = self._detect_default_model()
61
+
62
+ # Track if we've warned about tool capability
63
+ self._tool_warning_shown = False
64
+
65
+ def _detect_default_model(self) -> str:
66
+ """Auto-detect the first available model from Ollama."""
67
+ try:
68
+ ollama_base = self.base_url.replace("/v1", "")
69
+ response = httpx.get(f"{ollama_base}/api/tags", timeout=5.0)
70
+ response.raise_for_status()
71
+ data = response.json()
72
+
73
+ models = data.get("models", [])
74
+ if models:
75
+ # Return the first available model
76
+ return models[0].get("name", "llama3")
77
+
78
+ except Exception:
79
+ pass
80
+
81
+ # Fallback to first default model
82
+ return self.DEFAULT_MODELS[0]
83
+
84
+ def _is_tool_capable(self, model_name: str) -> bool:
85
+ """Check if a model is known to support function calling."""
86
+ model_lower = model_name.lower()
87
+ for capable in self.TOOL_CAPABLE_MODELS:
88
+ if capable in model_lower:
89
+ return True
90
+ # Small models (under 3B) generally don't support tools well
91
+ if ":0.5b" in model_lower or ":1b" in model_lower or ":2b" in model_lower:
92
+ return False
93
+ return True # Assume capable for unknown larger models
94
+
95
+ def list_models(self) -> list[ModelInfo]:
96
+ """Fetch models from local Ollama instance."""
97
+ try:
98
+ # Ollama API endpoint for listing models
99
+ ollama_base = self.base_url.replace("/v1", "")
100
+ response = httpx.get(f"{ollama_base}/api/tags", timeout=5.0)
101
+ response.raise_for_status()
102
+ data = response.json()
103
+
104
+ models = []
105
+ for model in data.get("models", []):
106
+ name = model.get("name")
107
+ tool_note = "" if self._is_tool_capable(name) else " (no tool support)"
108
+ models.append(
109
+ ModelInfo(
110
+ id=name,
111
+ name=name,
112
+ description=tool_note if tool_note else None,
113
+ )
114
+ )
115
+
116
+ return models if models else [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
117
+
118
+ except Exception:
119
+ return [ModelInfo(id=m, name=m + " (not pulled)") for m in self.DEFAULT_MODELS]
120
+
121
+ def generate(
122
+ self,
123
+ messages: list[Message],
124
+ tools: list | None = None,
125
+ stream: bool = False,
126
+ ) -> LLMResponse:
127
+ # Format messages (same as OpenAI)
128
+ formatted_messages = []
129
+ for msg in messages:
130
+ if msg.role == "tool":
131
+ formatted_msg = {
132
+ "role": "tool",
133
+ "content": msg.content,
134
+ "tool_call_id": msg.tool_call_id or msg.name,
135
+ }
136
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
137
+ # Preserve tool_calls in assistant messages
138
+ formatted_msg = {
139
+ "role": "assistant",
140
+ "content": msg.content or "",
141
+ "tool_calls": msg.metadata["tool_calls"],
142
+ }
143
+ else:
144
+ formatted_msg = {"role": msg.role, "content": msg.content}
145
+ formatted_messages.append(formatted_msg)
146
+
147
+ # Format tools (same as OpenAI)
148
+ formatted_tools = None
149
+ if tools:
150
+ formatted_tools = []
151
+ for tool in tools:
152
+ formatted_tools.append(
153
+ {
154
+ "type": "function",
155
+ "function": {
156
+ "name": tool["name"],
157
+ "description": tool["description"],
158
+ "parameters": tool.get(
159
+ "parameters", {"type": "object", "properties": {}}
160
+ ),
161
+ },
162
+ }
163
+ )
164
+
165
+ # Warn if model may not support tools
166
+ if (
167
+ formatted_tools
168
+ and not self._tool_warning_shown
169
+ and not self._is_tool_capable(self.model)
170
+ ):
171
+ import sys
172
+
173
+ print(f"\n⚠️ Warning: {self.model} may not support function calling.", file=sys.stderr)
174
+ print(
175
+ " For best results, use a larger model like llama3.1, qwen2.5:7b, or mistral.",
176
+ file=sys.stderr,
177
+ )
178
+ print(" Run: ollama pull llama3.1\n", file=sys.stderr)
179
+ self._tool_warning_shown = True
180
+
181
+ # Try with tools first, fall back to without if model doesn't support them
182
+ try:
183
+ kwargs = {"model": self.model, "messages": formatted_messages, "temperature": 0.0}
184
+ if formatted_tools:
185
+ kwargs["tools"] = formatted_tools
186
+
187
+ response = self.client.chat.completions.create(**kwargs)
188
+ except Exception as e:
189
+ error_str = str(e).lower()
190
+ # Retry without tools if the model doesn't support function calling
191
+ if formatted_tools and ("tool" in error_str or "function" in error_str):
192
+ response = self.client.chat.completions.create(
193
+ model=self.model, messages=formatted_messages, temperature=0.0
194
+ )
195
+ else:
196
+ raise
197
+
198
+ choice = response.choices[0]
199
+ message = choice.message
200
+
201
+ tool_calls = []
202
+ if hasattr(message, "tool_calls") and message.tool_calls:
203
+ for tc in message.tool_calls:
204
+ args = json.loads(tc.function.arguments) if tc.function.arguments else {}
205
+ tool_call = ToolCall(name=tc.function.name, arguments=args, id=tc.id)
206
+ tool_calls.append(tool_call)
207
+
208
+ content = message.content if message.content else "Calling tool..."
209
+
210
+ return LLMResponse(
211
+ message=Message(role="assistant", content=content),
212
+ tool_calls=tool_calls if tool_calls else None,
213
+ raw=response,
214
+ )
providers/mistral.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ Mistral AI Provider
3
+ https://mistral.ai/
4
+ """
5
+
6
+ import json
7
+
8
+ import httpx
9
+
10
+ from providers.base import LLMProvider, ModelInfo
11
+ from schemas.message import LLMResponse, Message, ToolCall
12
+
13
+
14
+ class MistralProvider(LLMProvider):
15
+ """
16
+ Mistral AI provider for Mistral models.
17
+ """
18
+
19
+ name = "mistral"
20
+
21
+ DEFAULT_MODELS = [
22
+ "mistral-large-latest",
23
+ "mistral-medium-latest",
24
+ "mistral-small-latest",
25
+ "open-mistral-7b",
26
+ "open-mixtral-8x7b",
27
+ "open-mixtral-8x22b",
28
+ "codestral-latest",
29
+ ]
30
+
31
+ BASE_URL = "https://api.mistral.ai/v1"
32
+
33
+ def __init__(
34
+ self,
35
+ model: str = "mistral-large-latest",
36
+ api_key: str | None = None,
37
+ ):
38
+ self.model = model
39
+ self.api_key = api_key
40
+ self.client = httpx.Client(timeout=120.0)
41
+
42
+ def _get_headers(self) -> dict:
43
+ return {
44
+ "Authorization": f"Bearer {self.api_key}",
45
+ "Content-Type": "application/json",
46
+ }
47
+
48
+ def list_models(self) -> list[ModelInfo]:
49
+ """Fetch available models from Mistral API."""
50
+ try:
51
+ response = self.client.get(f"{self.BASE_URL}/models", headers=self._get_headers())
52
+ response.raise_for_status()
53
+ data = response.json()
54
+
55
+ models = []
56
+ for model in data.get("data", []):
57
+ models.append(
58
+ ModelInfo(
59
+ id=model.get("id"),
60
+ name=model.get("id"),
61
+ )
62
+ )
63
+
64
+ models.sort(key=lambda m: m.id)
65
+ return models if models else [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
66
+
67
+ except Exception:
68
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
69
+
70
+ def generate(
71
+ self,
72
+ messages: list[Message],
73
+ tools: list | None = None,
74
+ stream: bool = False,
75
+ ) -> LLMResponse:
76
+ # Format messages for Mistral API
77
+ formatted_messages = []
78
+ for msg in messages:
79
+ if msg.role == "tool":
80
+ # Mistral requires tool results with specific format
81
+ formatted_msg = {
82
+ "role": "tool",
83
+ "content": msg.content,
84
+ "tool_call_id": msg.tool_call_id or msg.name,
85
+ "name": msg.name,
86
+ }
87
+ elif (
88
+ msg.role == "assistant"
89
+ and hasattr(msg, "metadata")
90
+ and msg.metadata
91
+ and msg.metadata.get("tool_calls")
92
+ ):
93
+ # Preserve tool_calls in assistant messages
94
+ formatted_msg = {
95
+ "role": "assistant",
96
+ "content": msg.content or "",
97
+ "tool_calls": msg.metadata["tool_calls"],
98
+ }
99
+ else:
100
+ formatted_msg = {"role": msg.role, "content": msg.content}
101
+ formatted_messages.append(formatted_msg)
102
+
103
+ # Format tools
104
+ formatted_tools = None
105
+ if tools:
106
+ formatted_tools = []
107
+ for tool in tools:
108
+ formatted_tools.append(
109
+ {
110
+ "type": "function",
111
+ "function": {
112
+ "name": tool["name"],
113
+ "description": tool["description"],
114
+ "parameters": tool.get(
115
+ "parameters", {"type": "object", "properties": {}}
116
+ ),
117
+ },
118
+ }
119
+ )
120
+
121
+ payload = {
122
+ "model": self.model,
123
+ "messages": formatted_messages,
124
+ "temperature": 0.0,
125
+ }
126
+
127
+ if formatted_tools:
128
+ payload["tools"] = formatted_tools
129
+ payload["tool_choice"] = "auto"
130
+
131
+ response = self.client.post(
132
+ f"{self.BASE_URL}/chat/completions", headers=self._get_headers(), json=payload
133
+ )
134
+ response.raise_for_status()
135
+ data = response.json()
136
+
137
+ choice = data["choices"][0]
138
+ message = choice["message"]
139
+
140
+ tool_calls = []
141
+ if message.get("tool_calls"):
142
+ for tc in message["tool_calls"]:
143
+ args = (
144
+ json.loads(tc["function"]["arguments"])
145
+ if tc["function"].get("arguments")
146
+ else {}
147
+ )
148
+ tool_call = ToolCall(
149
+ name=tc["function"]["name"],
150
+ arguments=args,
151
+ id=tc.get("id", tc["function"]["name"]),
152
+ )
153
+ tool_calls.append(tool_call)
154
+
155
+ content = message.get("content") or "Calling tool..."
156
+
157
+ return LLMResponse(
158
+ message=Message(role="assistant", content=content),
159
+ tool_calls=tool_calls if tool_calls else None,
160
+ raw=data,
161
+ )