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 +7 -35
- package/README.md +7 -31
- package/client.py +4 -3
- package/env_constants.py +1 -0
- package/package.json +1 -1
- package/registry.py +149 -4
- 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 +76 -19
- package/server/routers/spec_creation.py +13 -7
- package/server/routers/terminal.py +14 -8
- package/server/schemas.py +43 -5
- package/server/services/assistant_chat_session.py +7 -11
- package/server/services/expand_chat_session.py +6 -11
- package/server/services/process_manager.py +58 -2
- package/server/services/spec_chat_session.py +6 -11
- package/server/websocket.py +8 -5
- package/ui/dist/assets/index-CCu7z6o1.css +1 -0
- package/ui/dist/assets/index-DOPvjpbF.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
|
@@ -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-
|
|
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-
|
|
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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
[](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
|
|
|
@@ -326,37 +326,13 @@ When test progress increases, the agent sends:
|
|
|
326
326
|
}
|
|
327
327
|
```
|
|
328
328
|
|
|
329
|
-
###
|
|
329
|
+
### Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
|
330
330
|
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
50
|
-
while the Anthropic API uses - (e.g., claude-
|
|
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-
|
|
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
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-
|
|
50
|
-
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet
|
|
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-
|
|
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
|
-
|
|
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
|
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:
|
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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=
|
|
92
|
-
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=
|
|
123
|
-
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
|
|
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:
|