Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
# 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)
# 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.
Expand Down
Empty file modified bin/autoforge.js
100644 → 100755
Empty file.
1 change: 1 addition & 0 deletions env_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# Core API configuration
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
"ANTHROPIC_AUTH_TOKEN", # API authentication token
"ANTHROPIC_API_KEY", # API key (used by Kimi and other providers)
"API_TIMEOUT_MS", # Request timeout in milliseconds
# Model tier overrides
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
Expand Down
117 changes: 117 additions & 0 deletions registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,120 @@ def get_all_settings() -> dict[str, str]:
except Exception as e:
logger.warning("Failed to read settings: %s", e)
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
14 changes: 10 additions & 4 deletions server/routers/assistant_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
get_conversations,
)
from ..utils.project_helpers import get_project_path as _get_project_path
from ..utils.validation import is_valid_project_name as validate_project_name
from ..utils.validation import validate_project_name

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -217,20 +217,26 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
if not validate_project_name(project_name):
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()

try:
project_name = validate_project_name(project_name)
except HTTPException:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

await websocket.accept()
logger.info(f"Assistant WebSocket connected for project: {project_name}")

session: Optional[AssistantChatSession] = None
Expand Down
10 changes: 8 additions & 2 deletions server/routers/expand_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,31 +104,37 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
# Always accept the WebSocket first to avoid opaque 403 errors.
# Starlette returns 403 if we close before accepting.
await websocket.accept()

try:
project_name = validate_project_name(project_name)
except HTTPException:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

# Verify project has app_spec.txt
from autoforge_paths import get_prompts_dir
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
if not spec_path.exists():
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
return

await websocket.accept()

session: Optional[ExpandChatSession] = None

try:
Expand Down
86 changes: 79 additions & 7 deletions server/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

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

# Mimetype fix for Windows - must run before StaticFiles is mounted
Expand All @@ -23,9 +23,11 @@
sys.path.insert(0, str(ROOT_DIR))

from registry import (
API_PROVIDERS,
AVAILABLE_MODELS,
DEFAULT_MODEL,
get_all_settings,
get_setting,
set_setting,
)

Expand All @@ -50,13 +52,40 @@ def _is_ollama_mode() -> bool:
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)
async def get_available_models():
"""Get list of available models.

Frontend should call this to get the current list of models
instead of hardcoding them.
Returns models for the currently selected API provider.
"""
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(
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
default=DEFAULT_MODEL,
Expand Down Expand Up @@ -85,14 +114,24 @@ async def get_settings():
"""Get current global 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(
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
glm_mode=_is_glm_mode(),
ollama_mode=_is_ollama_mode(),
glm_mode=glm_mode,
ollama_mode=ollama_mode,
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
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"),
)


Expand All @@ -114,14 +153,47 @@ async def update_settings(update: SettingsUpdate):
if update.batch_size is not None:
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
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(
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
glm_mode=_is_glm_mode(),
ollama_mode=_is_ollama_mode(),
glm_mode=glm_mode,
ollama_mode=ollama_mode,
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
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"),
)
Loading
Loading