From 4205942e07f0411178472c7f9f6907599226204f Mon Sep 17 00:00:00 2001 From: nioasoft Date: Fri, 6 Feb 2026 09:51:07 +0200 Subject: [PATCH 1/5] Add OpenRouter as API provider option Enables access to 200+ models via OpenRouter's unified API, including Claude, Gemini, GPT-4o, and DeepSeek variants. Co-Authored-By: Claude Opus 4.6 --- registry.py | 14 ++++++++++++++ ui/src/components/SettingsModal.tsx | 1 + 2 files changed, 15 insertions(+) diff --git a/registry.py b/registry.py index 30765198..ec91d4b1 100644 --- a/registry.py +++ b/registry.py @@ -686,6 +686,20 @@ def get_all_settings() -> dict[str, str]: ], "default_model": "qwen3-coder", }, + "openrouter": { + "name": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + "requires_auth": True, + "auth_env_var": "ANTHROPIC_API_KEY", + "models": [ + {"id": "anthropic/claude-sonnet-4-5", "name": "Claude Sonnet 4.5"}, + {"id": "anthropic/claude-opus-4", "name": "Claude Opus 4"}, + {"id": "google/gemini-2.5-pro", "name": "Gemini 2.5 Pro"}, + {"id": "openai/gpt-4o", "name": "GPT-4o"}, + {"id": "deepseek/deepseek-chat-v3-0324", "name": "DeepSeek V3"}, + ], + "default_model": "anthropic/claude-sonnet-4-5", + }, "custom": { "name": "Custom Provider", "base_url": "", diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 0a2b9eec..4a624327 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -24,6 +24,7 @@ const PROVIDER_INFO_TEXT: Record = { 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', + openrouter: 'Access 200+ models. Get an API key at openrouter.ai', custom: 'Connect to any OpenAI-compatible API endpoint.', } From 8625eba15f5ed9d1536d6c377b34579de54dba09 Mon Sep 17 00:00:00 2001 From: nioasoft Date: Fri, 6 Feb 2026 10:15:09 +0200 Subject: [PATCH 2/5] Update default model to Opus 4.6 and refresh OpenRouter model list - Add claude-opus-4-6 as default model for Claude provider - Update OpenRouter models: add Gemini 2.5 Flash, GPT-4.1, o3 - Replace deprecated GPT-4o with GPT-4.1 Co-Authored-By: Claude Opus 4.6 --- registry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/registry.py b/registry.py index ec91d4b1..78535302 100644 --- a/registry.py +++ b/registry.py @@ -693,9 +693,11 @@ def get_all_settings() -> dict[str, str]: "auth_env_var": "ANTHROPIC_API_KEY", "models": [ {"id": "anthropic/claude-sonnet-4-5", "name": "Claude Sonnet 4.5"}, - {"id": "anthropic/claude-opus-4", "name": "Claude Opus 4"}, + {"id": "anthropic/claude-opus-4.6", "name": "Claude Opus 4.6"}, {"id": "google/gemini-2.5-pro", "name": "Gemini 2.5 Pro"}, - {"id": "openai/gpt-4o", "name": "GPT-4o"}, + {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash"}, + {"id": "openai/gpt-4.1", "name": "GPT-4.1"}, + {"id": "openai/o3", "name": "OpenAI o3"}, {"id": "deepseek/deepseek-chat-v3-0324", "name": "DeepSeek V3"}, ], "default_model": "anthropic/claude-sonnet-4-5", From 955cb855c81bf5950ecc8dd559840df48c5bb591 Mon Sep 17 00:00:00 2001 From: nioasoft Date: Fri, 6 Feb 2026 10:17:10 +0200 Subject: [PATCH 3/5] Update OpenRouter models to latest (GPT-5.2, Gemini 2.5) Replace outdated GPT-4.1/o3 with GPT-5.2 and GPT-5 Mini, reorder models with Opus 4.6 first. Co-Authored-By: Claude Opus 4.6 --- registry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registry.py b/registry.py index 78535302..a58329ee 100644 --- a/registry.py +++ b/registry.py @@ -692,12 +692,12 @@ def get_all_settings() -> dict[str, str]: "requires_auth": True, "auth_env_var": "ANTHROPIC_API_KEY", "models": [ - {"id": "anthropic/claude-sonnet-4-5", "name": "Claude Sonnet 4.5"}, {"id": "anthropic/claude-opus-4.6", "name": "Claude Opus 4.6"}, + {"id": "anthropic/claude-sonnet-4-5", "name": "Claude Sonnet 4.5"}, + {"id": "openai/gpt-5.2-20251211", "name": "GPT-5.2"}, + {"id": "openai/gpt-5-mini-2025-08-07", "name": "GPT-5 Mini"}, {"id": "google/gemini-2.5-pro", "name": "Gemini 2.5 Pro"}, {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash"}, - {"id": "openai/gpt-4.1", "name": "GPT-4.1"}, - {"id": "openai/o3", "name": "OpenAI o3"}, {"id": "deepseek/deepseek-chat-v3-0324", "name": "DeepSeek V3"}, ], "default_model": "anthropic/claude-sonnet-4-5", From e2abb3d30c01efdb889a91e061a2a2b3b17ef232 Mon Sep 17 00:00:00 2001 From: nioasoft Date: Fri, 6 Feb 2026 11:49:13 +0200 Subject: [PATCH 4/5] Per-provider API keys, test connection, clickable badges, UI fixes - Store auth tokens per-provider (api_auth_token.{provider}) with global fallback - Add POST /api/settings/test-connection endpoint (Claude CLI check + HTTP provider test) - Provider badge in header now clickable to open settings - Show badge for all providers including Claude (orange) - Fix model selection: dropdown for 4+ models, grid for 2-3 - Fix provider button grid layout (3-col grid) - Update GLM endpoint to coding API (api.z.ai/api/coding/paas/v4) - Update GLM info text Co-Authored-By: Claude Opus 4.6 --- registry.py | 6 +- server/routers/settings.py | 156 ++++++++++++++++++++++------ ui/src/App.tsx | 53 ++++++---- ui/src/components/SettingsModal.tsx | 84 ++++++++++++--- ui/src/lib/api.ts | 9 ++ 5 files changed, 242 insertions(+), 66 deletions(-) diff --git a/registry.py b/registry.py index a58329ee..11166c99 100644 --- a/registry.py +++ b/registry.py @@ -667,7 +667,7 @@ def get_all_settings() -> dict[str, str]: }, "glm": { "name": "GLM (Zhipu AI)", - "base_url": "https://api.z.ai/api/anthropic", + "base_url": "https://api.z.ai/api/coding/paas/v4", "requires_auth": True, "auth_env_var": "ANTHROPIC_AUTH_TOKEN", "models": [ @@ -769,8 +769,8 @@ def get_effective_sdk_env() -> dict[str, str]: if base_url: sdk_env["ANTHROPIC_BASE_URL"] = base_url - # Auth token - auth_token = all_settings.get("api_auth_token") + # Auth token - per-provider key first, then global fallback + auth_token = all_settings.get(f"api_auth_token.{provider_id}") or all_settings.get("api_auth_token") if auth_token: sdk_env[auth_env_var] = auth_token diff --git a/server/routers/settings.py b/server/routers/settings.py index 6137c63c..0323dc39 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -6,12 +6,19 @@ Settings are stored in the registry database and shared across all projects. """ +import asyncio +import logging import mimetypes +import shutil import sys +import httpx from fastapi import APIRouter +from pydantic import BaseModel from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate + +logger = logging.getLogger(__name__) from ..services.chat_constants import ROOT_DIR # Mimetype fix for Windows - must run before StaticFiles is mounted @@ -95,31 +102,30 @@ def _parse_bool(value: str | None, default: bool = False) -> bool: return value.lower() == "true" -@router.get("", response_model=SettingsResponse) -async def get_settings(): - """Get current global settings.""" - all_settings = get_all_settings() - +def _build_settings_response(all_settings: dict[str, str]) -> SettingsResponse: + """Build SettingsResponse from settings dict (shared by GET and PATCH).""" api_provider = all_settings.get("api_provider", "claude") - - glm_mode = api_provider == "glm" - ollama_mode = api_provider == "ollama" - return SettingsResponse( yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), model=all_settings.get("model", DEFAULT_MODEL), - glm_mode=glm_mode, - ollama_mode=ollama_mode, + glm_mode=api_provider == "glm", + ollama_mode=api_provider == "ollama", 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_has_auth_token=bool(all_settings.get(f"api_auth_token.{api_provider}") or all_settings.get("api_auth_token")), api_model=all_settings.get("api_model"), ) +@router.get("", response_model=SettingsResponse) +async def get_settings(): + """Get current global settings.""" + return _build_settings_response(get_all_settings()) + + @router.patch("", response_model=SettingsResponse) async def update_settings(update: SettingsUpdate): """Update global settings.""" @@ -158,27 +164,119 @@ async def update_settings(update: SettingsUpdate): set_setting("api_base_url", update.api_base_url) if update.api_auth_token is not None: + current_provider = get_setting("api_provider", "claude") + set_setting(f"api_auth_token.{current_provider}", update.api_auth_token) 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 _build_settings_response(get_all_settings()) + + +# ============================================================================= +# Test Connection +# ============================================================================= + + +class TestConnectionResponse(BaseModel): + success: bool + message: str + + +@router.post("/test-connection", response_model=TestConnectionResponse) +async def test_provider_connection(): + """Test connectivity to the current API provider.""" all_settings = get_all_settings() - api_provider = all_settings.get("api_provider", "claude") - glm_mode = api_provider == "glm" - ollama_mode = api_provider == "ollama" + provider_id = all_settings.get("api_provider", "claude") - return SettingsResponse( - yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")), - model=all_settings.get("model", DEFAULT_MODEL), - 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"), - ) + if provider_id == "claude": + return await _test_claude() + + provider = API_PROVIDERS.get(provider_id) + if not provider: + return TestConnectionResponse(success=False, message=f"Unknown provider: {provider_id}") + + base_url = all_settings.get("api_base_url") or provider.get("base_url") + if not base_url: + return TestConnectionResponse(success=False, message="No base URL configured") + + auth_token = all_settings.get(f"api_auth_token.{provider_id}") or all_settings.get("api_auth_token") + if provider.get("requires_auth") and not auth_token: + return TestConnectionResponse(success=False, message="No API key configured") + + return await _test_http_provider(provider_id, base_url, auth_token, provider) + + +async def _test_claude() -> TestConnectionResponse: + """Test Claude CLI availability.""" + claude_path = shutil.which("claude") + if not claude_path: + return TestConnectionResponse(success=False, message="Claude CLI not found in PATH") + try: + proc = await asyncio.create_subprocess_exec( + claude_path, "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + version = stdout.decode().strip() if stdout else "unknown" + return TestConnectionResponse(success=True, message=f"Claude CLI: {version}") + except asyncio.TimeoutError: + return TestConnectionResponse(success=False, message="Claude CLI timed out") + except Exception as e: + return TestConnectionResponse(success=False, message=f"Claude CLI error: {e}") + + +async def _test_http_provider( + provider_id: str, base_url: str, auth_token: str | None, provider: dict, # type: ignore[type-arg] +) -> TestConnectionResponse: + """Test an HTTP-based API provider by sending a minimal messages request.""" + base = base_url.rstrip("/") + # Try multiple paths - different providers use different API structures + candidate_paths = ["/v1/messages", "/messages", "/chat/completions", ""] + + auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN") + headers: dict[str, str] = {"content-type": "application/json", "anthropic-version": "2023-06-01"} + if auth_token: + if auth_env_var == "ANTHROPIC_API_KEY": + headers["x-api-key"] = auth_token + else: + headers["authorization"] = f"Bearer {auth_token}" + + model = provider.get("default_model") or "test" + body = {"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]} + + try: + async with httpx.AsyncClient(timeout=15) as client: + for path in candidate_paths: + url = base + path + resp = await client.post(url, headers=headers, json=body) + if resp.status_code == 404: + continue # Try next path + if resp.status_code == 200: + return TestConnectionResponse(success=True, message=f"Connected to {provider_id}") + if resp.status_code == 401: + return TestConnectionResponse(success=False, message="Authentication failed - check API key") + if resp.status_code == 403: + return TestConnectionResponse(success=False, message="Access denied - check API key permissions") + # Some providers return errors but still prove connectivity + try: + data = resp.json() + err_type = data.get("error", {}).get("type", "") + if err_type in ("invalid_request_error", "not_found_error", "overloaded_error"): + return TestConnectionResponse(success=True, message=f"Connected to {provider_id}") + except Exception: + pass + # Got a non-404 response, so connectivity works even if there's an error + return TestConnectionResponse(success=False, message=f"HTTP {resp.status_code}: {resp.text[:200]}") + + # All paths returned 404 + return TestConnectionResponse(success=False, message=f"No valid endpoint found at {base_url}") + except httpx.ConnectError: + return TestConnectionResponse(success=False, message=f"Cannot connect to {base_url}") + except httpx.TimeoutException: + return TestConnectionResponse(success=False, message="Connection timed out") + except Exception as e: + logger.warning("Test connection error for %s: %s", provider_id, e) + return TestConnectionResponse(success=False, message=f"Error: {e}") diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ef916f30..fb2880a0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -315,25 +315,40 @@ function App() { - {/* Ollama Mode Indicator */} - {settings?.ollama_mode && ( -
- Ollama - Ollama -
- )} - - {/* GLM Mode Badge */} - {settings?.glm_mode && ( - - GLM - + {/* API Provider Badge - click to open settings */} + {settings?.api_provider && ( + settings.api_provider === 'ollama' ? ( + + ) : ( + setShowSettings(true)} + > + {{ + claude: 'Claude', + glm: 'GLM', + kimi: 'Kimi', + openrouter: 'OpenRouter', + custom: 'Custom', + }[settings.api_provider] ?? settings.api_provider} + + ) )} )} diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 4a624327..b69e43ca 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { Loader2, AlertCircle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react' +import { Loader2, AlertCircle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck, Wifi, WifiOff } from 'lucide-react' import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects' +import { testProviderConnection } from '../lib/api' import { useTheme, THEMES } from '../hooks/useTheme' import type { ProviderInfo } from '../lib/types' import { @@ -22,7 +23,7 @@ interface SettingsModalProps { const PROVIDER_INFO_TEXT: Record = { 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', + glm: 'GLM Coding Plan. Get an API key at z.ai', ollama: 'Run models locally. Install from ollama.com', openrouter: 'Access 200+ models. Get an API key at openrouter.ai', custom: 'Connect to any OpenAI-compatible API endpoint.', @@ -39,6 +40,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const [authTokenInput, setAuthTokenInput] = useState('') const [customModelInput, setCustomModelInput] = useState('') const [customBaseUrlInput, setCustomBaseUrlInput] = useState('') + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [isTesting, setIsTesting] = useState(false) const handleYoloToggle = () => { if (settings && !updateSettings.isPending) { @@ -72,6 +75,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { setShowAuthToken(false) setCustomModelInput('') setCustomBaseUrlInput('') + setTestResult(null) } } @@ -96,6 +100,19 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } } + const handleTestConnection = async () => { + setIsTesting(true) + setTestResult(null) + try { + const result = await testProviderConnection() + setTestResult(result) + } catch { + setTestResult({ success: false, message: 'Request failed' }) + } finally { + setIsTesting(false) + } + } + const providers = providersData?.providers ?? [] const models = modelsData?.models ?? [] const isSaving = updateSettings.isPending @@ -218,13 +235,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {/* API Provider Selection */}
-
+
{providers.map((provider) => ( ))}
-

- {PROVIDER_INFO_TEXT[currentProvider] ?? ''} -

+
+

+ {PROVIDER_INFO_TEXT[currentProvider] ?? ''} +

+ +
+ {testResult && ( +

+ {testResult.message} +

+ )} {/* Auth Token Field */} {showAuthField && ( @@ -313,25 +353,39 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {/* Model Selection */}
- {models.length > 0 && ( -
+ {models.length > 0 && models.length <= 3 ? ( +
{models.map((model) => ( ))}
- )} + ) : models.length > 3 ? ( + + ) : null} {/* Custom model input for Ollama/Custom */} {showCustomModelInput && (
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 10b577b4..446a4a15 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -415,6 +415,15 @@ export async function updateSettings(settings: SettingsUpdate): Promise { + return fetchJSON('/settings/test-connection', { method: 'POST' }) +} + // ============================================================================ // Dev Server API // ============================================================================ From 16da644920e0814d17ebecf5168253c071bee045 Mon Sep 17 00:00:00 2001 From: nioasoft Date: Fri, 6 Feb 2026 11:55:08 +0200 Subject: [PATCH 5/5] Fix GLM config: correct base URL and model IDs per official docs - Base URL: https://api.z.ai/api/anthropic (not coding/paas endpoint) - Model IDs: GLM-4.7, GLM-4.5-Air (case-sensitive, uppercase) Co-Authored-By: Claude Opus 4.6 --- registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/registry.py b/registry.py index 11166c99..cdc990a2 100644 --- a/registry.py +++ b/registry.py @@ -667,14 +667,14 @@ def get_all_settings() -> dict[str, str]: }, "glm": { "name": "GLM (Zhipu AI)", - "base_url": "https://api.z.ai/api/coding/paas/v4", + "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"}, + {"id": "GLM-4.7", "name": "GLM 4.7"}, + {"id": "GLM-4.5-Air", "name": "GLM 4.5 Air"}, ], - "default_model": "glm-4.7", + "default_model": "GLM-4.7", }, "ollama": { "name": "Ollama (Local)",