autoforge-ai 0.1.3 → 0.1.5

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
@@ -9,11 +9,6 @@
9
9
  # - webkit: Safari engine
10
10
  # - msedge: Microsoft Edge
11
11
  # PLAYWRIGHT_BROWSER=firefox
12
- #
13
- # PLAYWRIGHT_HEADLESS: Run browser without visible window
14
- # - true: Browser runs in background, saves CPU (default)
15
- # - false: Browser opens a visible window (useful for debugging)
16
- # PLAYWRIGHT_HEADLESS=true
17
12
 
18
13
  # Extra Read Paths (Optional)
19
14
  # Comma-separated list of absolute paths for read-only access to external directories.
@@ -25,40 +20,17 @@
25
20
  # Google Cloud Vertex AI Configuration (Optional)
26
21
  # To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
27
22
  # Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
28
- # Note: Use @ instead of - in model names (e.g., claude-opus-4-5@20251101)
23
+ # Note: Use @ instead of - in model names for date-suffixed models (e.g., claude-sonnet-4-5@20250929)
29
24
  #
30
25
  # CLAUDE_CODE_USE_VERTEX=1
31
26
  # CLOUD_ML_REGION=us-east5
32
27
  # ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
33
- # ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
28
+ # ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
34
29
  # ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
35
30
  # ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
36
31
 
37
- # GLM/Alternative API Configuration (Optional)
38
- # To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
39
- # This only affects AutoForge - your global Claude Code settings remain unchanged.
40
- # Get an API key at: https://z.ai/subscribe
41
- #
42
- # ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
43
- # ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
44
- # API_TIMEOUT_MS=3000000
45
- # ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
46
- # ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
47
- # ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
48
-
49
- # Ollama Local Model Configuration (Optional)
50
- # To use local models via Ollama instead of Claude, uncomment and set these variables.
51
- # Requires Ollama v0.14.0+ with Anthropic API compatibility.
52
- # See: https://ollama.com/blog/claude
53
- #
54
- # ANTHROPIC_BASE_URL=http://localhost:11434
55
- # ANTHROPIC_AUTH_TOKEN=ollama
56
- # API_TIMEOUT_MS=3000000
57
- # ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
58
- # ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
59
- # ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
60
- #
61
- # Model recommendations:
62
- # - For best results, use a capable coding model like qwen3-coder or deepseek-coder-v2
63
- # - You can use the same model for all tiers, or different models per tier
64
- # - Larger models (70B+) work best for Opus tier, smaller (7B-20B) for Haiku
32
+ # ===================
33
+ # Alternative API Providers (GLM, Ollama, Kimi, Custom)
34
+ # ===================
35
+ # Configure alternative providers via the Settings UI (gear icon > API Provider).
36
+ # The Settings UI is the recommended way to switch providers and models.
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
 
@@ -326,37 +326,13 @@ When test progress increases, the agent sends:
326
326
  }
327
327
  ```
328
328
 
329
- ### Using GLM Models (Alternative to Claude)
329
+ ### Alternative API Providers (GLM, Ollama, Kimi, Custom)
330
330
 
331
- Add these variables to your `.env` file to use Zhipu AI's GLM models:
331
+ Alternative providers are configured via the **Settings UI** (gear icon > API Provider). Select your provider, set the base URL, auth token, and model directly in the UI — no `.env` changes needed.
332
332
 
333
- ```bash
334
- ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
335
- ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
336
- API_TIMEOUT_MS=3000000
337
- ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
338
- ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
339
- ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
340
- ```
341
-
342
- This routes AutoForge's API requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models. **This only affects AutoForge** - your global Claude Code settings remain unchanged.
343
-
344
- Get an API key at: https://z.ai/subscribe
345
-
346
- ### Using Ollama Local Models
347
-
348
- Add these variables to your `.env` file to run agents with local models via Ollama v0.14.0+:
349
-
350
- ```bash
351
- ANTHROPIC_BASE_URL=http://localhost:11434
352
- ANTHROPIC_AUTH_TOKEN=ollama
353
- API_TIMEOUT_MS=3000000
354
- ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
355
- ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
356
- ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
357
- ```
333
+ Available providers: **Claude** (default), **GLM** (Zhipu AI), **Ollama** (local models), **Kimi** (Moonshot), **Custom**
358
334
 
359
- See the [CLAUDE.md](CLAUDE.md) for recommended models and known limitations.
335
+ For Ollama, install [Ollama v0.14.0+](https://ollama.com), run `ollama serve`, and pull a coding model (e.g., `ollama pull qwen3-coder`). Then select "Ollama" in the Settings UI.
360
336
 
361
337
  ### Using Vertex AI
362
338
 
@@ -366,7 +342,7 @@ Add these variables to your `.env` file to run agents via Google Cloud Vertex AI
366
342
  CLAUDE_CODE_USE_VERTEX=1
367
343
  CLOUD_ML_REGION=us-east5
368
344
  ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
369
- ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
345
+ ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
370
346
  ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
371
347
  ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
372
348
  ```
