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/openai.py ADDED
@@ -0,0 +1,114 @@
1
+ import json
2
+
3
+ from openai import OpenAI
4
+
5
+ from providers.base import LLMProvider, ModelInfo
6
+ from schemas.message import LLMResponse, Message, ToolCall
7
+
8
+
9
+ class OpenAIProvider(LLMProvider):
10
+ name = "openai"
11
+
12
+ DEFAULT_MODELS = [
13
+ "gpt-4o",
14
+ "gpt-4o-mini",
15
+ "gpt-4-turbo",
16
+ "gpt-4",
17
+ "gpt-3.5-turbo",
18
+ "o1-preview",
19
+ "o1-mini",
20
+ ]
21
+
22
+ def __init__(self, model: str = "gpt-4o", api_key: str | None = None):
23
+ self.client = OpenAI(api_key=api_key)
24
+ self.model = model
25
+
26
+ def list_models(self) -> list[ModelInfo]:
27
+ """Fetch available models from OpenAI API."""
28
+ try:
29
+ response = self.client.models.list()
30
+ models = []
31
+ # Filter to chat models
32
+ chat_prefixes = ("gpt-4", "gpt-3.5", "o1")
33
+ for model in response.data:
34
+ if any(model.id.startswith(p) for p in chat_prefixes):
35
+ models.append(
36
+ ModelInfo(
37
+ id=model.id,
38
+ name=model.id,
39
+ )
40
+ )
41
+ # Sort by name
42
+ models.sort(key=lambda m: m.id)
43
+ return models if models else [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
44
+ except Exception:
45
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
46
+
47
+ def generate(
48
+ self,
49
+ messages: list[Message],
50
+ tools: list | None = None,
51
+ stream: bool = False,
52
+ ) -> LLMResponse:
53
+ # Format messages for OpenAI
54
+ formatted_messages = []
55
+ for msg in messages:
56
+ if msg.role == "tool":
57
+ formatted_msg = {
58
+ "role": "tool",
59
+ "content": msg.content,
60
+ "tool_call_id": msg.tool_call_id or msg.name,
61
+ }
62
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
63
+ # Preserve tool_calls in assistant messages
64
+ formatted_msg = {
65
+ "role": "assistant",
66
+ "content": msg.content or "",
67
+ "tool_calls": msg.metadata["tool_calls"],
68
+ }
69
+ else:
70
+ formatted_msg = {"role": msg.role, "content": msg.content}
71
+ formatted_messages.append(formatted_msg)
72
+
73
+ # Format tools for OpenAI
74
+ formatted_tools = None
75
+ if tools:
76
+ formatted_tools = []
77
+ for tool in tools:
78
+ formatted_tools.append(
79
+ {
80
+ "type": "function",
81
+ "function": {
82
+ "name": tool["name"],
83
+ "description": tool["description"],
84
+ "parameters": tool.get(
85
+ "parameters", {"type": "object", "properties": {}}
86
+ ),
87
+ },
88
+ }
89
+ )
90
+
91
+ response = self.client.chat.completions.create(
92
+ model=self.model, messages=formatted_messages, tools=formatted_tools, temperature=0.0
93
+ )
94
+
95
+ choice = response.choices[0]
96
+ message = choice.message
97
+
98
+ tool_calls = []
99
+ if message.tool_calls:
100
+ for tc in message.tool_calls:
101
+ # OpenAI returns arguments as a JSON string
102
+ args = json.loads(tc.function.arguments) if tc.function.arguments else {}
103
+
104
+ tool_call = ToolCall(name=tc.function.name, arguments=args, id=tc.id)
105
+ tool_calls.append(tool_call)
106
+
107
+ # Handle case where assistant message has no content but has tool calls
108
+ content = message.content if message.content else "Calling tool..."
109
+
110
+ return LLMResponse(
111
+ message=Message(role="assistant", content=content),
112
+ tool_calls=tool_calls if tool_calls else None,
113
+ raw=response,
114
+ )
@@ -0,0 +1,195 @@
1
+ """
2
+ OpenRouter Provider - Access multiple models through one API.
3
+ https://openrouter.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 OpenRouterProvider(LLMProvider):
15
+ """
16
+ OpenRouter provides access to many models through a single API.
17
+ Supports: Claude, GPT-4, Llama, Mistral, and many more.
18
+ """
19
+
20
+ name = "openrouter"
21
+
22
+ DEFAULT_MODELS = [
23
+ "anthropic/claude-sonnet-4",
24
+ "anthropic/claude-3.5-sonnet",
25
+ "openai/gpt-4o",
26
+ "openai/gpt-4o-mini",
27
+ "meta-llama/llama-3.3-70b-instruct",
28
+ "google/gemini-2.0-flash-001",
29
+ "mistralai/mistral-large-2411",
30
+ "deepseek/deepseek-chat-v3-0324",
31
+ ]
32
+
33
+ BASE_URL = "https://openrouter.ai/api/v1"
34
+
35
+ def __init__(
36
+ self,
37
+ model: str = "anthropic/claude-3.5-sonnet",
38
+ api_key: str | None = None,
39
+ site_url: str = "https://github.com/Amanbig/DevOrch",
40
+ site_name: str = "DevOrch",
41
+ ):
42
+ self.model = model
43
+ self.api_key = api_key
44
+ self.site_url = site_url
45
+ self.site_name = site_name
46
+ self.client = httpx.Client(timeout=120.0)
47
+
48
+ def _get_headers(self) -> dict:
49
+ return {
50
+ "Authorization": f"Bearer {self.api_key}",
51
+ "HTTP-Referer": self.site_url,
52
+ "X-Title": self.site_name,
53
+ "Content-Type": "application/json",
54
+ }
55
+
56
+ def list_models(self) -> list[ModelInfo]:
57
+ """Fetch available models from OpenRouter API."""
58
+ try:
59
+ response = self.client.get(f"{self.BASE_URL}/models", headers=self._get_headers())
60
+ response.raise_for_status()
61
+ data = response.json()
62
+
63
+ models = []
64
+ for model in data.get("data", []):
65
+ model_id = model.get("id", "")
66
+ # Skip models that don't support chat or are deprecated
67
+ architecture = model.get("architecture", {})
68
+ if architecture.get("modality") == "text->image":
69
+ continue # Skip image generation models
70
+
71
+ models.append(
72
+ ModelInfo(
73
+ id=model_id,
74
+ name=model.get("name", model_id),
75
+ context_length=model.get("context_length"),
76
+ description=model.get("description"),
77
+ )
78
+ )
79
+
80
+ # Sort by name, prioritizing well-known providers
81
+ def sort_key(m):
82
+ priority_prefixes = [
83
+ "openai/",
84
+ "anthropic/",
85
+ "google/",
86
+ "meta-llama/",
87
+ "mistralai/",
88
+ "deepseek/",
89
+ ]
90
+ for i, prefix in enumerate(priority_prefixes):
91
+ if m.id.startswith(prefix):
92
+ return (i, m.name)
93
+ return (len(priority_prefixes), m.name)
94
+
95
+ models.sort(key=sort_key)
96
+ return models if models else [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
97
+
98
+ except Exception:
99
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
100
+
101
+ def generate(
102
+ self,
103
+ messages: list[Message],
104
+ tools: list | None = None,
105
+ stream: bool = False,
106
+ ) -> LLMResponse:
107
+ # Format messages for OpenRouter API
108
+ formatted_messages = []
109
+ for msg in messages:
110
+ if msg.role == "tool":
111
+ formatted_msg = {
112
+ "role": "tool",
113
+ "content": msg.content,
114
+ "tool_call_id": msg.tool_call_id or msg.name,
115
+ }
116
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
117
+ # Preserve tool_calls in assistant messages
118
+ formatted_msg = {
119
+ "role": "assistant",
120
+ "content": msg.content or "",
121
+ "tool_calls": msg.metadata["tool_calls"],
122
+ }
123
+ else:
124
+ formatted_msg = {"role": msg.role, "content": msg.content}
125
+ formatted_messages.append(formatted_msg)
126
+
127
+ # Format tools (OpenAI-compatible format)
128
+ formatted_tools = None
129
+ if tools:
130
+ formatted_tools = []
131
+ for tool in tools:
132
+ formatted_tools.append(
133
+ {
134
+ "type": "function",
135
+ "function": {
136
+ "name": tool["name"],
137
+ "description": tool["description"],
138
+ "parameters": tool.get(
139
+ "parameters", {"type": "object", "properties": {}}
140
+ ),
141
+ },
142
+ }
143
+ )
144
+
145
+ payload = {
146
+ "model": self.model,
147
+ "messages": formatted_messages,
148
+ "temperature": 0.0,
149
+ }
150
+
151
+ if formatted_tools:
152
+ payload["tools"] = formatted_tools
153
+
154
+ response = self.client.post(
155
+ f"{self.BASE_URL}/chat/completions", headers=self._get_headers(), json=payload
156
+ )
157
+
158
+ # Handle errors with better messages
159
+ if response.status_code == 404:
160
+ raise Exception(
161
+ f"Model '{self.model}' not found on OpenRouter. Try /model to select a different model."
162
+ )
163
+ elif response.status_code == 401:
164
+ raise Exception("Invalid OpenRouter API key. Please check your API key.")
165
+ elif response.status_code == 402:
166
+ raise Exception("OpenRouter credits exhausted. Please add credits to your account.")
167
+
168
+ response.raise_for_status()
169
+ data = response.json()
170
+
171
+ choice = data["choices"][0]
172
+ message = choice["message"]
173
+
174
+ tool_calls = []
175
+ if message.get("tool_calls"):
176
+ for tc in message["tool_calls"]:
177
+ args = (
178
+ json.loads(tc["function"]["arguments"])
179
+ if tc["function"].get("arguments")
180
+ else {}
181
+ )
182
+ tool_call = ToolCall(
183
+ name=tc["function"]["name"],
184
+ arguments=args,
185
+ id=tc.get("id", tc["function"]["name"]),
186
+ )
187
+ tool_calls.append(tool_call)
188
+
189
+ content = message.get("content") or "Calling tool..."
190
+
191
+ return LLMResponse(
192
+ message=Message(role="assistant", content=content),
193
+ tool_calls=tool_calls if tool_calls else None,
194
+ raw=data,
195
+ )
providers/together.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ Together AI Provider - Open source models at scale
3
+ https://together.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 TogetherProvider(LLMProvider):
15
+ """
16
+ Together AI provider for open source models.
17
+ Supports Llama, Mistral, Code Llama, and more.
18
+ """
19
+
20
+ name = "together"
21
+
22
+ DEFAULT_MODELS = [
23
+ "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
24
+ "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
25
+ "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
26
+ "mistralai/Mixtral-8x22B-Instruct-v0.1",
27
+ "mistralai/Mistral-7B-Instruct-v0.3",
28
+ "Qwen/Qwen2-72B-Instruct",
29
+ "deepseek-ai/deepseek-coder-33b-instruct",
30
+ ]
31
+
32
+ BASE_URL = "https://api.together.xyz/v1"
33
+
34
+ def __init__(
35
+ self,
36
+ model: str = "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
37
+ api_key: str | None = None,
38
+ ):
39
+ self.model = model
40
+ self.api_key = api_key
41
+ self.client = httpx.Client(timeout=120.0)
42
+
43
+ def _get_headers(self) -> dict:
44
+ return {
45
+ "Authorization": f"Bearer {self.api_key}",
46
+ "Content-Type": "application/json",
47
+ }
48
+
49
+ def list_models(self) -> list[ModelInfo]:
50
+ """Fetch available models from Together API."""
51
+ try:
52
+ response = self.client.get(f"{self.BASE_URL}/models", headers=self._get_headers())
53
+ response.raise_for_status()
54
+ data = response.json()
55
+
56
+ models = []
57
+ # Filter to chat/instruct models
58
+ for model in data:
59
+ model_id = model.get("id", "")
60
+ model_type = model.get("type", "")
61
+ if "chat" in model_type.lower() or "instruct" in model_id.lower():
62
+ models.append(
63
+ ModelInfo(
64
+ id=model_id,
65
+ name=model.get("display_name", model_id),
66
+ context_length=model.get("context_length"),
67
+ )
68
+ )
69
+
70
+ models.sort(key=lambda m: m.name)
71
+ return models[:50] if models else [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
72
+
73
+ except Exception:
74
+ return [ModelInfo(id=m, name=m) for m in self.DEFAULT_MODELS]
75
+
76
+ def generate(
77
+ self,
78
+ messages: list[Message],
79
+ tools: list | None = None,
80
+ stream: bool = False,
81
+ ) -> LLMResponse:
82
+ # Format messages for Together API
83
+ formatted_messages = []
84
+ for msg in messages:
85
+ if msg.role == "tool":
86
+ formatted_msg = {
87
+ "role": "tool",
88
+ "content": msg.content,
89
+ "tool_call_id": msg.tool_call_id or msg.name,
90
+ }
91
+ elif msg.role == "assistant" and msg.metadata and msg.metadata.get("tool_calls"):
92
+ # Preserve tool_calls in assistant messages
93
+ formatted_msg = {
94
+ "role": "assistant",
95
+ "content": msg.content or "",
96
+ "tool_calls": msg.metadata["tool_calls"],
97
+ }
98
+ else:
99
+ formatted_msg = {"role": msg.role, "content": msg.content}
100
+ formatted_messages.append(formatted_msg)
101
+
102
+ # Format tools
103
+ formatted_tools = None
104
+ if tools:
105
+ formatted_tools = []
106
+ for tool in tools:
107
+ formatted_tools.append(
108
+ {
109
+ "type": "function",
110
+ "function": {
111
+ "name": tool["name"],
112
+ "description": tool["description"],
113
+ "parameters": tool.get(
114
+ "parameters", {"type": "object", "properties": {}}
115
+ ),
116
+ },
117
+ }
118
+ )
119
+
120
+ payload = {
121
+ "model": self.model,
122
+ "messages": formatted_messages,
123
+ "temperature": 0.0,
124
+ }
125
+
126
+ if formatted_tools:
127
+ payload["tools"] = formatted_tools
128
+
129
+ response = self.client.post(
130
+ f"{self.BASE_URL}/chat/completions", headers=self._get_headers(), json=payload
131
+ )
132
+ response.raise_for_status()
133
+ data = response.json()
134
+
135
+ choice = data["choices"][0]
136
+ message = choice["message"]
137
+
138
+ tool_calls = []
139
+ if message.get("tool_calls"):
140
+ for tc in message["tool_calls"]:
141
+ args = (
142
+ json.loads(tc["function"]["arguments"])
143
+ if tc["function"].get("arguments")
144
+ else {}
145
+ )
146
+ tool_call = ToolCall(
147
+ name=tc["function"]["name"],
148
+ arguments=args,
149
+ id=tc.get("id", tc["function"]["name"]),
150
+ )
151
+ tool_calls.append(tool_call)
152
+
153
+ content = message.get("content") or "Calling tool..."
154
+
155
+ return LLMResponse(
156
+ message=Message(role="assistant", content=content),
157
+ tool_calls=tool_calls if tool_calls else None,
158
+ raw=data,
159
+ )
schemas/message.py ADDED
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class ToolCall:
7
+ name: str
8
+ arguments: dict[str, Any]
9
+ id: str | None = None
10
+
11
+
12
+ @dataclass
13
+ class Message:
14
+ role: str # "system" | "user" | "tool" | "assistant"
15
+ content: str
16
+ name: str | None = None
17
+ tool_call_id: str | None = None
18
+ metadata: dict[str, Any] | None = None
19
+
20
+
21
+ @dataclass
22
+ class Tool:
23
+ name: str
24
+ description: str
25
+ arguments: dict[str, Any] = None
26
+
27
+
28
+ @dataclass
29
+ class LLMResponse:
30
+ message: Message
31
+ tool_calls: list[ToolCall] | None = None
32
+ raw: Any | None = None
schemas/task.py ADDED
@@ -0,0 +1,115 @@
1
+ """Task schema for tracking work progress."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from enum import Enum
6
+
7
+
8
+ class TaskStatus(str, Enum):
9
+ """Status of a task."""
10
+
11
+ PENDING = "pending"
12
+ IN_PROGRESS = "in_progress"
13
+ COMPLETED = "completed"
14
+
15
+
16
+ @dataclass
17
+ class Task:
18
+ """A single task to track."""
19
+
20
+ content: str # What needs to be done (imperative form)
21
+ status: TaskStatus = TaskStatus.PENDING
22
+ id: str | None = None
23
+ active_form: str | None = None # Present continuous form (e.g., "Running tests")
24
+ created_at: datetime = field(default_factory=datetime.now)
25
+ completed_at: datetime | None = None
26
+
27
+ def to_dict(self) -> dict:
28
+ """Convert task to dictionary."""
29
+ return {
30
+ "id": self.id,
31
+ "content": self.content,
32
+ "status": self.status.value,
33
+ "active_form": self.active_form,
34
+ "created_at": self.created_at.isoformat(),
35
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
36
+ }
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict) -> "Task":
40
+ """Create task from dictionary."""
41
+ return cls(
42
+ id=data.get("id"),
43
+ content=data["content"],
44
+ status=TaskStatus(data.get("status", "pending")),
45
+ active_form=data.get("active_form") or data.get("activeForm"),
46
+ created_at=datetime.fromisoformat(data["created_at"])
47
+ if data.get("created_at")
48
+ else datetime.now(),
49
+ completed_at=datetime.fromisoformat(data["completed_at"])
50
+ if data.get("completed_at")
51
+ else None,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class TaskList:
57
+ """A list of tasks."""
58
+
59
+ tasks: list[Task] = field(default_factory=list)
60
+
61
+ def add(self, task: Task) -> Task:
62
+ """Add a task to the list."""
63
+ if not task.id:
64
+ task.id = f"task_{len(self.tasks) + 1}"
65
+ self.tasks.append(task)
66
+ return task
67
+
68
+ def get(self, task_id: str) -> Task | None:
69
+ """Get a task by ID."""
70
+ for task in self.tasks:
71
+ if task.id == task_id:
72
+ return task
73
+ return None
74
+
75
+ def update_status(self, task_id: str, status: TaskStatus) -> Task | None:
76
+ """Update a task's status."""
77
+ task = self.get(task_id)
78
+ if task:
79
+ task.status = status
80
+ if status == TaskStatus.COMPLETED:
81
+ task.completed_at = datetime.now()
82
+ return task
83
+
84
+ def get_by_status(self, status: TaskStatus) -> list[Task]:
85
+ """Get all tasks with a specific status."""
86
+ return [t for t in self.tasks if t.status == status]
87
+
88
+ def get_current(self) -> Task | None:
89
+ """Get the current in-progress task."""
90
+ in_progress = self.get_by_status(TaskStatus.IN_PROGRESS)
91
+ return in_progress[0] if in_progress else None
92
+
93
+ def clear(self):
94
+ """Clear all tasks."""
95
+ self.tasks = []
96
+
97
+ @property
98
+ def pending_count(self) -> int:
99
+ return len(self.get_by_status(TaskStatus.PENDING))
100
+
101
+ @property
102
+ def in_progress_count(self) -> int:
103
+ return len(self.get_by_status(TaskStatus.IN_PROGRESS))
104
+
105
+ @property
106
+ def completed_count(self) -> int:
107
+ return len(self.get_by_status(TaskStatus.COMPLETED))
108
+
109
+ @property
110
+ def total_count(self) -> int:
111
+ return len(self.tasks)
112
+
113
+ def to_list(self) -> list[dict]:
114
+ """Convert to list of dictionaries."""
115
+ return [t.to_dict() for t in self.tasks]
schemas/tool.py ADDED
File without changes
tools/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ DevOrch Tools - Tools for interacting with the system.
3
+
4
+ Available tools:
5
+ - ShellTool: Execute shell commands
6
+ - OpenTerminalTool: Run a command in a new terminal window (servers, scaffolds, long-running processes)
7
+ - TerminalSessionTool: Managed background sessions with read/send/stop/list
8
+ - FilesystemTool: Read, write, list files with line-specific control
9
+ - SearchTool: Find files by glob patterns
10
+ - GrepTool: Search file contents with regex
11
+ - EditTool: Make targeted edits to files
12
+ """
13
+
14
+ from tools.edit import EditTool
15
+ from tools.filesystem import FilesystemTool
16
+ from tools.grep import GrepTool
17
+ from tools.search import SearchTool
18
+ from tools.shell import ShellTool
19
+ from tools.terminal import OpenTerminalTool
20
+ from tools.terminal_session import TerminalSessionTool
21
+
22
+ __all__ = [
23
+ "ShellTool",
24
+ "OpenTerminalTool",
25
+ "TerminalSessionTool",
26
+ "FilesystemTool",
27
+ "SearchTool",
28
+ "GrepTool",
29
+ "EditTool",
30
+ ]
tools/base.py ADDED
@@ -0,0 +1,40 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Tool(ABC):
8
+ """
9
+ Base class for all tools.
10
+ """
11
+
12
+ name: str = ""
13
+ description: str = ""
14
+ args_schema: type[BaseModel] = None
15
+
16
+ @abstractmethod
17
+ def run(self, arguments: dict[str, Any]) -> Any:
18
+ """
19
+ Execute the tool with given arguments.
20
+ """
21
+ pass
22
+
23
+ def schema(self) -> dict[str, Any]:
24
+ """
25
+ JSON schema exposed to LLMs.
26
+ """
27
+ parameters = {"type": "object", "properties": {}}
28
+ if self.args_schema:
29
+ schema_dump = self.args_schema.model_json_schema()
30
+ parameters = {
31
+ "type": "object",
32
+ "properties": schema_dump.get("properties", {}),
33
+ "required": schema_dump.get("required", []),
34
+ }
35
+
36
+ return {
37
+ "name": self.name,
38
+ "description": self.description,
39
+ "parameters": parameters,
40
+ }