autoforge-ai 0.1.2 → 0.1.4
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/.env.example +16 -0
- package/README.md +2 -2
- package/env_constants.py +1 -0
- package/package.json +2 -1
- package/registry.py +117 -0
- package/server/routers/agent.py +1 -1
- package/server/routers/assistant_chat.py +10 -4
- package/server/routers/expand_project.py +8 -2
- package/server/routers/settings.py +79 -7
- package/server/routers/spec_creation.py +13 -7
- package/server/routers/terminal.py +14 -8
- package/server/schemas.py +41 -3
- package/server/services/assistant_chat_session.py +6 -10
- package/server/services/expand_chat_session.py +6 -11
- package/server/services/process_manager.py +57 -1
- package/server/services/spec_chat_session.py +6 -11
- package/server/websocket.py +8 -5
- package/temp_cleanup.py +148 -0
- package/ui/dist/assets/index-CCu7z6o1.css +1 -0
- package/ui/dist/assets/index-CWf0HODJ.js +97 -0
- package/ui/dist/assets/vendor-utils-ZeeSylek.js +2 -0
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-CNq40B6c.js +0 -97
- package/ui/dist/assets/index-InF2n2n-.css +0 -1
- package/ui/dist/assets/vendor-utils-Cj4T6W23.js +0 -2
package/.env.example
CHANGED
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
|
35
35
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
|
36
36
|
|
|
37
|
+
# ===================
|
|
38
|
+
# Alternative API Providers
|
|
39
|
+
# ===================
|
|
40
|
+
# NOTE: These env vars are the legacy way to configure providers.
|
|
41
|
+
# The recommended way is to use the Settings UI (API Provider section).
|
|
42
|
+
# UI settings take precedence when api_provider != "claude".
|
|
43
|
+
|
|
44
|
+
# Kimi K2.5 (Moonshot) Configuration (Optional)
|
|
45
|
+
# Get an API key at: https://kimi.com
|
|
46
|
+
#
|
|
47
|
+
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
|
|
48
|
+
# ANTHROPIC_API_KEY=your-kimi-api-key
|
|
49
|
+
# ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.5
|
|
50
|
+
# ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.5
|
|
51
|
+
# ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.5
|
|
52
|
+
|
|
37
53
|
# GLM/Alternative API Configuration (Optional)
|
|
38
54
|
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
|
|
39
55
|
# This only affects AutoForge - your global Claude Code settings remain unchanged.
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@ A long-running autonomous coding agent powered by the Claude Agent SDK. This too
|
|
|
6
6
|
|
|
7
7
|
## Video Tutorial
|
|
8
8
|
|
|
9
|
-
[](https://youtu.be/nKiPOxDpcJY)
|
|
10
10
|
|
|
11
|
-
> **[Watch the setup and usage guide →](https://youtu.be/
|
|
11
|
+
> **[Watch the setup and usage guide →](https://youtu.be/nKiPOxDpcJY)**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/env_constants.py
CHANGED
|
@@ -15,6 +15,7 @@ API_ENV_VARS: list[str] = [
|
|
|
15
15
|
# Core API configuration
|
|
16
16
|
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
|
17
17
|
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
|
18
|
+
"ANTHROPIC_API_KEY", # API key (used by Kimi and other providers)
|
|
18
19
|
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
|
19
20
|
# Model tier overrides
|
|
20
21
|
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autoforge-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"registry.py",
|
|
35
35
|
"rate_limit_utils.py",
|
|
36
36
|
"security.py",
|
|
37
|
+
"temp_cleanup.py",
|
|
37
38
|
"requirements-prod.txt",
|
|
38
39
|
"pyproject.toml",
|
|
39
40
|
".env.example",
|
package/registry.py
CHANGED
|
@@ -612,3 +612,120 @@ def get_all_settings() -> dict[str, str]:
|
|
|
612
612
|
except Exception as e:
|
|
613
613
|
logger.warning("Failed to read settings: %s", e)
|
|
614
614
|
return {}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# =============================================================================
|
|
618
|
+
# API Provider Definitions
|
|
619
|
+
# =============================================================================
|
|
620
|
+
|
|
621
|
+
API_PROVIDERS: dict[str, dict[str, Any]] = {
|
|
622
|
+
"claude": {
|
|
623
|
+
"name": "Claude (Anthropic)",
|
|
624
|
+
"base_url": None,
|
|
625
|
+
"requires_auth": False,
|
|
626
|
+
"models": [
|
|
627
|
+
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
|
628
|
+
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
|
629
|
+
],
|
|
630
|
+
"default_model": "claude-opus-4-5-20251101",
|
|
631
|
+
},
|
|
632
|
+
"kimi": {
|
|
633
|
+
"name": "Kimi K2.5 (Moonshot)",
|
|
634
|
+
"base_url": "https://api.kimi.com/coding/",
|
|
635
|
+
"requires_auth": True,
|
|
636
|
+
"auth_env_var": "ANTHROPIC_API_KEY",
|
|
637
|
+
"models": [{"id": "kimi-k2.5", "name": "Kimi K2.5"}],
|
|
638
|
+
"default_model": "kimi-k2.5",
|
|
639
|
+
},
|
|
640
|
+
"glm": {
|
|
641
|
+
"name": "GLM (Zhipu AI)",
|
|
642
|
+
"base_url": "https://api.z.ai/api/anthropic",
|
|
643
|
+
"requires_auth": True,
|
|
644
|
+
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
|
645
|
+
"models": [
|
|
646
|
+
{"id": "glm-4.7", "name": "GLM 4.7"},
|
|
647
|
+
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
|
648
|
+
],
|
|
649
|
+
"default_model": "glm-4.7",
|
|
650
|
+
},
|
|
651
|
+
"ollama": {
|
|
652
|
+
"name": "Ollama (Local)",
|
|
653
|
+
"base_url": "http://localhost:11434",
|
|
654
|
+
"requires_auth": False,
|
|
655
|
+
"models": [
|
|
656
|
+
{"id": "qwen3-coder", "name": "Qwen3 Coder"},
|
|
657
|
+
{"id": "deepseek-coder-v2", "name": "DeepSeek Coder V2"},
|
|
658
|
+
],
|
|
659
|
+
"default_model": "qwen3-coder",
|
|
660
|
+
},
|
|
661
|
+
"custom": {
|
|
662
|
+
"name": "Custom Provider",
|
|
663
|
+
"base_url": "",
|
|
664
|
+
"requires_auth": True,
|
|
665
|
+
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
|
666
|
+
"models": [],
|
|
667
|
+
"default_model": "",
|
|
668
|
+
},
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def get_effective_sdk_env() -> dict[str, str]:
|
|
673
|
+
"""Build environment variable dict for Claude SDK based on current API provider settings.
|
|
674
|
+
|
|
675
|
+
When api_provider is "claude" (or unset), falls back to existing env vars (current behavior).
|
|
676
|
+
For other providers, builds env dict from stored settings (api_base_url, api_auth_token, api_model).
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Dict ready to merge into subprocess env or pass to SDK.
|
|
680
|
+
"""
|
|
681
|
+
all_settings = get_all_settings()
|
|
682
|
+
provider_id = all_settings.get("api_provider", "claude")
|
|
683
|
+
|
|
684
|
+
if provider_id == "claude":
|
|
685
|
+
# Default behavior: forward existing env vars
|
|
686
|
+
from env_constants import API_ENV_VARS
|
|
687
|
+
sdk_env: dict[str, str] = {}
|
|
688
|
+
for var in API_ENV_VARS:
|
|
689
|
+
value = os.getenv(var)
|
|
690
|
+
if value:
|
|
691
|
+
sdk_env[var] = value
|
|
692
|
+
return sdk_env
|
|
693
|
+
|
|
694
|
+
# Alternative provider: build env from settings
|
|
695
|
+
provider = API_PROVIDERS.get(provider_id)
|
|
696
|
+
if not provider:
|
|
697
|
+
logger.warning("Unknown API provider '%s', falling back to claude", provider_id)
|
|
698
|
+
from env_constants import API_ENV_VARS
|
|
699
|
+
sdk_env = {}
|
|
700
|
+
for var in API_ENV_VARS:
|
|
701
|
+
value = os.getenv(var)
|
|
702
|
+
if value:
|
|
703
|
+
sdk_env[var] = value
|
|
704
|
+
return sdk_env
|
|
705
|
+
|
|
706
|
+
sdk_env = {}
|
|
707
|
+
|
|
708
|
+
# Base URL
|
|
709
|
+
base_url = all_settings.get("api_base_url") or provider.get("base_url")
|
|
710
|
+
if base_url:
|
|
711
|
+
sdk_env["ANTHROPIC_BASE_URL"] = base_url
|
|
712
|
+
|
|
713
|
+
# Auth token
|
|
714
|
+
auth_token = all_settings.get("api_auth_token")
|
|
715
|
+
if auth_token:
|
|
716
|
+
auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN")
|
|
717
|
+
sdk_env[auth_env_var] = auth_token
|
|
718
|
+
|
|
719
|
+
# Model - set all three tier overrides to the same model
|
|
720
|
+
model = all_settings.get("api_model") or provider.get("default_model")
|
|
721
|
+
if model:
|
|
722
|
+
sdk_env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
|
723
|
+
sdk_env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
|
724
|
+
sdk_env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
|
725
|
+
|
|
726
|
+
# Timeout
|
|
727
|
+
timeout = all_settings.get("api_timeout_ms")
|
|
728
|
+
if timeout:
|
|
729
|
+
sdk_env["API_TIMEOUT_MS"] = timeout
|
|
730
|
+
|
|
731
|
+
return sdk_env
|
package/server/routers/agent.py
CHANGED
|
@@ -32,7 +32,7 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
|
|
|
32
32
|
|
|
33
33
|
settings = get_all_settings()
|
|
34
34
|
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
|
35
|
-
model = settings.get("model", DEFAULT_MODEL)
|
|
35
|
+
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
|
36
36
|
|
|
37
37
|
# Parse testing agent settings with defaults
|
|
38
38
|
try:
|
|
@@ -26,7 +26,7 @@ from ..services.assistant_database import (
|
|
|
26
26
|
get_conversations,
|
|
27
27
|
)
|
|
28
28
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
29
|
-
from ..utils.validation import
|
|
29
|
+
from ..utils.validation import validate_project_name
|
|
30
30
|
|
|
31
31
|
logger = logging.getLogger(__name__)
|
|
32
32
|
|
|
@@ -217,20 +217,26 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|
|
217
217
|
- {"type": "error", "content": "..."} - Error message
|
|
218
218
|
- {"type": "pong"} - Keep-alive pong
|
|
219
219
|
"""
|
|
220
|
-
|
|
220
|
+
# Always accept WebSocket first to avoid opaque 403 errors
|
|
221
|
+
await websocket.accept()
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
project_name = validate_project_name(project_name)
|
|
225
|
+
except HTTPException:
|
|
226
|
+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
|
221
227
|
await websocket.close(code=4000, reason="Invalid project name")
|
|
222
228
|
return
|
|
223
229
|
|
|
224
230
|
project_dir = _get_project_path(project_name)
|
|
225
231
|
if not project_dir:
|
|
232
|
+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
|
226
233
|
await websocket.close(code=4004, reason="Project not found in registry")
|
|
227
234
|
return
|
|
228
235
|
|
|
229
236
|
if not project_dir.exists():
|
|
237
|
+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
|
230
238
|
await websocket.close(code=4004, reason="Project directory not found")
|
|
231
239
|
return
|
|
232
|
-
|
|
233
|
-
await websocket.accept()
|
|
234
240
|
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
|
235
241
|
|
|
236
242
|
session: Optional[AssistantChatSession] = None
|
|
@@ -104,19 +104,26 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
|
|
104
104
|
- {"type": "error", "content": "..."} - Error message
|
|
105
105
|
- {"type": "pong"} - Keep-alive pong
|
|
106
106
|
"""
|
|
107
|
+
# Always accept the WebSocket first to avoid opaque 403 errors.
|
|
108
|
+
# Starlette returns 403 if we close before accepting.
|
|
109
|
+
await websocket.accept()
|
|
110
|
+
|
|
107
111
|
try:
|
|
108
112
|
project_name = validate_project_name(project_name)
|
|
109
113
|
except HTTPException:
|
|
114
|
+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
|
110
115
|
await websocket.close(code=4000, reason="Invalid project name")
|
|
111
116
|
return
|
|
112
117
|
|
|
113
118
|
# Look up project directory from registry
|
|
114
119
|
project_dir = _get_project_path(project_name)
|
|
115
120
|
if not project_dir:
|
|
121
|
+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
|
116
122
|
await websocket.close(code=4004, reason="Project not found in registry")
|
|
117
123
|
return
|
|
118
124
|
|
|
119
125
|
if not project_dir.exists():
|
|
126
|
+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
|
120
127
|
await websocket.close(code=4004, reason="Project directory not found")
|
|
121
128
|
return
|
|
122
129
|
|
|
@@ -124,11 +131,10 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
|
|
124
131
|
from autoforge_paths import get_prompts_dir
|
|
125
132
|
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
|
126
133
|
if not spec_path.exists():
|
|
134
|
+
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
|
|
127
135
|
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
|
128
136
|
return
|
|
129
137
|
|
|
130
|
-
await websocket.accept()
|
|
131
|
-
|
|
132
138
|
session: Optional[ExpandChatSession] = None
|
|
133
139
|
|
|
134
140
|
try:
|
|
@@ -12,7 +12,7 @@ import sys
|
|
|
12
12
|
|
|
13
13
|
from fastapi import APIRouter
|
|
14
14
|
|
|
15
|
-
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
|
15
|
+
from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate
|
|
16
16
|
from ..services.chat_constants import ROOT_DIR
|
|
17
17
|
|
|
18
18
|
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
|
@@ -23,9 +23,11 @@ if str(ROOT_DIR) not in sys.path:
|
|
|
23
23
|
sys.path.insert(0, str(ROOT_DIR))
|
|
24
24
|
|
|
25
25
|
from registry import (
|
|
26
|
+
API_PROVIDERS,
|
|
26
27
|
AVAILABLE_MODELS,
|
|
27
28
|
DEFAULT_MODEL,
|
|
28
29
|
get_all_settings,
|
|
30
|
+
get_setting,
|
|
29
31
|
set_setting,
|
|
30
32
|
)
|
|
31
33
|
|
|
@@ -50,13 +52,40 @@ def _is_ollama_mode() -> bool:
|
|
|
50
52
|
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
@router.get("/providers", response_model=ProvidersResponse)
|
|
56
|
+
async def get_available_providers():
|
|
57
|
+
"""Get list of available API providers."""
|
|
58
|
+
current = get_setting("api_provider", "claude") or "claude"
|
|
59
|
+
providers = []
|
|
60
|
+
for pid, pdata in API_PROVIDERS.items():
|
|
61
|
+
providers.append(ProviderInfo(
|
|
62
|
+
id=pid,
|
|
63
|
+
name=pdata["name"],
|
|
64
|
+
base_url=pdata.get("base_url"),
|
|
65
|
+
models=[ModelInfo(id=m["id"], name=m["name"]) for m in pdata.get("models", [])],
|
|
66
|
+
default_model=pdata.get("default_model", ""),
|
|
67
|
+
requires_auth=pdata.get("requires_auth", False),
|
|
68
|
+
))
|
|
69
|
+
return ProvidersResponse(providers=providers, current=current)
|
|
70
|
+
|
|
71
|
+
|
|
53
72
|
@router.get("/models", response_model=ModelsResponse)
|
|
54
73
|
async def get_available_models():
|
|
55
74
|
"""Get list of available models.
|
|
56
75
|
|
|
57
|
-
|
|
58
|
-
instead of hardcoding them.
|
|
76
|
+
Returns models for the currently selected API provider.
|
|
59
77
|
"""
|
|
78
|
+
current_provider = get_setting("api_provider", "claude") or "claude"
|
|
79
|
+
provider = API_PROVIDERS.get(current_provider)
|
|
80
|
+
|
|
81
|
+
if provider and current_provider != "claude":
|
|
82
|
+
provider_models = provider.get("models", [])
|
|
83
|
+
return ModelsResponse(
|
|
84
|
+
models=[ModelInfo(id=m["id"], name=m["name"]) for m in provider_models],
|
|
85
|
+
default=provider.get("default_model", ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Default: return Claude models
|
|
60
89
|
return ModelsResponse(
|
|
61
90
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
|
62
91
|
default=DEFAULT_MODEL,
|
|
@@ -85,14 +114,24 @@ async def get_settings():
|
|
|
85
114
|
"""Get current global settings."""
|
|
86
115
|
all_settings = get_all_settings()
|
|
87
116
|
|
|
117
|
+
api_provider = all_settings.get("api_provider", "claude")
|
|
118
|
+
|
|
119
|
+
# Compute glm_mode / ollama_mode from api_provider for backward compat
|
|
120
|
+
glm_mode = api_provider == "glm" or _is_glm_mode()
|
|
121
|
+
ollama_mode = api_provider == "ollama" or _is_ollama_mode()
|
|
122
|
+
|
|
88
123
|
return SettingsResponse(
|
|
89
124
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
|
90
125
|
model=all_settings.get("model", DEFAULT_MODEL),
|
|
91
|
-
glm_mode=
|
|
92
|
-
ollama_mode=
|
|
126
|
+
glm_mode=glm_mode,
|
|
127
|
+
ollama_mode=ollama_mode,
|
|
93
128
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
94
129
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
|
95
130
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
131
|
+
api_provider=api_provider,
|
|
132
|
+
api_base_url=all_settings.get("api_base_url"),
|
|
133
|
+
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
|
134
|
+
api_model=all_settings.get("api_model"),
|
|
96
135
|
)
|
|
97
136
|
|
|
98
137
|
|
|
@@ -114,14 +153,47 @@ async def update_settings(update: SettingsUpdate):
|
|
|
114
153
|
if update.batch_size is not None:
|
|
115
154
|
set_setting("batch_size", str(update.batch_size))
|
|
116
155
|
|
|
156
|
+
# API provider settings
|
|
157
|
+
if update.api_provider is not None:
|
|
158
|
+
old_provider = get_setting("api_provider", "claude")
|
|
159
|
+
set_setting("api_provider", update.api_provider)
|
|
160
|
+
|
|
161
|
+
# When provider changes, auto-set defaults for the new provider
|
|
162
|
+
if update.api_provider != old_provider:
|
|
163
|
+
provider = API_PROVIDERS.get(update.api_provider)
|
|
164
|
+
if provider:
|
|
165
|
+
# Auto-set base URL from provider definition
|
|
166
|
+
if provider.get("base_url"):
|
|
167
|
+
set_setting("api_base_url", provider["base_url"])
|
|
168
|
+
# Auto-set model to provider's default
|
|
169
|
+
if provider.get("default_model") and update.api_model is None:
|
|
170
|
+
set_setting("api_model", provider["default_model"])
|
|
171
|
+
|
|
172
|
+
if update.api_base_url is not None:
|
|
173
|
+
set_setting("api_base_url", update.api_base_url)
|
|
174
|
+
|
|
175
|
+
if update.api_auth_token is not None:
|
|
176
|
+
set_setting("api_auth_token", update.api_auth_token)
|
|
177
|
+
|
|
178
|
+
if update.api_model is not None:
|
|
179
|
+
set_setting("api_model", update.api_model)
|
|
180
|
+
|
|
117
181
|
# Return updated settings
|
|
118
182
|
all_settings = get_all_settings()
|
|
183
|
+
api_provider = all_settings.get("api_provider", "claude")
|
|
184
|
+
glm_mode = api_provider == "glm" or _is_glm_mode()
|
|
185
|
+
ollama_mode = api_provider == "ollama" or _is_ollama_mode()
|
|
186
|
+
|
|
119
187
|
return SettingsResponse(
|
|
120
188
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
|
121
189
|
model=all_settings.get("model", DEFAULT_MODEL),
|
|
122
|
-
glm_mode=
|
|
123
|
-
ollama_mode=
|
|
190
|
+
glm_mode=glm_mode,
|
|
191
|
+
ollama_mode=ollama_mode,
|
|
124
192
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
125
193
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
|
126
194
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
195
|
+
api_provider=api_provider,
|
|
196
|
+
api_base_url=all_settings.get("api_base_url"),
|
|
197
|
+
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
|
198
|
+
api_model=all_settings.get("api_model"),
|
|
127
199
|
)
|
|
@@ -21,7 +21,7 @@ from ..services.spec_chat_session import (
|
|
|
21
21
|
remove_session,
|
|
22
22
|
)
|
|
23
23
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
24
|
-
from ..utils.validation import is_valid_project_name
|
|
24
|
+
from ..utils.validation import is_valid_project_name, validate_project_name
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
@@ -49,7 +49,7 @@ async def list_spec_sessions():
|
|
|
49
49
|
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
|
50
50
|
async def get_session_status(project_name: str):
|
|
51
51
|
"""Get status of a spec creation session."""
|
|
52
|
-
if not
|
|
52
|
+
if not is_valid_project_name(project_name):
|
|
53
53
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
54
54
|
|
|
55
55
|
session = get_session(project_name)
|
|
@@ -67,7 +67,7 @@ async def get_session_status(project_name: str):
|
|
|
67
67
|
@router.delete("/sessions/{project_name}")
|
|
68
68
|
async def cancel_session(project_name: str):
|
|
69
69
|
"""Cancel and remove a spec creation session."""
|
|
70
|
-
if not
|
|
70
|
+
if not is_valid_project_name(project_name):
|
|
71
71
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
72
72
|
|
|
73
73
|
session = get_session(project_name)
|
|
@@ -95,7 +95,7 @@ async def get_spec_file_status(project_name: str):
|
|
|
95
95
|
This is used for polling to detect when Claude has finished writing spec files.
|
|
96
96
|
Claude writes this status file as the final step after completing all spec work.
|
|
97
97
|
"""
|
|
98
|
-
if not
|
|
98
|
+
if not is_valid_project_name(project_name):
|
|
99
99
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
100
100
|
|
|
101
101
|
project_dir = _get_project_path(project_name)
|
|
@@ -166,22 +166,28 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
|
|
|
166
166
|
- {"type": "error", "content": "..."} - Error message
|
|
167
167
|
- {"type": "pong"} - Keep-alive pong
|
|
168
168
|
"""
|
|
169
|
-
|
|
169
|
+
# Always accept WebSocket first to avoid opaque 403 errors
|
|
170
|
+
await websocket.accept()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
project_name = validate_project_name(project_name)
|
|
174
|
+
except HTTPException:
|
|
175
|
+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
|
170
176
|
await websocket.close(code=4000, reason="Invalid project name")
|
|
171
177
|
return
|
|
172
178
|
|
|
173
179
|
# Look up project directory from registry
|
|
174
180
|
project_dir = _get_project_path(project_name)
|
|
175
181
|
if not project_dir:
|
|
182
|
+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
|
176
183
|
await websocket.close(code=4004, reason="Project not found in registry")
|
|
177
184
|
return
|
|
178
185
|
|
|
179
186
|
if not project_dir.exists():
|
|
187
|
+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
|
180
188
|
await websocket.close(code=4004, reason="Project directory not found")
|
|
181
189
|
return
|
|
182
190
|
|
|
183
|
-
await websocket.accept()
|
|
184
|
-
|
|
185
191
|
session: Optional[SpecChatSession] = None
|
|
186
192
|
|
|
187
193
|
try:
|
|
@@ -26,7 +26,7 @@ from ..services.terminal_manager import (
|
|
|
26
26
|
stop_terminal_session,
|
|
27
27
|
)
|
|
28
28
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
29
|
-
from ..utils.validation import is_valid_project_name
|
|
29
|
+
from ..utils.validation import is_valid_project_name
|
|
30
30
|
|
|
31
31
|
logger = logging.getLogger(__name__)
|
|
32
32
|
|
|
@@ -89,7 +89,7 @@ async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse
|
|
|
89
89
|
Returns:
|
|
90
90
|
List of terminal info objects
|
|
91
91
|
"""
|
|
92
|
-
if not
|
|
92
|
+
if not is_valid_project_name(project_name):
|
|
93
93
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
94
94
|
|
|
95
95
|
project_dir = _get_project_path(project_name)
|
|
@@ -122,7 +122,7 @@ async def create_project_terminal(
|
|
|
122
122
|
Returns:
|
|
123
123
|
The created terminal info
|
|
124
124
|
"""
|
|
125
|
-
if not
|
|
125
|
+
if not is_valid_project_name(project_name):
|
|
126
126
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
127
127
|
|
|
128
128
|
project_dir = _get_project_path(project_name)
|
|
@@ -148,7 +148,7 @@ async def rename_project_terminal(
|
|
|
148
148
|
Returns:
|
|
149
149
|
The updated terminal info
|
|
150
150
|
"""
|
|
151
|
-
if not
|
|
151
|
+
if not is_valid_project_name(project_name):
|
|
152
152
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
153
153
|
|
|
154
154
|
if not validate_terminal_id(terminal_id):
|
|
@@ -180,7 +180,7 @@ async def delete_project_terminal(project_name: str, terminal_id: str) -> dict:
|
|
|
180
180
|
Returns:
|
|
181
181
|
Success message
|
|
182
182
|
"""
|
|
183
|
-
if not
|
|
183
|
+
if not is_valid_project_name(project_name):
|
|
184
184
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
185
185
|
|
|
186
186
|
if not validate_terminal_id(terminal_id):
|
|
@@ -221,8 +221,12 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|
|
221
221
|
- {"type": "pong"} - Keep-alive response
|
|
222
222
|
- {"type": "error", "message": "..."} - Error message
|
|
223
223
|
"""
|
|
224
|
+
# Always accept WebSocket first to avoid opaque 403 errors
|
|
225
|
+
await websocket.accept()
|
|
226
|
+
|
|
224
227
|
# Validate project name
|
|
225
|
-
if not
|
|
228
|
+
if not is_valid_project_name(project_name):
|
|
229
|
+
await websocket.send_json({"type": "error", "message": "Invalid project name"})
|
|
226
230
|
await websocket.close(
|
|
227
231
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
|
228
232
|
)
|
|
@@ -230,6 +234,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|
|
230
234
|
|
|
231
235
|
# Validate terminal ID
|
|
232
236
|
if not validate_terminal_id(terminal_id):
|
|
237
|
+
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
|
|
233
238
|
await websocket.close(
|
|
234
239
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
|
235
240
|
)
|
|
@@ -238,6 +243,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|
|
238
243
|
# Look up project directory from registry
|
|
239
244
|
project_dir = _get_project_path(project_name)
|
|
240
245
|
if not project_dir:
|
|
246
|
+
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
|
|
241
247
|
await websocket.close(
|
|
242
248
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
243
249
|
reason="Project not found in registry",
|
|
@@ -245,6 +251,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|
|
245
251
|
return
|
|
246
252
|
|
|
247
253
|
if not project_dir.exists():
|
|
254
|
+
await websocket.send_json({"type": "error", "message": "Project directory not found"})
|
|
248
255
|
await websocket.close(
|
|
249
256
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
250
257
|
reason="Project directory not found",
|
|
@@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|
|
254
261
|
# Verify terminal exists in metadata
|
|
255
262
|
terminal_info = get_terminal_info(project_name, terminal_id)
|
|
256
263
|
if not terminal_info:
|
|
264
|
+
await websocket.send_json({"type": "error", "message": "Terminal not found"})
|
|
257
265
|
await websocket.close(
|
|
258
266
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
|
259
267
|
reason="Terminal not found",
|
|
260
268
|
)
|
|
261
269
|
return
|
|
262
270
|
|
|
263
|
-
await websocket.accept()
|
|
264
|
-
|
|
265
271
|
# Get or create terminal session for this project/terminal
|
|
266
272
|
session = get_terminal_session(project_name, project_dir, terminal_id)
|
|
267
273
|
|
package/server/schemas.py
CHANGED
|
@@ -391,6 +391,22 @@ class ModelInfo(BaseModel):
|
|
|
391
391
|
name: str
|
|
392
392
|
|
|
393
393
|
|
|
394
|
+
class ProviderInfo(BaseModel):
|
|
395
|
+
"""Information about an API provider."""
|
|
396
|
+
id: str
|
|
397
|
+
name: str
|
|
398
|
+
base_url: str | None = None
|
|
399
|
+
models: list[ModelInfo]
|
|
400
|
+
default_model: str
|
|
401
|
+
requires_auth: bool = False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ProvidersResponse(BaseModel):
|
|
405
|
+
"""Response schema for available providers list."""
|
|
406
|
+
providers: list[ProviderInfo]
|
|
407
|
+
current: str
|
|
408
|
+
|
|
409
|
+
|
|
394
410
|
class SettingsResponse(BaseModel):
|
|
395
411
|
"""Response schema for global settings."""
|
|
396
412
|
yolo_mode: bool = False
|
|
@@ -400,6 +416,10 @@ class SettingsResponse(BaseModel):
|
|
|
400
416
|
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
|
401
417
|
playwright_headless: bool = True
|
|
402
418
|
batch_size: int = 3 # Features per coding agent batch (1-3)
|
|
419
|
+
api_provider: str = "claude"
|
|
420
|
+
api_base_url: str | None = None
|
|
421
|
+
api_has_auth_token: bool = False # Never expose actual token
|
|
422
|
+
api_model: str | None = None
|
|
403
423
|
|
|
404
424
|
|
|
405
425
|
class ModelsResponse(BaseModel):
|
|
@@ -415,12 +435,30 @@ class SettingsUpdate(BaseModel):
|
|
|
415
435
|
testing_agent_ratio: int | None = None # 0-3
|
|
416
436
|
playwright_headless: bool | None = None
|
|
417
437
|
batch_size: int | None = None # Features per agent batch (1-3)
|
|
438
|
+
api_provider: str | None = None
|
|
439
|
+
api_base_url: str | None = Field(None, max_length=500)
|
|
440
|
+
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
|
441
|
+
api_model: str | None = Field(None, max_length=200)
|
|
442
|
+
|
|
443
|
+
@field_validator('api_base_url')
|
|
444
|
+
@classmethod
|
|
445
|
+
def validate_api_base_url(cls, v: str | None) -> str | None:
|
|
446
|
+
if v is not None and v.strip():
|
|
447
|
+
v = v.strip()
|
|
448
|
+
if not v.startswith(("http://", "https://")):
|
|
449
|
+
raise ValueError("api_base_url must start with http:// or https://")
|
|
450
|
+
return v
|
|
418
451
|
|
|
419
452
|
@field_validator('model')
|
|
420
453
|
@classmethod
|
|
421
|
-
def validate_model(cls, v: str | None) -> str | None:
|
|
422
|
-
if v is not None
|
|
423
|
-
|
|
454
|
+
def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
|
|
455
|
+
if v is not None:
|
|
456
|
+
# Skip VALID_MODELS check when using an alternative API provider
|
|
457
|
+
api_provider = info.data.get("api_provider")
|
|
458
|
+
if api_provider and api_provider != "claude":
|
|
459
|
+
return v
|
|
460
|
+
if v not in VALID_MODELS:
|
|
461
|
+
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
|
424
462
|
return v
|
|
425
463
|
|
|
426
464
|
@field_validator('testing_agent_ratio')
|