package/client.py CHANGED
@@ -46,8 +46,9 @@ def convert_model_for_vertex(model: str) -> str:
46
46
  """
47
47
  Convert model name format for Vertex AI compatibility.
48
48
 
49
- Vertex AI uses @ to separate model name from version (e.g., claude-opus-4-5@20251101)
50
- while the Anthropic API uses - (e.g., claude-opus-4-5-20251101).
49
+ Vertex AI uses @ to separate model name from version (e.g., claude-sonnet-4-5@20250929)
50
+ while the Anthropic API uses - (e.g., claude-sonnet-4-5-20250929).
51
+ Models without a date suffix (e.g., claude-opus-4-6) pass through unchanged.
51
52
 
52
53
  Args:
53
54
  model: Model name in Anthropic format (with hyphens)
@@ -61,7 +62,7 @@ def convert_model_for_vertex(model: str) -> str:
61
62
  return model
62
63
 
63
64
  # Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
64
- # Example: claude-opus-4-5-20251101 -> claude-opus-4-5@20251101
65
+ # Example: claude-sonnet-4-5-20250929 -> claude-sonnet-4-5@20250929
65
66
  # The date is always 8 digits at the end
66
67
  match = re.match(r'^(claude-.+)-(\d{8})$', model)
67
68
  if match:
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",
3
+ "version": "0.1.5",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
package/registry.py CHANGED
@@ -46,10 +46,16 @@ def _migrate_registry_dir() -> None:
46
46
  # Available models with display names
47
47
  # To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
48
48
  AVAILABLE_MODELS = [
49
- {"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
50
- {"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
49
+ {"id": "claude-opus-4-6", "name": "Claude Opus"},
50
+ {"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
51
51
  ]
52
52
 
53
+ # Map legacy model IDs to their current replacements.
54
+ # Used by get_all_settings() to auto-migrate stale values on first read after upgrade.
55
+ LEGACY_MODEL_MAP = {
56
+ "claude-opus-4-5-20251101": "claude-opus-4-6",
57
+ }
58
+
53
59
  # List of valid model IDs (derived from AVAILABLE_MODELS)
54
60
  VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
55
61
 
@@ -59,7 +65,7 @@ VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
59
65
  _env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
60
66
  if _env_default_model is not None:
61
67
  _env_default_model = _env_default_model.strip()
62
- DEFAULT_MODEL = _env_default_model or "claude-opus-4-5-20251101"
68
+ DEFAULT_MODEL = _env_default_model or "claude-opus-4-6"
63
69
 
64
70
  # Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
65
71
  # (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
@@ -598,6 +604,9 @@ def get_all_settings() -> dict[str, str]:
598
604
  """
599
605
  Get all settings as a dictionary.
600
606
 
607
+ Automatically migrates legacy model IDs (e.g. claude-opus-4-5-20251101 -> claude-opus-4-6)
608
+ on first read after upgrade. This is a one-time silent migration.
609
+
601
610
  Returns:
602
611
  Dictionary mapping setting keys to values.
