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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
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
|
+
)
|