@voria/cli 0.0.2
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.
- package/README.md +439 -0
- package/bin/voria +730 -0
- package/docs/ARCHITECTURE.md +419 -0
- package/docs/CHANGELOG.md +189 -0
- package/docs/CONTRIBUTING.md +447 -0
- package/docs/DESIGN_DECISIONS.md +380 -0
- package/docs/DEVELOPMENT.md +535 -0
- package/docs/EXAMPLES.md +434 -0
- package/docs/INSTALL.md +335 -0
- package/docs/IPC_PROTOCOL.md +310 -0
- package/docs/LLM_INTEGRATION.md +416 -0
- package/docs/MODULES.md +470 -0
- package/docs/PERFORMANCE.md +346 -0
- package/docs/PLUGINS.md +432 -0
- package/docs/QUICKSTART.md +184 -0
- package/docs/README.md +133 -0
- package/docs/ROADMAP.md +346 -0
- package/docs/SECURITY.md +334 -0
- package/docs/TROUBLESHOOTING.md +565 -0
- package/docs/USER_GUIDE.md +700 -0
- package/package.json +63 -0
- package/python/voria/__init__.py +8 -0
- package/python/voria/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/__pycache__/engine.cpython-312.pyc +0 -0
- package/python/voria/core/__init__.py +1 -0
- package/python/voria/core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/__pycache__/setup.cpython-312.pyc +0 -0
- package/python/voria/core/agent/__init__.py +9 -0
- package/python/voria/core/agent/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/agent/__pycache__/loop.cpython-312.pyc +0 -0
- package/python/voria/core/agent/loop.py +343 -0
- package/python/voria/core/executor/__init__.py +19 -0
- package/python/voria/core/executor/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/executor/__pycache__/executor.cpython-312.pyc +0 -0
- package/python/voria/core/executor/executor.py +431 -0
- package/python/voria/core/github/__init__.py +33 -0
- package/python/voria/core/github/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/github/__pycache__/client.cpython-312.pyc +0 -0
- package/python/voria/core/github/client.py +438 -0
- package/python/voria/core/llm/__init__.py +55 -0
- package/python/voria/core/llm/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/base.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/claude_provider.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/gemini_provider.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/modal_provider.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/model_discovery.cpython-312.pyc +0 -0
- package/python/voria/core/llm/__pycache__/openai_provider.cpython-312.pyc +0 -0
- package/python/voria/core/llm/base.py +152 -0
- package/python/voria/core/llm/claude_provider.py +188 -0
- package/python/voria/core/llm/gemini_provider.py +148 -0
- package/python/voria/core/llm/modal_provider.py +228 -0
- package/python/voria/core/llm/model_discovery.py +289 -0
- package/python/voria/core/llm/openai_provider.py +146 -0
- package/python/voria/core/patcher/__init__.py +9 -0
- package/python/voria/core/patcher/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/patcher/__pycache__/patcher.cpython-312.pyc +0 -0
- package/python/voria/core/patcher/patcher.py +375 -0
- package/python/voria/core/planner/__init__.py +1 -0
- package/python/voria/core/setup.py +201 -0
- package/python/voria/core/token_manager/__init__.py +29 -0
- package/python/voria/core/token_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/voria/core/token_manager/__pycache__/manager.cpython-312.pyc +0 -0
- package/python/voria/core/token_manager/manager.py +241 -0
- package/python/voria/engine.py +1185 -0
- package/python/voria/plugins/__init__.py +1 -0
- package/python/voria/plugins/python/__init__.py +1 -0
- package/python/voria/plugins/typescript/__init__.py +1 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""GitHub API Integration Module
|
|
2
|
+
|
|
3
|
+
Fetches issues, manages PRs, and handles GitHub workflows.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import List, Dict, Any, Optional
|
|
9
|
+
import httpx
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
GITHUB_TOKEN_GUIDE = """
|
|
16
|
+
================================================================================
|
|
17
|
+
GITHUB PERSONAL ACCESS TOKEN GUIDE
|
|
18
|
+
================================================================================
|
|
19
|
+
|
|
20
|
+
To access your GitHub issues, you need a Personal Access Token (PAT).
|
|
21
|
+
|
|
22
|
+
STEP 1: Generate a new token
|
|
23
|
+
------------------------
|
|
24
|
+
1. Go to: https://github.com/settings/tokens
|
|
25
|
+
2. Click "Generate new token (classic)"
|
|
26
|
+
3. Give it a descriptive name like "voria-cli"
|
|
27
|
+
4. Select expiration (recommend: 90 days)
|
|
28
|
+
|
|
29
|
+
STEP 2: Select scopes (permissions)
|
|
30
|
+
---------------------------
|
|
31
|
+
✅ repo (Full control of private repositories)
|
|
32
|
+
- This allows reading issues, creating PRs, etc.
|
|
33
|
+
- Required for accessing private repos
|
|
34
|
+
|
|
35
|
+
STEP 3: Generate token
|
|
36
|
+
-------------------
|
|
37
|
+
1. Click "Generate token"
|
|
38
|
+
2. COPY THE TOKEN IMMEDIATELY - it won't be shown again!
|
|
39
|
+
|
|
40
|
+
STEP 4: Use the token
|
|
41
|
+
-------------------
|
|
42
|
+
Save it to environment:
|
|
43
|
+
export GITHUB_TOKEN='ghp_your_token_here'
|
|
44
|
+
|
|
45
|
+
Or use directly in commands.
|
|
46
|
+
|
|
47
|
+
SECURITY NOTE: Never commit tokens to git! Add to .gitignore:
|
|
48
|
+
echo 'GITHUB_TOKEN=' >> .gitignore
|
|
49
|
+
================================================================================
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_token_guide() -> None:
|
|
54
|
+
"""Print the GitHub token guide to console."""
|
|
55
|
+
print(GITHUB_TOKEN_GUIDE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_github_token(interactive: bool = True) -> Optional[str]:
|
|
59
|
+
"""
|
|
60
|
+
Get GitHub token from environment or prompt user.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
interactive: If True, prompt user for token. If False, only check env.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Token string or None if not available
|
|
67
|
+
"""
|
|
68
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
69
|
+
if token:
|
|
70
|
+
logger.info("Using GitHub token from GITHUB_TOKEN environment")
|
|
71
|
+
return token
|
|
72
|
+
|
|
73
|
+
if interactive:
|
|
74
|
+
print_token_guide()
|
|
75
|
+
print("\nEnter your GitHub Personal Access Token: ", end="")
|
|
76
|
+
token = input().strip()
|
|
77
|
+
if token:
|
|
78
|
+
return token
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class GitHubIssue:
|
|
85
|
+
"""GitHub issue representation"""
|
|
86
|
+
|
|
87
|
+
id: int
|
|
88
|
+
number: int
|
|
89
|
+
title: str
|
|
90
|
+
body: str
|
|
91
|
+
labels: List[str]
|
|
92
|
+
state: str # "open" or "closed"
|
|
93
|
+
url: str
|
|
94
|
+
repo: str
|
|
95
|
+
created_at: str
|
|
96
|
+
updated_at: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GitHubClient:
|
|
100
|
+
"""GitHub API client for fetching issues and managing PRs"""
|
|
101
|
+
|
|
102
|
+
API_BASE = "https://api.github.com"
|
|
103
|
+
|
|
104
|
+
def __init__(self, token: str):
|
|
105
|
+
"""
|
|
106
|
+
Initialize GitHub client
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
token: GitHub personal access token
|
|
110
|
+
"""
|
|
111
|
+
self.token = token
|
|
112
|
+
self.client = httpx.AsyncClient(
|
|
113
|
+
headers={
|
|
114
|
+
"Authorization": f"token {token}",
|
|
115
|
+
"Accept": "application/vnd.github.v3+json",
|
|
116
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
117
|
+
},
|
|
118
|
+
timeout=30.0,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def fetch_issue(
|
|
122
|
+
self, owner: str, repo: str, issue_number: int
|
|
123
|
+
) -> GitHubIssue:
|
|
124
|
+
"""
|
|
125
|
+
Fetch a specific issue
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
owner: Repository owner (username)
|
|
129
|
+
repo: Repository name
|
|
130
|
+
issue_number: Issue number
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
GitHubIssue object
|
|
134
|
+
"""
|
|
135
|
+
url = f"{self.API_BASE}/repos/{owner}/{repo}/issues/{issue_number}"
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
logger.debug(f"Fetching issue {owner}/{repo}#{issue_number}")
|
|
139
|
+
response = await self.client.get(url)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
|
|
142
|
+
data = response.json()
|
|
143
|
+
|
|
144
|
+
issue = GitHubIssue(
|
|
145
|
+
id=data["id"],
|
|
146
|
+
number=data["number"],
|
|
147
|
+
title=data["title"],
|
|
148
|
+
body=data["body"] or "",
|
|
149
|
+
labels=[label["name"] for label in data.get("labels", [])],
|
|
150
|
+
state=data["state"],
|
|
151
|
+
url=data["html_url"],
|
|
152
|
+
repo=f"{owner}/{repo}",
|
|
153
|
+
created_at=data["created_at"],
|
|
154
|
+
updated_at=data["updated_at"],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
logger.info(f"Fetched issue: {issue.title}")
|
|
158
|
+
return issue
|
|
159
|
+
|
|
160
|
+
except httpx.HTTPError as e:
|
|
161
|
+
logger.error(f"GitHub API error: {e}")
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
async def fetch_issue_by_url(self, issue_url: str) -> GitHubIssue:
|
|
165
|
+
"""
|
|
166
|
+
Fetch issue from GitHub URL
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
issue_url: GitHub issue URL (e.g., https://github.com/owner/repo/issues/123)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
GitHubIssue object
|
|
173
|
+
"""
|
|
174
|
+
# Parse URL: https://github.com/owner/repo/issues/123
|
|
175
|
+
parts = issue_url.strip("/").split("/")
|
|
176
|
+
owner = parts[-4] if len(parts) >= 4 else None
|
|
177
|
+
repo = parts[-3] if len(parts) >= 3 else None
|
|
178
|
+
number = int(parts[-1]) if len(parts) >= 1 else None
|
|
179
|
+
|
|
180
|
+
if not all([owner, repo, number]):
|
|
181
|
+
raise ValueError(f"Invalid GitHub URL: {issue_url}")
|
|
182
|
+
|
|
183
|
+
return await self.fetch_issue(owner, repo, number)
|
|
184
|
+
|
|
185
|
+
async def create_pr(
|
|
186
|
+
self,
|
|
187
|
+
owner: str,
|
|
188
|
+
repo: str,
|
|
189
|
+
title: str,
|
|
190
|
+
body: str,
|
|
191
|
+
head: str,
|
|
192
|
+
base: str = "main",
|
|
193
|
+
) -> Dict[str, Any]:
|
|
194
|
+
"""
|
|
195
|
+
Create a pull request
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
owner: Repository owner
|
|
199
|
+
repo: Repository name
|
|
200
|
+
title: PR title
|
|
201
|
+
body: PR description
|
|
202
|
+
head: Branch to merge from (e.g., "feature/branch")
|
|
203
|
+
base: Branch to merge into (default: main)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
PR data dict
|
|
207
|
+
"""
|
|
208
|
+
url = f"{self.API_BASE}/repos/{owner}/{repo}/pulls"
|
|
209
|
+
|
|
210
|
+
payload = {"title": title, "body": body, "head": head, "base": base}
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
logger.debug(f"Creating PR: {title}")
|
|
214
|
+
response = await self.client.post(url, json=payload)
|
|
215
|
+
response.raise_for_status()
|
|
216
|
+
|
|
217
|
+
data = response.json()
|
|
218
|
+
logger.info(f"Created PR #{data['number']}: {title}")
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"number": data["number"],
|
|
222
|
+
"url": data["html_url"],
|
|
223
|
+
"id": data["id"],
|
|
224
|
+
"state": data["state"],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
except httpx.HTTPError as e:
|
|
228
|
+
logger.error(f"Failed to create PR: {e}")
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
async def add_comment(
|
|
232
|
+
self, owner: str, repo: str, issue_number: int, body: str
|
|
233
|
+
) -> Dict[str, Any]:
|
|
234
|
+
"""
|
|
235
|
+
Add a comment to an issue or PR
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
owner: Repository owner
|
|
239
|
+
repo: Repository name
|
|
240
|
+
issue_number: Issue/PR number
|
|
241
|
+
body: Comment body
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Comment data dict
|
|
245
|
+
"""
|
|
246
|
+
url = f"{self.API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
|
247
|
+
|
|
248
|
+
payload = {"body": body}
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
logger.debug(f"Adding comment to {owner}/{repo}#{issue_number}")
|
|
252
|
+
response = await self.client.post(url, json=payload)
|
|
253
|
+
response.raise_for_status()
|
|
254
|
+
|
|
255
|
+
data = response.json()
|
|
256
|
+
logger.info(f"Added comment to issue #{issue_number}")
|
|
257
|
+
|
|
258
|
+
return {"id": data["id"], "url": data["html_url"], "body": data["body"]}
|
|
259
|
+
|
|
260
|
+
except httpx.HTTPError as e:
|
|
261
|
+
logger.error(f"Failed to add comment: {e}")
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
async def update_issue_status(
|
|
265
|
+
self, owner: str, repo: str, issue_number: int, state: str
|
|
266
|
+
) -> None:
|
|
267
|
+
"""
|
|
268
|
+
Update issue status (open/closed)
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
owner: Repository owner
|
|
272
|
+
repo: Repository name
|
|
273
|
+
issue_number: Issue number
|
|
274
|
+
state: "open" or "closed"
|
|
275
|
+
"""
|
|
276
|
+
url = f"{self.API_BASE}/repos/{owner}/{repo}/issues/{issue_number}"
|
|
277
|
+
payload = {"state": state}
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
logger.debug(f"Updating issue {issue_number} state to {state}")
|
|
281
|
+
response = await self.client.patch(url, json=payload)
|
|
282
|
+
response.raise_for_status()
|
|
283
|
+
logger.info(f"Updated issue #{issue_number} state to {state}")
|
|
284
|
+
|
|
285
|
+
except httpx.HTTPError as e:
|
|
286
|
+
logger.error(f"Failed to update issue: {e}")
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
async def fetch_user_repos(self, username: str) -> List[Dict[str, Any]]:
|
|
290
|
+
"""
|
|
291
|
+
Fetch all repositories for a user
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
username: GitHub username
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
List of repository dicts
|
|
298
|
+
"""
|
|
299
|
+
url = f"{self.API_BASE}/users/{username}/repos"
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
logger.debug(f"Fetching repos for user {username}")
|
|
303
|
+
response = await self.client.get(
|
|
304
|
+
url, params={"sort": "updated", "per_page": 100}
|
|
305
|
+
)
|
|
306
|
+
response.raise_for_status()
|
|
307
|
+
|
|
308
|
+
repos = response.json()
|
|
309
|
+
logger.info(f"Found {len(repos)} repos for user {username}")
|
|
310
|
+
return repos
|
|
311
|
+
|
|
312
|
+
except httpx.HTTPError as e:
|
|
313
|
+
logger.error(f"Failed to fetch repos: {e}")
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
async def fetch_repo_issues(
|
|
317
|
+
self, owner: str, repo: str, state: str = "open"
|
|
318
|
+
) -> List[GitHubIssue]:
|
|
319
|
+
"""
|
|
320
|
+
Fetch all issues for a repository
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
owner: Repository owner
|
|
324
|
+
repo: Repository name
|
|
325
|
+
state: Issue state (open, closed, all)
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of GitHubIssue objects
|
|
329
|
+
"""
|
|
330
|
+
url = f"{self.API_BASE}/repos/{owner}/{repo}/issues"
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
logger.debug(f"Fetching {state} issues for {owner}/{repo}")
|
|
334
|
+
response = await self.client.get(
|
|
335
|
+
url, params={"state": state, "per_page": 100, "sort": "created"}
|
|
336
|
+
)
|
|
337
|
+
response.raise_for_status()
|
|
338
|
+
|
|
339
|
+
issues = []
|
|
340
|
+
for data in response.json():
|
|
341
|
+
if "pull_request" not in data:
|
|
342
|
+
issue = GitHubIssue(
|
|
343
|
+
id=data["id"],
|
|
344
|
+
number=data["number"],
|
|
345
|
+
title=data["title"],
|
|
346
|
+
body=data["body"] or "",
|
|
347
|
+
labels=[label["name"] for label in data.get("labels", [])],
|
|
348
|
+
state=data["state"],
|
|
349
|
+
url=data["html_url"],
|
|
350
|
+
repo=f"{owner}/{repo}",
|
|
351
|
+
created_at=data["created_at"],
|
|
352
|
+
updated_at=data["updated_at"],
|
|
353
|
+
)
|
|
354
|
+
issues.append(issue)
|
|
355
|
+
|
|
356
|
+
logger.info(f"Fetched {len(issues)} issues from {owner}/{repo}")
|
|
357
|
+
return issues
|
|
358
|
+
|
|
359
|
+
except httpx.HTTPError as e:
|
|
360
|
+
logger.error(f"Failed to fetch repo issues: {e}")
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
async def fetch_user_issues(
|
|
364
|
+
self, username: str, state: str = "open"
|
|
365
|
+
) -> List[GitHubIssue]:
|
|
366
|
+
"""
|
|
367
|
+
Fetch all issues across all repos owned by a user
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
username: GitHub username/owner
|
|
371
|
+
state: Issue state (open, closed, all)
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
List of GitHubIssue objects from all user repos
|
|
375
|
+
"""
|
|
376
|
+
all_issues = []
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
repos = await self.fetch_user_repos(username)
|
|
380
|
+
|
|
381
|
+
for repo in repos[:20]:
|
|
382
|
+
repo_name = repo.get("name")
|
|
383
|
+
try:
|
|
384
|
+
issues = await self.fetch_repo_issues(username, repo_name, state)
|
|
385
|
+
all_issues.extend(issues)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.warning(f"Skipping repo {repo_name}: {e}")
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
logger.info(f"Total issues for user {username}: {len(all_issues)}")
|
|
391
|
+
return all_issues
|
|
392
|
+
|
|
393
|
+
except httpx.HTTPError as e:
|
|
394
|
+
logger.error(f"Failed to fetch user issues: {e}")
|
|
395
|
+
raise
|
|
396
|
+
|
|
397
|
+
async def get_authenticated_user(self) -> Dict[str, Any]:
|
|
398
|
+
"""
|
|
399
|
+
Get authenticated user info
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
User dict with login, id, etc.
|
|
403
|
+
"""
|
|
404
|
+
url = f"{self.API_BASE}/user"
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
response = await self.client.get(url)
|
|
408
|
+
response.raise_for_status()
|
|
409
|
+
|
|
410
|
+
user = response.json()
|
|
411
|
+
logger.info(f"Authenticated as: {user.get('login')}")
|
|
412
|
+
return user
|
|
413
|
+
|
|
414
|
+
except httpx.HTTPError as e:
|
|
415
|
+
logger.error(f"Failed to get authenticated user: {e}")
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
async def get_rate_limit(self) -> Dict[str, Any]:
|
|
419
|
+
"""
|
|
420
|
+
Get API rate limit status
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Rate limit dict with limit, remaining, reset
|
|
424
|
+
"""
|
|
425
|
+
url = f"{self.API_BASE}/rate_limit"
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
response = await self.client.get(url)
|
|
429
|
+
response.raise_for_status()
|
|
430
|
+
return response.json()
|
|
431
|
+
|
|
432
|
+
except httpx.HTTPError as e:
|
|
433
|
+
logger.error(f"Failed to get rate limit: {e}")
|
|
434
|
+
raise
|
|
435
|
+
|
|
436
|
+
async def close(self):
|
|
437
|
+
"""Close HTTP client"""
|
|
438
|
+
await self.client.aclose()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""LLM Provider Integration Module
|
|
2
|
+
|
|
3
|
+
Supports multiple LLM providers with dynamic model discovery:
|
|
4
|
+
- Modal Z.ai GLM-5.1 (745B parameters)
|
|
5
|
+
- OpenAI GPT-4 / GPT-3.5-turbo
|
|
6
|
+
- Google Gemini Pro
|
|
7
|
+
- Anthropic Claude 3
|
|
8
|
+
|
|
9
|
+
Dynamic Model Discovery:
|
|
10
|
+
from voria.core.llm import LLMProviderFactory
|
|
11
|
+
|
|
12
|
+
# Discover available models
|
|
13
|
+
models = await LLMProviderFactory.discover_models(
|
|
14
|
+
provider_name="openai",
|
|
15
|
+
api_key="sk-..."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# User chooses from models list
|
|
19
|
+
chosen_model = models[0]
|
|
20
|
+
|
|
21
|
+
# Create provider with chosen model
|
|
22
|
+
provider = LLMProviderFactory.create(
|
|
23
|
+
provider_name="openai",
|
|
24
|
+
api_key="sk-...",
|
|
25
|
+
model=chosen_model.name
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
response = await provider.generate(messages)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .base import BaseLLMProvider, Message, LLMResponse, LLMProviderFactory
|
|
32
|
+
from .model_discovery import ModelDiscovery, ModelInfo
|
|
33
|
+
from .modal_provider import ModalProvider
|
|
34
|
+
from .openai_provider import OpenAIProvider
|
|
35
|
+
from .gemini_provider import GeminiProvider
|
|
36
|
+
from .claude_provider import ClaudeProvider
|
|
37
|
+
|
|
38
|
+
# Register all providers
|
|
39
|
+
LLMProviderFactory.register("modal", ModalProvider)
|
|
40
|
+
LLMProviderFactory.register("openai", OpenAIProvider)
|
|
41
|
+
LLMProviderFactory.register("gemini", GeminiProvider)
|
|
42
|
+
LLMProviderFactory.register("claude", ClaudeProvider)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"BaseLLMProvider",
|
|
46
|
+
"Message",
|
|
47
|
+
"LLMResponse",
|
|
48
|
+
"LLMProviderFactory",
|
|
49
|
+
"ModelDiscovery",
|
|
50
|
+
"ModelInfo",
|
|
51
|
+
"ModalProvider",
|
|
52
|
+
"OpenAIProvider",
|
|
53
|
+
"GeminiProvider",
|
|
54
|
+
"ClaudeProvider",
|
|
55
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""LLM Provider Interfaces and Base Classes"""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import logging
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Message:
|
|
14
|
+
"""LLM message in chat format"""
|
|
15
|
+
|
|
16
|
+
role: str # "system", "user", "assistant"
|
|
17
|
+
content: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LLMResponse:
|
|
22
|
+
"""Standard response from LLM providers"""
|
|
23
|
+
|
|
24
|
+
content: str
|
|
25
|
+
tokens_used: int
|
|
26
|
+
model: str
|
|
27
|
+
provider: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseLLMProvider(ABC):
|
|
31
|
+
"""Abstract base class for all LLM providers"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key: str, model: str):
|
|
34
|
+
"""
|
|
35
|
+
Initialize LLM provider
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
api_key: Provider-specific API key
|
|
39
|
+
model: Model identifier
|
|
40
|
+
"""
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.model = model
|
|
43
|
+
self.name = self.__class__.__name__
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def generate(
|
|
47
|
+
self, messages: List[Message], max_tokens: int = 2000, temperature: float = 0.7
|
|
48
|
+
) -> LLMResponse:
|
|
49
|
+
"""
|
|
50
|
+
Generate response from LLM
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
messages: List of messages in conversation
|
|
54
|
+
max_tokens: Maximum tokens in response
|
|
55
|
+
temperature: Sampling temperature (0-1)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
LLMResponse with content and token info
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def plan(self, issue_description: str) -> str:
|
|
64
|
+
"""Generate implementation plan from issue"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def generate_patch(
|
|
69
|
+
self,
|
|
70
|
+
issue_description: str,
|
|
71
|
+
context_files: Dict[str, str],
|
|
72
|
+
previous_errors: Optional[str] = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Generate code patch"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def analyze_test_failure(
|
|
79
|
+
self, test_output: str, code_context: str
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""Analyze test failure and suggest fix"""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LLMProviderFactory:
|
|
86
|
+
"""Factory for creating LLM provider instances"""
|
|
87
|
+
|
|
88
|
+
_providers = {}
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def register(cls, name: str, provider_class):
|
|
92
|
+
"""Register a new LLM provider"""
|
|
93
|
+
cls._providers[name.lower()] = provider_class
|
|
94
|
+
logger.info(f"Registered LLM provider: {name}")
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def create(cls, provider_name: str, api_key: str, model: str):
|
|
98
|
+
"""
|
|
99
|
+
Create an LLM provider instance
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
provider_name: Name of provider (modal, openai, gemini, claude)
|
|
103
|
+
api_key: Provider API key
|
|
104
|
+
model: Model identifier
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Configured LLM provider instance
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ValueError: If provider not found
|
|
111
|
+
"""
|
|
112
|
+
provider_class = cls._providers.get(provider_name.lower())
|
|
113
|
+
if not provider_class:
|
|
114
|
+
available = ", ".join(cls._providers.keys())
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Unknown LLM provider: {provider_name}. " f"Available: {available}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
logger.info(f"Creating {provider_name} provider with model {model}")
|
|
120
|
+
return provider_class(api_key=api_key, model=model)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
async def discover_models(cls, provider_name: str, api_key: str):
|
|
124
|
+
"""
|
|
125
|
+
Discover available models for a provider
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
provider_name: Name of provider (modal, openai, gemini, claude)
|
|
129
|
+
api_key: Provider API key for authentication
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of ModelInfo objects with available models
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If provider not found
|
|
136
|
+
"""
|
|
137
|
+
# Import here to avoid circular dependency
|
|
138
|
+
from .model_discovery import ModelDiscovery
|
|
139
|
+
|
|
140
|
+
if provider_name.lower() not in cls._providers:
|
|
141
|
+
available = ", ".join(cls._providers.keys())
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"Unknown LLM provider: {provider_name}. " f"Available: {available}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
logger.info(f"Discovering models for {provider_name}")
|
|
147
|
+
return await ModelDiscovery.discover_all(provider_name, api_key)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def list_providers(cls) -> List[str]:
|
|
151
|
+
"""List all registered providers"""
|
|
152
|
+
return list(cls._providers.keys())
|