603
612
  """
@@ -606,9 +615,145 @@ def get_all_settings() -> dict[str, str]:
606
615
  session = SessionLocal()
607
616
  try:
608
617
  settings = session.query(Settings).all()
609
- return {s.key: s.value for s in settings}
618
+ result = {s.key: s.value for s in settings}
619
+
620
+ # Auto-migrate legacy model IDs
621
+ migrated = False
622
+ for key in ("model", "api_model"):
623
+ old_id = result.get(key)
624
+ if old_id and old_id in LEGACY_MODEL_MAP:
625
+ new_id = LEGACY_MODEL_MAP[old_id]
626
+ setting = session.query(Settings).filter(Settings.key == key).first()
627
+ if setting:
628
+ setting.value = new_id
629
+ setting.updated_at = datetime.now()
630
+ result[key] = new_id
631
+ migrated = True
632
+ logger.info("Migrated setting '%s': %s -> %s", key, old_id, new_id)
633
+
634
+ if migrated:
635
+ session.commit()
636
+
637
+ return result
610
638
  finally:
611
639
  session.close()
612
640
  except Exception as e:
613
641
  logger.warning("Failed to read settings: %s", e)
614
642
  return {}
643
+
644
+
645
+ # =============================================================================
646
+ # API Provider Definitions
647
+ # =============================================================================
648
+
649
+ API_PROVIDERS: dict[str, dict[str, Any]] = {
650
+ "claude": {
651
+ "name": "Claude (Anthropic)",
652
+ "base_url": None,
653
+ "requires_auth": False,
654
+ "models": [
655
+ {"id": "claude-opus-4-6", "name": "Claude Opus"},
656
+ {"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
657
+ ],
658
+ "default_model": "claude-opus-4-6",
659
+ },
660
+ "kimi": {
661
+ "name": "Kimi K2.5 (Moonshot)",
662
+ "base_url": "https://api.kimi.com/coding/",
663
+ "requires_auth": True,
664
+ "auth_env_var": "ANTHROPIC_API_KEY",
665
+ "models": [{"id": "kimi-k2.5", "name": "Kimi K2.5"}],
666
+ "default_model": "kimi-k2.5",
667
+ },
668
+ "glm": {
669
+ "name": "GLM (Zhipu AI)",
670
+ "base_url": "https://api.z.ai/api/anthropic",
671
+ "requires_auth": True,
672
+ "auth_env_var": "ANTHROPIC_AUTH_TOKEN",
673
+ "models": [
674
+ {"id": "glm-4.7", "name": "GLM 4.7"},
675
+ {"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
676
+ ],
677
+ "default_model": "glm-4.7",
678
+ },
679
+ "ollama": {
680
+ "name": "Ollama (Local)",
681
+ "base_url": "http://localhost:11434",
682
+ "requires_auth": False,
683
+ "models": [
684
+ {"id": "qwen3-coder", "name": "Qwen3 Coder"},
685
+ {"id": "deepseek-coder-v2", "name": "DeepSeek Coder V2"},
686
+ ],
687
+ "default_model": "qwen3-coder",
688
+ },
689
+ "custom": {
690
+ "name": "Custom Provider",
691
+ "base_url": "",
692
+ "requires_auth": True,
693
+ "auth_env_var": "ANTHROPIC_AUTH_TOKEN",
694
+ "models": [],
695
+ "default_model": "",
696
+ },
697
+ }
698
+
699
+
700
+ def get_effective_sdk_env() -> dict[str, str]:
701
+ """Build environment variable dict for Claude SDK based on current API provider settings.
702
+
703
+ When api_provider is "claude" (or unset), falls back to existing env vars (current behavior).
704
+ For other providers, builds env dict from stored settings (api_base_url, api_auth_token, api_model).
705
+
706
+ Returns:
707
+ Dict ready to merge into subprocess env or pass to SDK.
708
+ """
709
+ all_settings = get_all_settings()
710
+ provider_id = all_settings.get("api_provider", "claude")
711
+
712
+ if provider_id == "claude":
713
+ # Default behavior: forward existing env vars
714
+ from env_constants import API_ENV_VARS
715
+ sdk_env: dict[str, str] = {}
716
+ for var in API_ENV_VARS:
717
+ value = os.getenv(var)
718
+ if value:
719
+ sdk_env[var] = value
720
+ return sdk_env
721
+
722
+ # Alternative provider: build env from settings
723
+ provider = API_PROVIDERS.get(provider_id)
724
+ if not provider:
725
+ logger.warning("Unknown API provider '%s', falling back to claude", provider_id)
726
+ from env_constants import API_ENV_VARS
727
+ sdk_env = {}
728
+ for var in API_ENV_VARS:
729
+ value = os.getenv(var)
730
+ if value:
731
+ sdk_env[var] = value
732
+ return sdk_env
733
+
734
+ sdk_env = {}
735
+
736
+ # Base URL
737
+ base_url = all_settings.get("api_base_url") or provider.get("base_url")
738
+ if base_url:
739
+ sdk_env["ANTHROPIC_BASE_URL"] = base_url
740
+
741
+ # Auth token
742
+ auth_token = all_settings.get("api_auth_token")
743
+ if auth_token:
744
+ auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN")
745
+ sdk_env[auth_env_var] = auth_token
746
+
747
+ # Model - set all three tier overrides to the same model
748
+ model = all_settings.get("api_model") or provider.get("default_model")
749
+ if model:
750
+ sdk_env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
751
+ sdk_env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
752
+ sdk_env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
753
+
754
+ # Timeout
755
+ timeout = all_settings.get("api_timeout_ms")
756
+ if timeout:
757
+ sdk_env["API_TIMEOUT_MS"] = timeout
758
+
759
+ 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:
@@ -7,12 +7,11 @@ Settings are stored in the registry database and shared across all projects.
7
7
  """
