forgex-cli 0.1.0__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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/models/base.py ADDED
@@ -0,0 +1,112 @@
1
+ """Model provider contract (spec section 13).
2
+
3
+ Agents never touch provider SDKs — they talk to the ModelRouter, which
4
+ talks to adapters implementing this ABC.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import AsyncIterator
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class Message(BaseModel):
17
+ """One chat message (wire format for OpenAI-compatible APIs)."""
18
+
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ role: str # system | user | assistant | tool
22
+ content: str | None = None
23
+ name: str | None = None
24
+ tool_call_id: str | None = None
25
+ tool_calls: list[dict[str, Any]] | None = None
26
+
27
+
28
+ ChatMessage = Message
29
+
30
+
31
+ class ToolCall(BaseModel):
32
+ model_config = ConfigDict(extra="ignore")
33
+
34
+ id: str = ""
35
+ name: str
36
+ arguments: Any = ""
37
+
38
+
39
+ class ModelUsage(BaseModel):
40
+ """Usage as reported by the provider (None = not reported)."""
41
+
42
+ model_config = ConfigDict(extra="ignore")
43
+
44
+ prompt_tokens: int | None = None
45
+ completion_tokens: int | None = None
46
+ total_tokens: int | None = None
47
+
48
+ def __init__(self, **data: Any) -> None:
49
+ if "input_tokens" in data and "prompt_tokens" not in data:
50
+ data["prompt_tokens"] = data.pop("input_tokens")
51
+ if "output_tokens" in data and "completion_tokens" not in data:
52
+ data["completion_tokens"] = data.pop("output_tokens")
53
+ super().__init__(**data)
54
+
55
+ @property
56
+ def input_tokens(self) -> int | None:
57
+ return self.prompt_tokens
58
+
59
+ @property
60
+ def output_tokens(self) -> int | None:
61
+ return self.completion_tokens
62
+
63
+
64
+ class ModelRequest(BaseModel):
65
+ model_config = ConfigDict(extra="ignore")
66
+
67
+ model: str
68
+ messages: list[Message]
69
+ system: str | None = None
70
+ tools: list[dict[str, Any]] | None = None
71
+ tool_choice: str | None = None
72
+ response_format: dict[str, str] | None = None
73
+ temperature: float | None = None
74
+ max_tokens: int | None = None
75
+
76
+
77
+ class ModelResponse(BaseModel):
78
+ model_config = ConfigDict(extra="ignore")
79
+
80
+ content: str | None = None
81
+ model: str = "default"
82
+ provider: str = "default"
83
+ usage: ModelUsage | None = None
84
+ attempts: int = 1
85
+ tool_calls: list[ToolCall] = Field(default_factory=list)
86
+
87
+
88
+ class ProviderHealth(BaseModel):
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ provider: str
92
+ ok: bool
93
+ detail: str
94
+ latency_ms: float | None = None
95
+
96
+
97
+ class ModelProvider(ABC):
98
+ @abstractmethod
99
+ async def generate(self, request: ModelRequest) -> ModelResponse:
100
+ pass
101
+
102
+ @abstractmethod
103
+ def stream(self, request: ModelRequest) -> AsyncIterator[str]:
104
+ pass
105
+
106
+ @abstractmethod
107
+ async def health(self) -> ProviderHealth:
108
+ pass
109
+
110
+ @abstractmethod
111
+ async def aclose(self) -> None:
112
+ pass
@@ -0,0 +1,21 @@
1
+ """Model capability metadata (spec section 31).
2
+
3
+ Never assume a model supports a feature: the router reads these and
4
+ adapts requests (drops tools / response_format when unsupported).
5
+ Unknown models get conservative defaults — honest "unknown", not guesses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+
13
+ class ModelCapabilities(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+ model: str
17
+ context_window: int | None = None # None = unknown
18
+ supports_tools: bool = False
19
+ supports_json: bool = False
20
+ supports_streaming: bool = False
21
+ known: bool = False # False = inferred conservative defaults, not verified
@@ -0,0 +1,66 @@
1
+ """Provider factory: config → provider instance (spec sections 13, 26, 46, 73)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from forgex.config.schema import ForgexConfig, ProviderConfig
9
+ from forgex.models.base import ModelProvider
10
+ from forgex.models.groq import DEFAULT_API_KEY_ENV as GROQ_KEY_ENV
11
+ from forgex.models.groq import DEFAULT_BASE_URL as GROQ_BASE_URL
12
+ from forgex.models.groq import GroqProvider
13
+ from forgex.models.local import LocalProvider
14
+ from forgex.models.openai_compatible import OpenAICompatibleProvider
15
+ from forgex.schemas.error import ConfigError, ProviderError
16
+
17
+
18
+ def resolve_api_key(config: ProviderConfig) -> str | None:
19
+ env_name = config.api_key_env
20
+ if env_name is None and config.type == "groq":
21
+ env_name = GROQ_KEY_ENV
22
+ if not env_name:
23
+ return None
24
+ return os.environ.get(env_name) or None
25
+
26
+
27
+ def build_provider(
28
+ name: str,
29
+ config: ProviderConfig,
30
+ *,
31
+ http_client: Any = None,
32
+ ) -> ModelProvider:
33
+ """Build the provider instance for a configured provider entry."""
34
+ if os.environ.get("FORGEX_OFFLINE") == "1" and config.type != "local":
35
+ msg = (
36
+ f"offline mode: remote provider '{name}' is disabled "
37
+ "(--offline / FORGEX_OFFLINE=1) — configure a local provider"
38
+ )
39
+ raise ProviderError(msg)
40
+
41
+ kwargs: dict[str, Any] = {
42
+ "api_key": resolve_api_key(config),
43
+ "timeout_seconds": config.timeout_seconds,
44
+ "max_retries": config.max_retries,
45
+ }
46
+ if http_client is not None:
47
+ kwargs["http_client"] = http_client
48
+
49
+ if config.type == "groq":
50
+ return GroqProvider(base_url=config.base_url or GROQ_BASE_URL, **kwargs)
51
+ if config.type == "local":
52
+ if not config.base_url:
53
+ msg = (
54
+ f"Provider '{name}' (local) needs a base_url, "
55
+ "e.g. http://localhost:11434/v1"
56
+ )
57
+ raise ConfigError(msg)
58
+ return LocalProvider(base_url=config.base_url, **kwargs)
59
+ if not config.base_url:
60
+ msg = f"Provider '{name}' (openai_compatible) needs a base_url"
61
+ raise ConfigError(msg)
62
+ return OpenAICompatibleProvider(name, base_url=config.base_url, **kwargs)
63
+
64
+
65
+ def provider_names(config: ForgexConfig) -> list[str]:
66
+ return sorted(config.providers)
forgex/models/groq.py ADDED
@@ -0,0 +1,13 @@
1
+ """Groq adapter — the OpenAI-compatible protocol with Groq defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from forgex.models.openai_compatible import OpenAICompatibleProvider
6
+
7
+ DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
8
+ DEFAULT_API_KEY_ENV = "FORGEX_GROQ_API_KEY"
9
+
10
+
11
+ class GroqProvider(OpenAICompatibleProvider):
12
+ def __init__(self, *, base_url: str | None = None, api_key: str | None = None, **kwargs) -> None:
13
+ super().__init__("groq", base_url=base_url or DEFAULT_BASE_URL, api_key=api_key, **kwargs)
forgex/models/local.py ADDED
@@ -0,0 +1,10 @@
1
+ """Local model provider — any local OpenAI-compatible server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from forgex.models.openai_compatible import OpenAICompatibleProvider
6
+
7
+
8
+ class LocalProvider(OpenAICompatibleProvider):
9
+ def __init__(self, *, base_url: str, api_key: str | None = None, **kwargs) -> None:
10
+ super().__init__("local", base_url=base_url, api_key=api_key, **kwargs)
@@ -0,0 +1,233 @@
1
+ """OpenAI-compatible chat-completions adapter.
2
+
3
+ Works with any server implementing POST /chat/completions and GET /models
4
+ (OpenAI, Groq, llama.cpp, Ollama, vLLM, LM Studio, ...).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import time
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from forgex.models.base import (
18
+ ModelProvider,
19
+ ModelRequest,
20
+ ModelResponse,
21
+ ModelUsage,
22
+ ProviderHealth,
23
+ ToolCall,
24
+ )
25
+ from forgex.orchestration.retry import RetryPolicy, run_with_retries
26
+ from forgex.schemas.error import AuthenticationError, ProviderError, RateLimitError
27
+ from forgex.utils.redact import redact
28
+
29
+ logger = logging.getLogger("forgex.models")
30
+
31
+
32
+ def parse_sse_content(line: str) -> str | None:
33
+ """Parse one SSE line: content delta, '' for [DONE], None to skip."""
34
+ if not line.startswith("data:"):
35
+ return None
36
+ data = line[len("data:") :].strip()
37
+ if not data:
38
+ return None
39
+ if data == "[DONE]":
40
+ return ""
41
+ try:
42
+ chunk = json.loads(data)
43
+ except ValueError:
44
+ return None
45
+ choices = chunk.get("choices") or []
46
+ if not choices:
47
+ return None
48
+ content = (choices[0].get("delta") or {}).get("content")
49
+ return content if content else None
50
+
51
+
52
+ class OpenAICompatibleProvider(ModelProvider):
53
+ """Adapter for OpenAI-compatible HTTP APIs."""
54
+
55
+ def __init__(
56
+ self,
57
+ name: str,
58
+ *,
59
+ base_url: str,
60
+ api_key: str | None = None,
61
+ timeout_seconds: float = 60.0,
62
+ max_retries: int = 2,
63
+ http_client: httpx.AsyncClient | None = None,
64
+ ) -> None:
65
+ self.name = name
66
+ self.base_url = base_url.rstrip("/")
67
+ self.api_key = api_key
68
+ self.timeout_seconds = timeout_seconds
69
+ self.max_retries = max_retries
70
+ self._owns_client = http_client is None
71
+ self._client = http_client or httpx.AsyncClient(timeout=timeout_seconds)
72
+
73
+ def _headers(self) -> dict[str, str]:
74
+ headers = {"Content-Type": "application/json"}
75
+ if self.api_key:
76
+ headers["Authorization"] = f"Bearer {self.api_key}"
77
+ return headers
78
+
79
+ def _redact_error(self, message: str) -> str:
80
+ secrets = [self.api_key] if self.api_key else []
81
+ return redact(message, secrets)
82
+
83
+ async def generate(self, request: ModelRequest) -> ModelResponse:
84
+ url = f"{self.base_url}/chat/completions"
85
+
86
+ messages_payload: list[dict[str, Any]] = []
87
+ if request.system:
88
+ messages_payload.append({"role": "system", "content": request.system})
89
+
90
+ for msg in request.messages:
91
+ item: dict[str, Any] = {"role": msg.role, "content": msg.content}
92
+ if msg.name:
93
+ item["name"] = msg.name
94
+ if msg.tool_call_id:
95
+ item["tool_call_id"] = msg.tool_call_id
96
+ if msg.tool_calls:
97
+ item["tool_calls"] = msg.tool_calls
98
+ messages_payload.append(item)
99
+
100
+ body: dict[str, Any] = {
101
+ "model": request.model,
102
+ "messages": messages_payload,
103
+ }
104
+
105
+ if request.tools:
106
+ body["tools"] = request.tools
107
+ body["tool_choice"] = request.tool_choice or "auto"
108
+ if request.response_format:
109
+ body["response_format"] = request.response_format
110
+ if request.temperature is not None:
111
+ body["temperature"] = request.temperature
112
+ if request.max_tokens is not None:
113
+ body["max_tokens"] = request.max_tokens
114
+
115
+ async def _call() -> httpx.Response:
116
+ try:
117
+ resp = await self._client.post(url, headers=self._headers(), json=body)
118
+ except httpx.TimeoutException as exc:
119
+ msg = self._redact_error(f"Timeout calling {self.name}: {exc}")
120
+ raise ProviderError(msg, retryable=True) from exc
121
+ except httpx.NetworkError as exc:
122
+ msg = self._redact_error(f"Network error calling {self.name}: {exc}")
123
+ raise ProviderError(msg, retryable=True) from exc
124
+ except OSError as exc:
125
+ msg = self._redact_error(f"Connection error calling {self.name}: {exc}")
126
+ raise ProviderError(msg, retryable=True) from exc
127
+
128
+ if resp.status_code in (401, 403):
129
+ msg = self._redact_error(f"Authentication failed for {self.name}: {resp.text}")
130
+ raise AuthenticationError(msg)
131
+ elif resp.status_code == 429:
132
+ msg = self._redact_error(f"Rate limited by {self.name}: {resp.text}")
133
+ raise RateLimitError(msg)
134
+ elif resp.status_code >= 500:
135
+ msg = self._redact_error(f"Server error from {self.name} ({resp.status_code}): {resp.text}")
136
+ raise ProviderError(msg, retryable=True)
137
+ elif resp.status_code != 200:
138
+ msg = self._redact_error(f"Error from {self.name} ({resp.status_code}): {resp.text}")
139
+ raise ProviderError(msg, retryable=False)
140
+
141
+ return resp
142
+
143
+ policy = RetryPolicy(max_attempts=self.max_retries + 1, initial_delay=1.0)
144
+ try:
145
+ http_resp, attempts = await run_with_retries(_call, policy=policy)
146
+ except ProviderError as exc:
147
+ exc.attempts = getattr(exc, "attempts", 1)
148
+ raise
149
+
150
+ data = http_resp.json()
151
+ choices = data.get("choices") or []
152
+ first_choice = choices[0] if choices else {}
153
+ message_data = first_choice.get("message") or {}
154
+
155
+ content = message_data.get("content")
156
+
157
+ # Parse tool calls
158
+ tool_calls: list[ToolCall] = []
159
+ raw_tcs = message_data.get("tool_calls") or []
160
+ for raw_tc in raw_tcs:
161
+ fn = raw_tc.get("function") or {}
162
+ tool_calls.append(
163
+ ToolCall(
164
+ id=raw_tc.get("id", ""),
165
+ name=fn.get("name", ""),
166
+ arguments=fn.get("arguments", ""),
167
+ )
168
+ )
169
+
170
+ # Parse usage
171
+ usage_data = data.get("usage")
172
+ usage = None
173
+ if usage_data:
174
+ usage = ModelUsage(
175
+ prompt_tokens=usage_data.get("prompt_tokens"),
176
+ completion_tokens=usage_data.get("completion_tokens"),
177
+ total_tokens=usage_data.get("total_tokens"),
178
+ )
179
+
180
+ return ModelResponse(
181
+ content=content,
182
+ model=data.get("model", request.model),
183
+ provider=self.name,
184
+ usage=usage,
185
+ attempts=attempts,
186
+ tool_calls=tool_calls,
187
+ )
188
+
189
+ async def stream(self, request: ModelRequest) -> AsyncIterator[str]:
190
+ url = f"{self.base_url}/chat/completions"
191
+
192
+ messages_payload: list[dict[str, Any]] = []
193
+ if request.system:
194
+ messages_payload.append({"role": "system", "content": request.system})
195
+ for msg in request.messages:
196
+ messages_payload.append({"role": msg.role, "content": msg.content})
197
+
198
+ body: dict[str, Any] = {
199
+ "model": request.model,
200
+ "messages": messages_payload,
201
+ "stream": True,
202
+ }
203
+ if request.temperature is not None:
204
+ body["temperature"] = request.temperature
205
+
206
+ async with self._client.stream("POST", url, headers=self._headers(), json=body) as response:
207
+ if response.status_code != 200:
208
+ text = await response.aread()
209
+ err_msg = self._redact_error(f"Stream error {response.status_code}: {text.decode('utf-8', 'replace')}")
210
+ raise ProviderError(err_msg)
211
+ async for line in response.aiter_lines():
212
+ content = parse_sse_content(line)
213
+ if content:
214
+ yield content
215
+
216
+ async def health(self) -> ProviderHealth:
217
+ start = time.perf_counter()
218
+ try:
219
+ url = f"{self.base_url}/models"
220
+ resp = await self._client.get(url, headers=self._headers(), timeout=10.0)
221
+ latency = (time.perf_counter() - start) * 1000
222
+ if resp.status_code == 200:
223
+ return ProviderHealth(provider=self.name, ok=True, detail="available", latency_ms=latency)
224
+ msg = self._redact_error(f"HTTP {resp.status_code}")
225
+ return ProviderHealth(provider=self.name, ok=False, detail=msg, latency_ms=latency)
226
+ except Exception as exc: # noqa: BLE001
227
+ latency = (time.perf_counter() - start) * 1000
228
+ msg = self._redact_error(str(exc))
229
+ return ProviderHealth(provider=self.name, ok=False, detail=msg, latency_ms=latency)
230
+
231
+ async def aclose(self) -> None:
232
+ if self._owns_client:
233
+ await self._client.aclose()
@@ -0,0 +1,81 @@
1
+ """Model registry (spec sections 14, 31, 65).
2
+
3
+ The built-in catalog is an ADVISORY snapshot — model IDs go stale. Actual
4
+ selection always comes from user configuration; the catalog only informs
5
+ capability adaptation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from forgex.models.capabilities import ModelCapabilities
11
+
12
+ _DEFAULT_CATALOG: dict[str, ModelCapabilities] = {
13
+ # Groq models
14
+ "llama-3.3-70b-versatile": ModelCapabilities(
15
+ model="llama-3.3-70b-versatile",
16
+ context_window=131072,
17
+ supports_tools=True,
18
+ supports_json=True,
19
+ supports_streaming=True,
20
+ known=True,
21
+ ),
22
+ "llama-3.1-8b-instant": ModelCapabilities(
23
+ model="llama-3.1-8b-instant",
24
+ context_window=131072,
25
+ supports_tools=True,
26
+ supports_json=True,
27
+ supports_streaming=True,
28
+ known=True,
29
+ ),
30
+ # OpenAI models
31
+ "gpt-4o": ModelCapabilities(
32
+ model="gpt-4o",
33
+ context_window=128000,
34
+ supports_tools=True,
35
+ supports_json=True,
36
+ supports_streaming=True,
37
+ known=True,
38
+ ),
39
+ "gpt-4o-mini": ModelCapabilities(
40
+ model="gpt-4o-mini",
41
+ context_window=128000,
42
+ supports_tools=True,
43
+ supports_json=True,
44
+ supports_streaming=True,
45
+ known=True,
46
+ ),
47
+ "o1-mini": ModelCapabilities(
48
+ model="o1-mini",
49
+ context_window=128000,
50
+ supports_tools=False,
51
+ supports_json=True,
52
+ supports_streaming=False,
53
+ known=True,
54
+ ),
55
+ }
56
+
57
+
58
+ class ModelRegistry:
59
+ def __init__(self, overrides: dict[str, ModelCapabilities] | None = None) -> None:
60
+ self._catalog = dict(_DEFAULT_CATALOG)
61
+ if overrides:
62
+ for k, cap in overrides.items():
63
+ if not cap.known:
64
+ cap = cap.model_copy(update={"known": True})
65
+ self._catalog[k] = cap
66
+
67
+ def lookup(self, model: str) -> ModelCapabilities:
68
+ if model in self._catalog:
69
+ return self._catalog[model]
70
+ # Conservative defaults for unknown models
71
+ return ModelCapabilities(
72
+ model=model,
73
+ context_window=None,
74
+ supports_tools=False,
75
+ supports_json=False,
76
+ supports_streaming=False,
77
+ known=False,
78
+ )
79
+
80
+ def catalog(self) -> list[ModelCapabilities]:
81
+ return sorted(self._catalog.values(), key=lambda c: c.model)