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 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
- [![Watch the tutorial](https://img.youtube.com/vi/lGWFlpffWk4/hqdefault.jpg)](https://youtu.be/lGWFlpffWk4)
9
+ [![Watch the tutorial](https://img.youtube.com/vi/nKiPOxDpcJY/hqdefault.jpg)](https://youtu.be/nKiPOxDpcJY)
10
10
 
11
- > **[Watch the setup and usage guide →](https://youtu.be/lGWFlpffWk4)**
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.2",
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
@@ -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 is_valid_project_name as validate_project_name
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
- if not validate_project_name(project_name):
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
- Frontend should call this to get the current list of models
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=_is_glm_mode(),
92
- ollama_mode=_is_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=_is_glm_mode(),
123
- ollama_mode=_is_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 as validate_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 validate_project_name(project_name):
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 validate_project_name(project_name):
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 validate_project_name(project_name):
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
- if not validate_project_name(project_name):
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 as validate_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 validate_project_name(project_name):
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 validate_project_name(project_name):
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 validate_project_name(project_name):
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 validate_project_name(project_name):
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 validate_project_name(project_name):
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 and v not in VALID_MODELS:
423
- raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
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')