8
8
 
9
9
  import mimetypes
10
- import os
11
10
  import sys
12
11
 
13
12
  from fastapi import APIRouter
14
13
 
15
- from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
14
+ from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate
16
15
  from ..services.chat_constants import ROOT_DIR
17
16
 
18
17
  # Mimetype fix for Windows - must run before StaticFiles is mounted
@@ -23,9 +22,11 @@ if str(ROOT_DIR) not in sys.path:
23
22
  sys.path.insert(0, str(ROOT_DIR))
24
23
 
25
24
  from registry import (
25
+ API_PROVIDERS,
26
26
  AVAILABLE_MODELS,
27
27
  DEFAULT_MODEL,
28
28
  get_all_settings,
29
+ get_setting,
29
30
  set_setting,
30
31
  )
31
32
 
@@ -37,26 +38,40 @@ def _parse_yolo_mode(value: str | None) -> bool:
37
38
  return (value or "false").lower() == "true"
38
39
 
39
40
 
40
- def _is_glm_mode() -> bool:
41
- """Check if GLM API is configured via environment variables."""
42
- base_url = os.getenv("ANTHROPIC_BASE_URL", "")
43
- # GLM mode is when ANTHROPIC_BASE_URL is set but NOT pointing to Ollama
44
- return bool(base_url) and not _is_ollama_mode()
45
-
46
-
47
- def _is_ollama_mode() -> bool:
48
- """Check if Ollama API is configured via environment variables."""
49
- base_url = os.getenv("ANTHROPIC_BASE_URL", "")
50
- return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
41
+ @router.get("/providers", response_model=ProvidersResponse)
42
+ async def get_available_providers():
43
+ """Get list of available API providers."""
44
+ current = get_setting("api_provider", "claude") or "claude"
45
+ providers = []
46
+ for pid, pdata in API_PROVIDERS.items():
47
+ providers.append(ProviderInfo(
48
+ id=pid,
49
+ name=pdata["name"],
50
+ base_url=pdata.get("base_url"),
51
+ models=[ModelInfo(id=m["id"], name=m["name"]) for m in pdata.get("models", [])],
52
+ default_model=pdata.get("default_model", ""),
53
+ requires_auth=pdata.get("requires_auth", False),
54
+ ))
55
+ return ProvidersResponse(providers=providers, current=current)
51
56
 
