@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.
Files changed (67) hide show
  1. package/README.md +439 -0
  2. package/bin/voria +730 -0
  3. package/docs/ARCHITECTURE.md +419 -0
  4. package/docs/CHANGELOG.md +189 -0
  5. package/docs/CONTRIBUTING.md +447 -0
  6. package/docs/DESIGN_DECISIONS.md +380 -0
  7. package/docs/DEVELOPMENT.md +535 -0
  8. package/docs/EXAMPLES.md +434 -0
  9. package/docs/INSTALL.md +335 -0
  10. package/docs/IPC_PROTOCOL.md +310 -0
  11. package/docs/LLM_INTEGRATION.md +416 -0
  12. package/docs/MODULES.md +470 -0
  13. package/docs/PERFORMANCE.md +346 -0
  14. package/docs/PLUGINS.md +432 -0
  15. package/docs/QUICKSTART.md +184 -0
  16. package/docs/README.md +133 -0
  17. package/docs/ROADMAP.md +346 -0
  18. package/docs/SECURITY.md +334 -0
  19. package/docs/TROUBLESHOOTING.md +565 -0
  20. package/docs/USER_GUIDE.md +700 -0
  21. package/package.json +63 -0
  22. package/python/voria/__init__.py +8 -0
  23. package/python/voria/__pycache__/__init__.cpython-312.pyc +0 -0
  24. package/python/voria/__pycache__/engine.cpython-312.pyc +0 -0
  25. package/python/voria/core/__init__.py +1 -0
  26. package/python/voria/core/__pycache__/__init__.cpython-312.pyc +0 -0
  27. package/python/voria/core/__pycache__/setup.cpython-312.pyc +0 -0
  28. package/python/voria/core/agent/__init__.py +9 -0
  29. package/python/voria/core/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  30. package/python/voria/core/agent/__pycache__/loop.cpython-312.pyc +0 -0
  31. package/python/voria/core/agent/loop.py +343 -0
  32. package/python/voria/core/executor/__init__.py +19 -0
  33. package/python/voria/core/executor/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/python/voria/core/executor/__pycache__/executor.cpython-312.pyc +0 -0
  35. package/python/voria/core/executor/executor.py +431 -0
  36. package/python/voria/core/github/__init__.py +33 -0
  37. package/python/voria/core/github/__pycache__/__init__.cpython-312.pyc +0 -0
  38. package/python/voria/core/github/__pycache__/client.cpython-312.pyc +0 -0
  39. package/python/voria/core/github/client.py +438 -0
  40. package/python/voria/core/llm/__init__.py +55 -0
  41. package/python/voria/core/llm/__pycache__/__init__.cpython-312.pyc +0 -0
  42. package/python/voria/core/llm/__pycache__/base.cpython-312.pyc +0 -0
  43. package/python/voria/core/llm/__pycache__/claude_provider.cpython-312.pyc +0 -0
  44. package/python/voria/core/llm/__pycache__/gemini_provider.cpython-312.pyc +0 -0
  45. package/python/voria/core/llm/__pycache__/modal_provider.cpython-312.pyc +0 -0
  46. package/python/voria/core/llm/__pycache__/model_discovery.cpython-312.pyc +0 -0
  47. package/python/voria/core/llm/__pycache__/openai_provider.cpython-312.pyc +0 -0
  48. package/python/voria/core/llm/base.py +152 -0
  49. package/python/voria/core/llm/claude_provider.py +188 -0
  50. package/python/voria/core/llm/gemini_provider.py +148 -0
  51. package/python/voria/core/llm/modal_provider.py +228 -0
  52. package/python/voria/core/llm/model_discovery.py +289 -0
  53. package/python/voria/core/llm/openai_provider.py +146 -0
  54. package/python/voria/core/patcher/__init__.py +9 -0
  55. package/python/voria/core/patcher/__pycache__/__init__.cpython-312.pyc +0 -0
  56. package/python/voria/core/patcher/__pycache__/patcher.cpython-312.pyc +0 -0
  57. package/python/voria/core/patcher/patcher.py +375 -0
  58. package/python/voria/core/planner/__init__.py +1 -0
  59. package/python/voria/core/setup.py +201 -0
  60. package/python/voria/core/token_manager/__init__.py +29 -0
  61. package/python/voria/core/token_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  62. package/python/voria/core/token_manager/__pycache__/manager.cpython-312.pyc +0 -0
  63. package/python/voria/core/token_manager/manager.py +241 -0
  64. package/python/voria/engine.py +1185 -0
  65. package/python/voria/plugins/__init__.py +1 -0
  66. package/python/voria/plugins/python/__init__.py +1 -0
  67. 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
+ ]
@@ -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())