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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
forgex/models/factory.py
ADDED
|
@@ -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)
|