mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 02:43:09 +00:00
feat: add API provider selection UI and fix stuck features on agent crash
API Provider Selection: - Add provider switcher in Settings modal (Claude, Kimi, GLM, Ollama, Custom) - Auth tokens stored locally only (registry.db), never returned by API - get_effective_sdk_env() builds provider-specific env vars for agent subprocess - All chat sessions (spec, expand, assistant) use provider settings - Backward compatible: defaults to Claude, env vars still work as override Fix Stuck Features: - Add _cleanup_stale_features() to process_manager.py - Reset in_progress features when agent stops, crashes, or fails healthcheck - Prevents features from being permanently stuck after rate limit crashes - Uses separate SQLAlchemy engine to avoid session conflicts with subprocess Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -34,6 +34,22 @@
|
|||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Alternative API Providers
|
||||||
|
# ===================
|
||||||
|
# NOTE: These env vars are the legacy way to configure providers.
|
||||||
|
# The recommended way is to use the Settings UI (API Provider section).
|
||||||
|
# UI settings take precedence when api_provider != "claude".
|
||||||
|
|
||||||
|
# Kimi K2.5 (Moonshot) Configuration (Optional)
|
||||||
|
# Get an API key at: https://kimi.com
|
||||||
|
#
|
||||||
|
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
|
||||||
|
# ANTHROPIC_API_KEY=your-kimi-api-key
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.5
|
||||||
|
|
||||||
# GLM/Alternative API Configuration (Optional)
|
# GLM/Alternative API Configuration (Optional)
|
||||||
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
|
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
|
||||||
# This only affects AutoForge - your global Claude Code settings remain unchanged.
|
# This only affects AutoForge - your global Claude Code settings remain unchanged.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ API_ENV_VARS: list[str] = [
|
|||||||
# Core API configuration
|
# Core API configuration
|
||||||
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
||||||
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
||||||
|
"ANTHROPIC_API_KEY", # API key (used by Kimi and other providers)
|
||||||
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
||||||
# Model tier overrides
|
# Model tier overrides
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
||||||
|
|||||||
117
registry.py
117
registry.py
@@ -612,3 +612,120 @@ def get_all_settings() -> dict[str, str]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to read settings: %s", e)
|
logger.warning("Failed to read settings: %s", e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Provider Definitions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
API_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||||
|
"claude": {
|
||||||
|
"name": "Claude (Anthropic)",
|
||||||
|
"base_url": None,
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
||||||
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
||||||
|
],
|
||||||
|
"default_model": "claude-opus-4-5-20251101",
|
||||||
|
},
|
||||||
|
"kimi": {
|
||||||
|
"name": "Kimi K2.5 (Moonshot)",
|
||||||
|
"base_url": "https://api.kimi.com/coding/",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_API_KEY",
|
||||||
|
"models": [{"id": "kimi-k2.5", "name": "Kimi K2.5"}],
|
||||||
|
"default_model": "kimi-k2.5",
|
||||||
|
},
|
||||||
|
"glm": {
|
||||||
|
"name": "GLM (Zhipu AI)",
|
||||||
|
"base_url": "https://api.z.ai/api/anthropic",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [
|
||||||
|
{"id": "glm-4.7", "name": "GLM 4.7"},
|
||||||
|
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
||||||
|
],
|
||||||
|
"default_model": "glm-4.7",
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"name": "Ollama (Local)",
|
||||||
|
"base_url": "http://localhost:11434",
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "qwen3-coder", "name": "Qwen3 Coder"},
|
||||||
|
{"id": "deepseek-coder-v2", "name": "DeepSeek Coder V2"},
|
||||||
|
],
|
||||||
|
"default_model": "qwen3-coder",
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"name": "Custom Provider",
|
||||||
|
"base_url": "",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [],
|
||||||
|
"default_model": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_sdk_env() -> dict[str, str]:
|
||||||
|
"""Build environment variable dict for Claude SDK based on current API provider settings.
|
||||||
|
|
||||||
|
When api_provider is "claude" (or unset), falls back to existing env vars (current behavior).
|
||||||
|
For other providers, builds env dict from stored settings (api_base_url, api_auth_token, api_model).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ready to merge into subprocess env or pass to SDK.
|
||||||
|
"""
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
provider_id = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
if provider_id == "claude":
|
||||||
|
# Default behavior: forward existing env vars
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env: dict[str, str] = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
# Alternative provider: build env from settings
|
||||||
|
provider = API_PROVIDERS.get(provider_id)
|
||||||
|
if not provider:
|
||||||
|
logger.warning("Unknown API provider '%s', falling back to claude", provider_id)
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
sdk_env = {}
|
||||||
|
|
||||||
|
# Base URL
|
||||||
|
base_url = all_settings.get("api_base_url") or provider.get("base_url")
|
||||||
|
if base_url:
|
||||||
|
sdk_env["ANTHROPIC_BASE_URL"] = base_url
|
||||||
|
|
||||||
|
# Auth token
|
||||||
|
auth_token = all_settings.get("api_auth_token")
|
||||||
|
if auth_token:
|
||||||
|
auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN")
|
||||||
|
sdk_env[auth_env_var] = auth_token
|
||||||
|
|
||||||
|
# Model - set all three tier overrides to the same model
|
||||||
|
model = all_settings.get("api_model") or provider.get("default_model")
|
||||||
|
if model:
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
timeout = all_settings.get("api_timeout_ms")
|
||||||
|
if timeout:
|
||||||
|
sdk_env["API_TIMEOUT_MS"] = timeout
|
||||||
|
|
||||||
|
return sdk_env
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate
|
||||||
from ..services.chat_constants import ROOT_DIR
|
from ..services.chat_constants import ROOT_DIR
|
||||||
|
|
||||||
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
||||||
@@ -23,9 +23,11 @@ if str(ROOT_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(ROOT_DIR))
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
from registry import (
|
from registry import (
|
||||||
|
API_PROVIDERS,
|
||||||
AVAILABLE_MODELS,
|
AVAILABLE_MODELS,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
get_all_settings,
|
get_all_settings,
|
||||||
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,13 +52,40 @@ def _is_ollama_mode() -> bool:
|
|||||||
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=ProvidersResponse)
|
||||||
|
async def get_available_providers():
|
||||||
|
"""Get list of available API providers."""
|
||||||
|
current = get_setting("api_provider", "claude") or "claude"
|
||||||
|
providers = []
|
||||||
|
for pid, pdata in API_PROVIDERS.items():
|
||||||
|
providers.append(ProviderInfo(
|
||||||
|
id=pid,
|
||||||
|
name=pdata["name"],
|
||||||
|
base_url=pdata.get("base_url"),
|
||||||
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in pdata.get("models", [])],
|
||||||
|
default_model=pdata.get("default_model", ""),
|
||||||
|
requires_auth=pdata.get("requires_auth", False),
|
||||||
|
))
|
||||||
|
return ProvidersResponse(providers=providers, current=current)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=ModelsResponse)
|
@router.get("/models", response_model=ModelsResponse)
|
||||||
async def get_available_models():
|
async def get_available_models():
|
||||||
"""Get list of available models.
|
"""Get list of available models.
|
||||||
|
|
||||||
Frontend should call this to get the current list of models
|
Returns models for the currently selected API provider.
|
||||||
instead of hardcoding them.
|
|
||||||
"""
|
"""
|
||||||
|
current_provider = get_setting("api_provider", "claude") or "claude"
|
||||||
|
provider = API_PROVIDERS.get(current_provider)
|
||||||
|
|
||||||
|
if provider and current_provider != "claude":
|
||||||
|
provider_models = provider.get("models", [])
|
||||||
|
return ModelsResponse(
|
||||||
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in provider_models],
|
||||||
|
default=provider.get("default_model", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: return Claude models
|
||||||
return ModelsResponse(
|
return ModelsResponse(
|
||||||
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
||||||
default=DEFAULT_MODEL,
|
default=DEFAULT_MODEL,
|
||||||
@@ -85,14 +114,24 @@ async def get_settings():
|
|||||||
"""Get current global settings."""
|
"""Get current global settings."""
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
# Compute glm_mode / ollama_mode from api_provider for backward compat
|
||||||
|
glm_mode = api_provider == "glm" or _is_glm_mode()
|
||||||
|
ollama_mode = api_provider == "ollama" or _is_ollama_mode()
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,14 +153,47 @@ async def update_settings(update: SettingsUpdate):
|
|||||||
if update.batch_size is not None:
|
if update.batch_size is not None:
|
||||||
set_setting("batch_size", str(update.batch_size))
|
set_setting("batch_size", str(update.batch_size))
|
||||||
|
|
||||||
|
# API provider settings
|
||||||
|
if update.api_provider is not None:
|
||||||
|
old_provider = get_setting("api_provider", "claude")
|
||||||
|
set_setting("api_provider", update.api_provider)
|
||||||
|
|
||||||
|
# When provider changes, auto-set defaults for the new provider
|
||||||
|
if update.api_provider != old_provider:
|
||||||
|
provider = API_PROVIDERS.get(update.api_provider)
|
||||||
|
if provider:
|
||||||
|
# Auto-set base URL from provider definition
|
||||||
|
if provider.get("base_url"):
|
||||||
|
set_setting("api_base_url", provider["base_url"])
|
||||||
|
# Auto-set model to provider's default
|
||||||
|
if provider.get("default_model") and update.api_model is None:
|
||||||
|
set_setting("api_model", provider["default_model"])
|
||||||
|
|
||||||
|
if update.api_base_url is not None:
|
||||||
|
set_setting("api_base_url", update.api_base_url)
|
||||||
|
|
||||||
|
if update.api_auth_token is not None:
|
||||||
|
set_setting("api_auth_token", update.api_auth_token)
|
||||||
|
|
||||||
|
if update.api_model is not None:
|
||||||
|
set_setting("api_model", update.api_model)
|
||||||
|
|
||||||
# Return updated settings
|
# Return updated settings
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
glm_mode = api_provider == "glm" or _is_glm_mode()
|
||||||
|
ollama_mode = api_provider == "ollama" or _is_ollama_mode()
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -391,6 +391,22 @@ class ModelInfo(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
"""Information about an API provider."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
base_url: str | None = None
|
||||||
|
models: list[ModelInfo]
|
||||||
|
default_model: str
|
||||||
|
requires_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersResponse(BaseModel):
|
||||||
|
"""Response schema for available providers list."""
|
||||||
|
providers: list[ProviderInfo]
|
||||||
|
current: str
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
"""Response schema for global settings."""
|
"""Response schema for global settings."""
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
@@ -400,6 +416,10 @@ class SettingsResponse(BaseModel):
|
|||||||
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
||||||
playwright_headless: bool = True
|
playwright_headless: bool = True
|
||||||
batch_size: int = 3 # Features per coding agent batch (1-3)
|
batch_size: int = 3 # Features per coding agent batch (1-3)
|
||||||
|
api_provider: str = "claude"
|
||||||
|
api_base_url: str | None = None
|
||||||
|
api_has_auth_token: bool = False # Never expose actual token
|
||||||
|
api_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModelsResponse(BaseModel):
|
class ModelsResponse(BaseModel):
|
||||||
@@ -415,12 +435,21 @@ class SettingsUpdate(BaseModel):
|
|||||||
testing_agent_ratio: int | None = None # 0-3
|
testing_agent_ratio: int | None = None # 0-3
|
||||||
playwright_headless: bool | None = None
|
playwright_headless: bool | None = None
|
||||||
batch_size: int | None = None # Features per agent batch (1-3)
|
batch_size: int | None = None # Features per agent batch (1-3)
|
||||||
|
api_provider: str | None = None
|
||||||
|
api_base_url: str | None = None
|
||||||
|
api_auth_token: str | None = None # Write-only, never returned
|
||||||
|
api_model: str | None = None
|
||||||
|
|
||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
# Skip VALID_MODELS check when using an alternative API provider
|
||||||
|
api_provider = info.data.get("api_provider")
|
||||||
|
if api_provider and api_provider != "claude":
|
||||||
|
return v
|
||||||
|
if v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator('testing_agent_ratio')
|
@field_validator('testing_agent_ratio')
|
||||||
|
|||||||
@@ -258,15 +258,11 @@ class AssistantChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
sdk_env: dict[str, str] = {}
|
from registry import get_effective_sdk_env
|
||||||
for var in API_ENV_VARS:
|
sdk_env = get_effective_sdk_env()
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Creating ClaudeSDKClient...")
|
logger.info("Creating ClaudeSDKClient...")
|
||||||
|
|||||||
@@ -154,16 +154,11 @@ class ExpandChatSession:
|
|||||||
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
# Build MCP servers config for feature creation
|
# Build MCP servers config for feature creation
|
||||||
mcp_servers = {
|
mcp_servers = {
|
||||||
|
|||||||
@@ -227,6 +227,46 @@ class AgentProcessManager:
|
|||||||
"""Remove lock file."""
|
"""Remove lock file."""
|
||||||
self.lock_file.unlink(missing_ok=True)
|
self.lock_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _cleanup_stale_features(self) -> None:
|
||||||
|
"""Clear in_progress flag for all features when agent stops/crashes.
|
||||||
|
|
||||||
|
When the agent process exits (normally or crash), any features left
|
||||||
|
with in_progress=True were being worked on and didn't complete.
|
||||||
|
Reset them so they can be picked up on next agent start.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_features_db_path
|
||||||
|
features_db = get_features_db_path(self.project_dir)
|
||||||
|
if not features_db.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from api.database import Feature
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{features_db}")
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
stuck = session.query(Feature).filter(
|
||||||
|
Feature.in_progress == True, # noqa: E712
|
||||||
|
Feature.passes == False, # noqa: E712
|
||||||
|
).all()
|
||||||
|
if stuck:
|
||||||
|
for f in stuck:
|
||||||
|
f.in_progress = False
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Cleaned up %d stuck feature(s) for %s",
|
||||||
|
len(stuck), self.project_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
|
||||||
|
|
||||||
async def _broadcast_output(self, line: str) -> None:
|
async def _broadcast_output(self, line: str) -> None:
|
||||||
"""Broadcast output line to all registered callbacks."""
|
"""Broadcast output line to all registered callbacks."""
|
||||||
with self._callbacks_lock:
|
with self._callbacks_lock:
|
||||||
@@ -288,6 +328,7 @@ class AgentProcessManager:
|
|||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
elif self.status == "running":
|
elif self.status == "running":
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
@@ -359,12 +400,22 @@ class AgentProcessManager:
|
|||||||
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
||||||
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
||||||
# PYTHONUNBUFFERED ensures output isn't delayed
|
# PYTHONUNBUFFERED ensures output isn't delayed
|
||||||
|
# Build subprocess environment with API provider settings
|
||||||
|
from registry import get_effective_sdk_env
|
||||||
|
api_env = get_effective_sdk_env()
|
||||||
|
subprocess_env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false",
|
||||||
|
**api_env,
|
||||||
|
}
|
||||||
|
|
||||||
popen_kwargs: dict[str, Any] = {
|
popen_kwargs: dict[str, Any] = {
|
||||||
"stdin": subprocess.DEVNULL,
|
"stdin": subprocess.DEVNULL,
|
||||||
"stdout": subprocess.PIPE,
|
"stdout": subprocess.PIPE,
|
||||||
"stderr": subprocess.STDOUT,
|
"stderr": subprocess.STDOUT,
|
||||||
"cwd": str(self.project_dir),
|
"cwd": str(self.project_dir),
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
|
"env": subprocess_env,
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -425,6 +476,7 @@ class AgentProcessManager:
|
|||||||
result.children_terminated, result.children_killed
|
result.children_terminated, result.children_killed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self.process = None
|
self.process = None
|
||||||
@@ -502,6 +554,7 @@ class AgentProcessManager:
|
|||||||
if poll is not None:
|
if poll is not None:
|
||||||
# Process has terminated
|
# Process has terminated
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused"):
|
||||||
|
self._cleanup_stale_features()
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -140,16 +140,11 @@ class SpecChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.client = ClaudeSDKClient(
|
self.client = ClaudeSDKClient(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
|
import { Loader2, AlertCircle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
||||||
|
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects'
|
||||||
import { useTheme, THEMES } from '../hooks/useTheme'
|
import { useTheme, THEMES } from '../hooks/useTheme'
|
||||||
|
import type { ProviderInfo } from '../lib/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,12 +19,26 @@ interface SettingsModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROVIDER_INFO_TEXT: Record<string, string> = {
|
||||||
|
claude: 'Default provider. Uses your Claude CLI credentials.',
|
||||||
|
kimi: 'Get an API key at kimi.com',
|
||||||
|
glm: 'Get an API key at open.bigmodel.cn',
|
||||||
|
ollama: 'Run models locally. Install from ollama.com',
|
||||||
|
custom: 'Connect to any OpenAI-compatible API endpoint.',
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
const { data: settings, isLoading, isError, refetch } = useSettings()
|
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||||
const { data: modelsData } = useAvailableModels()
|
const { data: modelsData } = useAvailableModels()
|
||||||
|
const { data: providersData } = useAvailableProviders()
|
||||||
const updateSettings = useUpdateSettings()
|
const updateSettings = useUpdateSettings()
|
||||||
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
||||||
|
|
||||||
|
const [showAuthToken, setShowAuthToken] = useState(false)
|
||||||
|
const [authTokenInput, setAuthTokenInput] = useState('')
|
||||||
|
const [customModelInput, setCustomModelInput] = useState('')
|
||||||
|
const [customBaseUrlInput, setCustomBaseUrlInput] = useState('')
|
||||||
|
|
||||||
const handleYoloToggle = () => {
|
const handleYoloToggle = () => {
|
||||||
if (settings && !updateSettings.isPending) {
|
if (settings && !updateSettings.isPending) {
|
||||||
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
||||||
@@ -31,7 +47,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
const handleModelChange = (modelId: string) => {
|
const handleModelChange = (modelId: string) => {
|
||||||
if (!updateSettings.isPending) {
|
if (!updateSettings.isPending) {
|
||||||
updateSettings.mutate({ model: modelId })
|
updateSettings.mutate({ api_model: modelId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +63,51 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleProviderChange = (providerId: string) => {
|
||||||
|
if (!updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_provider: providerId })
|
||||||
|
// Reset local state
|
||||||
|
setAuthTokenInput('')
|
||||||
|
setShowAuthToken(false)
|
||||||
|
setCustomModelInput('')
|
||||||
|
setCustomBaseUrlInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAuthToken = () => {
|
||||||
|
if (authTokenInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_auth_token: authTokenInput.trim() })
|
||||||
|
setAuthTokenInput('')
|
||||||
|
setShowAuthToken(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCustomBaseUrl = () => {
|
||||||
|
if (customBaseUrlInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_base_url: customBaseUrlInput.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCustomModel = () => {
|
||||||
|
if (customModelInput.trim() && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ api_model: customModelInput.trim() })
|
||||||
|
setCustomModelInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = providersData?.providers ?? []
|
||||||
const models = modelsData?.models ?? []
|
const models = modelsData?.models ?? []
|
||||||
const isSaving = updateSettings.isPending
|
const isSaving = updateSettings.isPending
|
||||||
|
const currentProvider = settings?.api_provider ?? 'claude'
|
||||||
|
const currentProviderInfo: ProviderInfo | undefined = providers.find(p => p.id === currentProvider)
|
||||||
|
const isAlternativeProvider = currentProvider !== 'claude'
|
||||||
|
const showAuthField = isAlternativeProvider && currentProviderInfo?.requires_auth
|
||||||
|
const showBaseUrlField = currentProvider === 'custom'
|
||||||
|
const showCustomModelInput = currentProvider === 'custom' || currentProvider === 'ollama'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent aria-describedby={undefined} className="sm:max-w-sm max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
Settings
|
Settings
|
||||||
@@ -159,6 +214,146 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* API Provider Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="font-medium">API Provider</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
onClick={() => handleProviderChange(provider.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`py-1.5 px-3 text-sm font-medium rounded-md border transition-colors ${
|
||||||
|
currentProvider === provider.id
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background text-foreground border-border hover:bg-muted'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{provider.name.split(' (')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{PROVIDER_INFO_TEXT[currentProvider] ?? ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Auth Token Field */}
|
||||||
|
{showAuthField && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-sm">API Key</Label>
|
||||||
|
{settings.api_has_auth_token && !authTokenInput && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ShieldCheck size={14} className="text-green-500" />
|
||||||
|
<span>Configured</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-0.5 px-2 text-xs"
|
||||||
|
onClick={() => setAuthTokenInput(' ')}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!settings.api_has_auth_token || authTokenInput) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type={showAuthToken ? 'text' : 'password'}
|
||||||
|
value={authTokenInput.trim()}
|
||||||
|
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||||
|
placeholder="Enter API key..."
|
||||||
|
className="w-full py-1.5 px-3 pe-9 text-sm border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthToken(!showAuthToken)}
|
||||||
|
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showAuthToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveAuthToken}
|
||||||
|
disabled={!authTokenInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Base URL Field */}
|
||||||
|
{showBaseUrlField && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-sm">Base URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customBaseUrlInput || settings.api_base_url || ''}
|
||||||
|
onChange={(e) => setCustomBaseUrlInput(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveCustomBaseUrl}
|
||||||
|
disabled={!customBaseUrlInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">Model</Label>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="flex rounded-lg border overflow-hidden">
|
||||||
|
{models.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => handleModelChange(model.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
||||||
|
(settings.api_model ?? settings.model) === model.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background text-foreground hover:bg-muted'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Custom model input for Ollama/Custom */}
|
||||||
|
{showCustomModelInput && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customModelInput}
|
||||||
|
onChange={(e) => setCustomModelInput(e.target.value)}
|
||||||
|
placeholder="Custom model name..."
|
||||||
|
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveCustomModel()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveCustomModel}
|
||||||
|
disabled={!customModelInput.trim() || isSaving}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
{/* YOLO Mode Toggle */}
|
{/* YOLO Mode Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -195,27 +390,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="font-medium">Model</Label>
|
|
||||||
<div className="flex rounded-lg border overflow-hidden">
|
|
||||||
{models.map((model) => (
|
|
||||||
<button
|
|
||||||
key={model.id}
|
|
||||||
onClick={() => handleModelChange(model.id)}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
|
||||||
settings.model === model.id
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background text-foreground hover:bg-muted'
|
|
||||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
{model.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Regression Agents */}
|
{/* Regression Agents */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="font-medium">Regression Agents</Label>
|
<Label className="font-medium">Regression Agents</Label>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import * as api from '../lib/api'
|
import * as api from '../lib/api'
|
||||||
import type { FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, Settings, SettingsUpdate } from '../lib/types'
|
import type { FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, ProvidersResponse, Settings, SettingsUpdate } from '../lib/types'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Projects
|
// Projects
|
||||||
@@ -268,6 +268,27 @@ const DEFAULT_SETTINGS: Settings = {
|
|||||||
testing_agent_ratio: 1,
|
testing_agent_ratio: 1,
|
||||||
playwright_headless: true,
|
playwright_headless: true,
|
||||||
batch_size: 3,
|
batch_size: 3,
|
||||||
|
api_provider: 'claude',
|
||||||
|
api_base_url: null,
|
||||||
|
api_has_auth_token: false,
|
||||||
|
api_model: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDERS: ProvidersResponse = {
|
||||||
|
providers: [
|
||||||
|
{ id: 'claude', name: 'Claude (Anthropic)', base_url: null, models: DEFAULT_MODELS.models, default_model: 'claude-opus-4-5-20251101', requires_auth: false },
|
||||||
|
],
|
||||||
|
current: 'claude',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvailableProviders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['available-providers'],
|
||||||
|
queryFn: api.getAvailableProviders,
|
||||||
|
staleTime: 300000,
|
||||||
|
retry: 1,
|
||||||
|
placeholderData: DEFAULT_PROVIDERS,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAvailableModels() {
|
export function useAvailableModels() {
|
||||||
@@ -319,6 +340,8 @@ export function useUpdateSettings() {
|
|||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['available-models'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['available-providers'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
SettingsUpdate,
|
SettingsUpdate,
|
||||||
ModelsResponse,
|
ModelsResponse,
|
||||||
|
ProvidersResponse,
|
||||||
DevServerStatusResponse,
|
DevServerStatusResponse,
|
||||||
DevServerConfig,
|
DevServerConfig,
|
||||||
TerminalInfo,
|
TerminalInfo,
|
||||||
@@ -399,6 +400,10 @@ export async function getAvailableModels(): Promise<ModelsResponse> {
|
|||||||
return fetchJSON('/settings/models')
|
return fetchJSON('/settings/models')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAvailableProviders(): Promise<ProvidersResponse> {
|
||||||
|
return fetchJSON('/settings/providers')
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSettings(): Promise<Settings> {
|
export async function getSettings(): Promise<Settings> {
|
||||||
return fetchJSON('/settings')
|
return fetchJSON('/settings')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,6 +525,20 @@ export interface ModelsResponse {
|
|||||||
default: string
|
default: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
base_url: string | null
|
||||||
|
models: ModelInfo[]
|
||||||
|
default_model: string
|
||||||
|
requires_auth: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvidersResponse {
|
||||||
|
providers: ProviderInfo[]
|
||||||
|
current: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
yolo_mode: boolean
|
yolo_mode: boolean
|
||||||
model: string
|
model: string
|
||||||
@@ -533,6 +547,10 @@ export interface Settings {
|
|||||||
testing_agent_ratio: number // Regression testing agents (0-3)
|
testing_agent_ratio: number // Regression testing agents (0-3)
|
||||||
playwright_headless: boolean
|
playwright_headless: boolean
|
||||||
batch_size: number // Features per coding agent batch (1-3)
|
batch_size: number // Features per coding agent batch (1-3)
|
||||||
|
api_provider: string
|
||||||
|
api_base_url: string | null
|
||||||
|
api_has_auth_token: boolean
|
||||||
|
api_model: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsUpdate {
|
export interface SettingsUpdate {
|
||||||
@@ -541,6 +559,10 @@ export interface SettingsUpdate {
|
|||||||
testing_agent_ratio?: number
|
testing_agent_ratio?: number
|
||||||
playwright_headless?: boolean
|
playwright_headless?: boolean
|
||||||
batch_size?: number
|
batch_size?: number
|
||||||
|
api_provider?: string
|
||||||
|
api_base_url?: string
|
||||||
|
api_auth_token?: string
|
||||||
|
api_model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectSettingsUpdate {
|
export interface ProjectSettingsUpdate {
|
||||||
|
|||||||
Reference in New Issue
Block a user