52
57
 
53
58
  @router.get("/models", response_model=ModelsResponse)
54
59
  async def get_available_models():
55
60
  """Get list of available models.
56
61
 
57
- Frontend should call this to get the current list of models
58
- instead of hardcoding them.
62
+ Returns models for the currently selected API provider.
59
63
  """
64
+ current_provider = get_setting("api_provider", "claude") or "claude"
65
+ provider = API_PROVIDERS.get(current_provider)
66
+
67
+ if provider and current_provider != "claude":
68
+ provider_models = provider.get("models", [])
69
+ return ModelsResponse(
70
+ models=[ModelInfo(id=m["id"], name=m["name"]) for m in provider_models],
71
+ default=provider.get("default_model", ""),
72
+ )
73
+
74
+ # Default: return Claude models
60
75
  return ModelsResponse(
61
76
  models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
62
77
  default=DEFAULT_MODEL,
@@ -85,14 +100,23 @@ async def get_settings():
85
100
  """Get current global settings."""
86
101
  all_settings = get_all_settings()
87
102
 
103
+ api_provider = all_settings.get("api_provider", "claude")
104
+
105
+ glm_mode = api_provider == "glm"
106
+ ollama_mode = api_provider == "ollama"
107
+
88
108
  return SettingsResponse(
89
109
  yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
90
110
  model=all_settings.get("model", DEFAULT_MODEL),
91
- glm_mode=_is_glm_mode(),
92
- ollama_mode=_is_ollama_mode(),
111
+ glm_mode=glm_mode,
112
+ ollama_mode=ollama_mode,
93
113
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
94
114
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
95
115
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
116
+ api_provider=api_provider,
117
+ api_base_url=all_settings.get("api_base_url"),
118
+ api_has_auth_token=bool(all_settings.get("api_auth_token")),
119
+ api_model=all_settings.get("api_model"),
96
120
  )
97
121
 
98
122
 
@@ -114,14 +138,47 @@ async def update_settings(update: SettingsUpdate):
114
138
  if update.batch_size is not None:
115
139
  set_setting("batch_size", str(update.batch_size))
116
140
 
141
+ # API provider settings
142
+ if update.api_provider is not None:
143
+ old_provider = get_setting("api_provider", "claude")
144
+ set_setting("api_provider", update.api_provider)
145
+
146
+ # When provider changes, auto-set defaults for the new provider
147
+ if update.api_provider != old_provider:
148
+ provider = API_PROVIDERS.get(update.api_provider)
149
+ if provider:
150
+ # Auto-set base URL from provider definition
151
+ if provider.get("base_url"):
152
+ set_setting("api_base_url", provider["base_url"])
153
+ # Auto-set model to provider's default
154
+ if provider.get("default_model") and update.api_model is None:
155
+ set_setting("api_model", provider["default_model"])
156
+
157
+ if update.api_base_url is not None:
158
+ set_setting("api_base_url", update.api_base_url)
159
+
160
+ if update.api_auth_token is not None:
161
+ set_setting("api_auth_token", update.api_auth_token)
162
+
163
+ if update.api_model is not None:
164
+ set_setting("api_model", update.api_model)
165
+
117
166
  # Return updated settings
118
167
  all_settings = get_all_settings()
168
+ api_provider = all_settings.get("api_provider", "claude")
169
+ glm_mode = api_provider == "glm"
170
+ ollama_mode = api_provider == "ollama"
171
+
119
172
  return SettingsResponse(
120
173
  yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
121
174
  model=all_settings.get("model", DEFAULT_MODEL),
122
- glm_mode=_is_glm_mode(),
123
- ollama_mode=_is_ollama_mode(),
175
+ glm_mode=glm_mode,
176
+ ollama_mode=ollama_mode,
124
177
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
125
178
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
126
179
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
180
+ api_provider=api_provider,
181
+ api_base_url=all_settings.get("api_base_url"),
182
+ api_has_auth_token=bool(all_settings.get("api_auth_token")),
183
+ api_model=all_settings.get("api_model"),
127
184
  )
@@ -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: