mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-06-02 06:13:46 +02:00
Improve admin UI setup flow
This commit is contained in:
@@ -14,7 +14,7 @@ Use Claude Code CLI, VS Code, JetBrains ACP, or chat bots through your own Anthr
|
||||
|
||||
Free Claude Code routes Anthropic Messages API traffic from Claude Code to NVIDIA NIM, Kimi, Wafer, OpenRouter, DeepSeek, LM Studio, llama.cpp, or Ollama. It keeps Claude Code's client-side protocol stable while letting you choose free, paid, or local models.
|
||||
|
||||
[Quick Start](#quick-start) · [Local Admin UI](#local-admin-ui) · [Providers](#choose-a-provider) · [Clients](#connect-claude-code) · [Configuration](#configuration-reference) · [Troubleshooting](#troubleshooting) · [Development](#development)
|
||||
[Quick Start](#quick-start) · [Providers](#choose-a-provider) · [Clients](#connect-claude-code) · [Configuration](#configuration-reference) · [Troubleshooting](#troubleshooting) · [Development](#development)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -42,14 +42,21 @@ Free Claude Code routes Anthropic Messages API traffic from Claude Code to NVIDI
|
||||
- Native Claude Code `/model` picker support through the proxy's `/v1/models` endpoint (Claude Code must opt in to Gateway model discovery; see [Model Picker](#model-picker)).
|
||||
- Streaming, tool use, reasoning/thinking block handling, and local request optimizations.
|
||||
- Optional Discord or Telegram bot wrapper for remote coding sessions.
|
||||
- Optional Usage through the VSCode extension.
|
||||
- Optional voice-note transcription through local Whisper or NVIDIA NIM.
|
||||
- Local **Admin UI** at `/admin` to edit supported proxy settings, validate changes, and check providers (loopback access only).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Requirements
|
||||
### 1. Install the latest version of [Claude Code](https://code.claude.com/docs/en/overview)
|
||||
|
||||
Install [Claude Code](https://github.com/anthropics/claude-code), then install `uv` and Python 3.14.
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
### 2. Install Runtime Requirements
|
||||
|
||||
Install the latest version of [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.14.
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
@@ -67,7 +74,7 @@ uv self update
|
||||
uv python install 3.14
|
||||
```
|
||||
|
||||
### 2. Install The Proxy
|
||||
### 3. Install The Proxy
|
||||
|
||||
```bash
|
||||
uv tool install git+https://github.com/Alishahryar1/free-claude-code.git
|
||||
@@ -75,10 +82,10 @@ uv tool install git+https://github.com/Alishahryar1/free-claude-code.git
|
||||
|
||||
Use the same command to update the proxy.
|
||||
|
||||
### 3. Start The Proxy
|
||||
### 4. Start The Proxy
|
||||
|
||||
```bash
|
||||
free-claude-code
|
||||
fcc-server
|
||||
```
|
||||
|
||||
After startup, the terminal prints the proxy and admin URLs:
|
||||
@@ -90,7 +97,7 @@ Admin UI: http://127.0.0.1:8082/admin
|
||||
|
||||
Many terminals make these clickable. Use your configured `PORT` if it is not `8082`.
|
||||
|
||||
### 4. Open The Admin UI
|
||||
### 5. Open The Admin UI
|
||||
|
||||
Open the **Admin UI** URL from the terminal output.
|
||||
|
||||
@@ -98,35 +105,21 @@ Open the **Admin UI** URL from the terminal output.
|
||||
<img src="assets/admin-page.png" alt="Local admin UI for proxy settings" width="700">
|
||||
</div>
|
||||
|
||||
### 5. Configure Provider And Model
|
||||
### 6. Configure Provider And Model
|
||||
|
||||
In the Admin UI, paste your provider key, set `MODEL`, then click **Validate** and **Apply**. Values are saved to `~/.config/free-claude-code/.env`.
|
||||
|
||||
### 6. Run Claude Code
|
||||
|
||||
Point `ANTHROPIC_BASE_URL` at the proxy root. Do not append `/v1`. Use the same `ANTHROPIC_AUTH_TOKEN` you configured in the Admin UI.
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:ANTHROPIC_AUTH_TOKEN="freecc"; $env:ANTHROPIC_BASE_URL="http://localhost:8082"; $env:CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="1"; claude
|
||||
```
|
||||
|
||||
Bash:
|
||||
### 7. Run Claude Code
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN="freecc" ANTHROPIC_BASE_URL="http://localhost:8082" CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 claude
|
||||
fcc-claude
|
||||
```
|
||||
|
||||
## Local Admin UI
|
||||
|
||||
The Admin UI is local-only: non-loopback clients, or requests with a non-local `Origin`, receive **403**.
|
||||
|
||||
It can load masked config values, validate/apply changes, show runtime status, probe local backends (LM Studio, llama.cpp, Ollama), test providers, list models, and refresh the model cache.
|
||||
`fcc-claude` reads the current configured port and auth token each time it starts, sets the Claude Code environment variables, and then launches the real `claude` command.
|
||||
|
||||
## Choose A Provider
|
||||
|
||||
Use these examples when setting `MODEL` in the [Admin UI](#local-admin-ui) or in `.env`.
|
||||
Use these examples when setting `MODEL` in the Admin UI or in `.env`.
|
||||
|
||||
Model values use this format:
|
||||
|
||||
@@ -303,10 +296,14 @@ MODEL="wafer/DeepSeek-V4-Pro"
|
||||
|
||||
### Claude Code CLI
|
||||
|
||||
For terminal use, prefer the installed launcher:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN="freecc" ANTHROPIC_BASE_URL="http://localhost:8082" CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 claude
|
||||
fcc-claude
|
||||
```
|
||||
|
||||
Keep `fcc-server` running while you work. The Admin UI manages proxy config, restarts the server when runtime settings change, and `fcc-claude` reads the current Admin UI-managed port and auth token every time it starts.
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
Open Settings, search for `claude-code.environmentVariables`, choose **Edit in settings.json**, and add:
|
||||
@@ -344,13 +341,13 @@ Restart the IDE after changing the file.
|
||||
|
||||
Claude Code 2.1.126 or later can populate `/model` from this proxy's Gateway `/v1/models` response when `ANTHROPIC_BASE_URL` points here. In **2.1.126–2.1.128** that discovery was automatic; **newer releases** require **`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`** in the same environment as `ANTHROPIC_*`. Omit the flag if you only set models via proxy config and never use `/model` discovery.
|
||||
|
||||
Start Claude Code with that variable set (see [Quick Start](#4-run-claude-code)), run `/model`, and choose any discovered provider model.
|
||||
Start Claude Code with that variable set (see [Run Claude Code](#7-run-claude-code)), run `/model`, and choose any discovered provider model.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/cc-model-picker.png" alt="Claude Code model picker showing gateway models" width="700">
|
||||
</div>
|
||||
|
||||
The proxy lists models for configured provider keys and referenced local providers. Picker-safe IDs are routed back to the real provider/model automatically, so no `.env` edit or separate launcher script is needed after startup.
|
||||
The proxy lists models for configured provider keys and referenced local providers. Picker-safe IDs are routed back to the real provider/model automatically, so no `.env` edit is needed after startup.
|
||||
|
||||
Each provider model also has a `(no thinking)` picker variant. Use it when a model does not support Claude Code thinking or fails with adaptive-thinking requests. It routes to the same upstream model while asking Claude Code to send a non-thinking request.
|
||||
|
||||
@@ -596,8 +593,10 @@ Run them in that order before pushing. CI enforces the same checks.
|
||||
|
||||
`pyproject.toml` installs:
|
||||
|
||||
- `free-claude-code`: starts the proxy with configured host and port.
|
||||
- `fcc-server`: starts the proxy with configured host and port.
|
||||
- `fcc-init`: optional file-based config scaffold at `~/.config/free-claude-code/.env`.
|
||||
- `fcc-claude`: launches Claude Code with the configured local proxy URL, auth token, and model discovery flag.
|
||||
- `free-claude-code`: compatibility alias for `fcc-server`.
|
||||
|
||||
### Extending
|
||||
|
||||
|
||||
+2
-1
@@ -995,6 +995,7 @@ def write_managed_env(updates: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return validation | {"applied": False, "pending_fields": []}
|
||||
|
||||
target_values = _target_values_with_updates(updates)
|
||||
pending_fields = changed_pending_fields(updates)
|
||||
path = managed_env_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
@@ -1006,7 +1007,7 @@ def write_managed_env(updates: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"errors": [],
|
||||
"env_preview": render_env_file(target_values, mask_secrets=True),
|
||||
"path": str(path),
|
||||
"pending_fields": changed_pending_fields(updates),
|
||||
"pending_fields": pending_fields,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+45
-2
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config.settings import Settings
|
||||
from config.settings import get_settings as get_cached_settings
|
||||
from providers.registry import ProviderRegistry
|
||||
|
||||
@@ -22,6 +24,7 @@ from .admin_config import (
|
||||
validate_updates,
|
||||
write_managed_env,
|
||||
)
|
||||
from .admin_urls import local_admin_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -104,13 +107,25 @@ async def validate_admin_config(payload: AdminConfigPayload, request: Request):
|
||||
|
||||
|
||||
@router.post("/admin/api/config/apply")
|
||||
async def apply_admin_config(payload: AdminConfigPayload, request: Request):
|
||||
async def apply_admin_config(
|
||||
payload: AdminConfigPayload,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
require_loopback_admin(request)
|
||||
result = write_managed_env(_filtered_values(payload.values))
|
||||
if not result["applied"]:
|
||||
return result
|
||||
|
||||
get_cached_settings.cache_clear()
|
||||
restart = _restart_metadata(result["pending_fields"], request)
|
||||
result["restart"] = restart
|
||||
if restart["required"] and restart["automatic"]:
|
||||
callback = request.app.state.admin_restart_callback
|
||||
background_tasks.add_task(_invoke_admin_restart_callback, callback)
|
||||
request.app.state.admin_pending_fields = []
|
||||
return result
|
||||
|
||||
old_registry = getattr(request.app.state, "provider_registry", None)
|
||||
if isinstance(old_registry, ProviderRegistry):
|
||||
await old_registry.cleanup()
|
||||
@@ -200,6 +215,34 @@ def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in values.items() if key in FIELD_BY_KEY}
|
||||
|
||||
|
||||
async def _invoke_admin_restart_callback(callback: Any) -> None:
|
||||
result = callback()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
def _restart_metadata(fields: list[str], request: Request) -> dict[str, Any]:
|
||||
callback = getattr(request.app.state, "admin_restart_callback", None)
|
||||
automatic = bool(fields and callable(callback))
|
||||
return {
|
||||
"required": bool(fields),
|
||||
"automatic": automatic,
|
||||
"admin_url": _next_admin_url() if automatic else None,
|
||||
"fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _next_admin_url() -> str:
|
||||
fields = {
|
||||
field["key"]: field["value"] for field in load_config_response()["fields"]
|
||||
}
|
||||
settings = Settings.model_construct(
|
||||
host=fields.get("HOST") or "0.0.0.0",
|
||||
port=int(fields.get("PORT") or 8082),
|
||||
)
|
||||
return local_admin_url(settings)
|
||||
|
||||
|
||||
def _local_provider_url(provider_id: str, values: dict[str, str]) -> str:
|
||||
if provider_id == "lmstudio":
|
||||
return values.get("LM_STUDIO_BASE_URL", "")
|
||||
|
||||
@@ -339,11 +339,20 @@ async function apply() {
|
||||
showValidationResult(result);
|
||||
return;
|
||||
}
|
||||
const pending = result.pending_fields || [];
|
||||
const restart = result.restart || {};
|
||||
if (restart.required && restart.automatic) {
|
||||
showMessage("Applied. Restarting server...", "ok");
|
||||
byId("applyButton").disabled = true;
|
||||
setTimeout(() => {
|
||||
window.location.href = restart.admin_url || "/admin";
|
||||
}, 1600);
|
||||
return;
|
||||
}
|
||||
const pending = restart.required ? restart.fields || [] : result.pending_fields || [];
|
||||
await load();
|
||||
showMessage(
|
||||
pending.length
|
||||
? `Applied. Pending manual runtime action: ${pending.join(", ")}`
|
||||
? `Applied. Restart fcc-server to use: ${pending.join(", ")}`
|
||||
: "Applied",
|
||||
"ok",
|
||||
);
|
||||
|
||||
+121
-18
@@ -2,7 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import uvicorn
|
||||
|
||||
from api.admin_urls import local_proxy_root_url
|
||||
from api.app import GracefulLifespanApp, create_app
|
||||
from cli.process_registry import kill_all_best_effort
|
||||
from config.settings import Settings, get_settings
|
||||
|
||||
PROXY_PREFLIGHT_PATH = "/health"
|
||||
PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5
|
||||
SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5
|
||||
|
||||
|
||||
def _load_env_template() -> str:
|
||||
@@ -21,26 +38,45 @@ def _load_env_template() -> str:
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
"""Start the FastAPI server (registered as `free-claude-code` script)."""
|
||||
import uvicorn
|
||||
|
||||
from cli.process_registry import kill_all_best_effort
|
||||
from config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
"""Start the FastAPI server (registered as `fcc-server` script)."""
|
||||
try:
|
||||
uvicorn.run(
|
||||
"api.app:create_asgi_app",
|
||||
factory=True,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="debug",
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
while True:
|
||||
settings = get_settings()
|
||||
if not _run_supervised_server(settings):
|
||||
return
|
||||
get_settings.cache_clear()
|
||||
finally:
|
||||
kill_all_best_effort()
|
||||
|
||||
|
||||
def _run_supervised_server(settings: Settings) -> bool:
|
||||
"""Run one uvicorn server instance; return whether admin requested restart."""
|
||||
|
||||
restart_requested = False
|
||||
server_holder: dict[str, uvicorn.Server] = {}
|
||||
|
||||
def request_restart() -> None:
|
||||
nonlocal restart_requested
|
||||
restart_requested = True
|
||||
if server := server_holder.get("server"):
|
||||
server.should_exit = True
|
||||
|
||||
app = create_app(lifespan_enabled=False)
|
||||
app.state.admin_restart_callback = request_restart
|
||||
asgi_app = GracefulLifespanApp(app)
|
||||
config = uvicorn.Config(
|
||||
asgi_app,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="debug",
|
||||
timeout_graceful_shutdown=SERVER_GRACEFUL_SHUTDOWN_SECONDS,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server_holder["server"] = server
|
||||
server.run()
|
||||
return restart_requested
|
||||
|
||||
|
||||
def init() -> None:
|
||||
"""Scaffold config at ~/.config/free-claude-code/.env (registered as `fcc-init`)."""
|
||||
config_dir = Path.home() / ".config" / "free-claude-code"
|
||||
@@ -55,6 +91,73 @@ def init() -> None:
|
||||
template = _load_env_template()
|
||||
env_file.write_text(template, encoding="utf-8")
|
||||
print(f"Config created at {env_file}")
|
||||
print(
|
||||
"Edit it to set your API keys and model preferences, then run: free-claude-code"
|
||||
)
|
||||
print("Edit it to set your API keys and model preferences, then run: fcc-server")
|
||||
|
||||
|
||||
def _claude_child_env(
|
||||
settings: Settings, base_env: Mapping[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""Return a Claude Code environment that targets this proxy."""
|
||||
|
||||
env = {
|
||||
key: value
|
||||
for key, value in base_env.items()
|
||||
if not key.startswith("ANTHROPIC_")
|
||||
}
|
||||
env["ANTHROPIC_BASE_URL"] = local_proxy_root_url(settings)
|
||||
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
||||
if token := settings.anthropic_auth_token.strip():
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token
|
||||
return env
|
||||
|
||||
|
||||
def _preflight_proxy(proxy_root_url: str) -> str | None:
|
||||
"""Return an error message when the local proxy health check is unreachable."""
|
||||
|
||||
url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}"
|
||||
request = Request(url, method="GET")
|
||||
try:
|
||||
with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
|
||||
status_code = response.getcode()
|
||||
except HTTPError as exc:
|
||||
return f"returned HTTP {exc.code}"
|
||||
except URLError as exc:
|
||||
return str(exc.reason)
|
||||
except OSError as exc:
|
||||
return str(exc)
|
||||
|
||||
if not 200 <= status_code < 300:
|
||||
return f"returned HTTP {status_code}"
|
||||
return None
|
||||
|
||||
|
||||
def launch_claude(argv: Sequence[str] | None = None) -> None:
|
||||
"""Launch Claude Code with Free Claude Code proxy environment variables."""
|
||||
|
||||
settings = get_settings()
|
||||
proxy_root_url = local_proxy_root_url(settings)
|
||||
if error := _preflight_proxy(proxy_root_url):
|
||||
print(
|
||||
f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Start it in another terminal with: fcc-server", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
command = [settings.claude_cli_bin, *args]
|
||||
env = _claude_child_env(settings, os.environ)
|
||||
try:
|
||||
result = subprocess.run(command, env=env, check=False)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"Could not find Claude Code command: {settings.claude_cli_bin}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Install Claude Code with: npm install -g @anthropic-ai/claude-code",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(127) from None
|
||||
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
@@ -25,8 +25,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fcc-server = "cli.entrypoints:serve"
|
||||
free-claude-code = "cli.entrypoints:serve"
|
||||
fcc-init = "cli.entrypoints:init"
|
||||
fcc-claude = "cli.entrypoints:launch_claude"
|
||||
|
||||
[project.optional-dependencies]
|
||||
voice = [
|
||||
|
||||
@@ -19,6 +19,7 @@ def _local_client(app):
|
||||
def _set_home(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
|
||||
def _clear_process_config(monkeypatch) -> None:
|
||||
@@ -28,6 +29,9 @@ def _clear_process_config(monkeypatch) -> None:
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"FCC_ENV_FILE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"LOG_FILE",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
@@ -103,6 +107,63 @@ def test_admin_apply_writes_complete_managed_env_and_masks_preview(
|
||||
assert "MODEL=open_router/test-model" in text
|
||||
assert "OPENROUTER_API_KEY=router-secret" in text
|
||||
assert "ANTHROPIC_AUTH_TOKEN=" in text
|
||||
assert body["restart"] == {
|
||||
"required": False,
|
||||
"automatic": False,
|
||||
"admin_url": None,
|
||||
"fields": [],
|
||||
}
|
||||
|
||||
|
||||
def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path):
|
||||
_set_home(monkeypatch, tmp_path)
|
||||
_clear_process_config(monkeypatch)
|
||||
app = create_app(lifespan_enabled=False)
|
||||
callbacks: list[str] = []
|
||||
|
||||
async def restart_callback() -> None:
|
||||
callbacks.append("restart")
|
||||
|
||||
app.state.admin_restart_callback = restart_callback
|
||||
|
||||
response = _local_client(app).post(
|
||||
"/admin/api/config/apply",
|
||||
json={"values": {"PORT": "9090"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["applied"] is True
|
||||
assert body["pending_fields"] == ["PORT"]
|
||||
assert body["restart"] == {
|
||||
"required": True,
|
||||
"automatic": True,
|
||||
"admin_url": "http://127.0.0.1:9090/admin",
|
||||
"fields": ["PORT"],
|
||||
}
|
||||
assert callbacks == ["restart"]
|
||||
|
||||
|
||||
def test_admin_apply_restart_required_reports_manual_fallback(monkeypatch, tmp_path):
|
||||
_set_home(monkeypatch, tmp_path)
|
||||
_clear_process_config(monkeypatch)
|
||||
app = create_app(lifespan_enabled=False)
|
||||
|
||||
response = _local_client(app).post(
|
||||
"/admin/api/config/apply",
|
||||
json={"values": {"PORT": "9091"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["applied"] is True
|
||||
assert body["pending_fields"] == ["PORT"]
|
||||
assert body["restart"] == {
|
||||
"required": True,
|
||||
"automatic": False,
|
||||
"admin_url": None,
|
||||
"fields": ["PORT"],
|
||||
}
|
||||
|
||||
|
||||
def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
"""Tests for cli/entrypoints.py — fcc-init scaffolding logic."""
|
||||
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def _launcher_settings(
|
||||
*,
|
||||
port: int = 8082,
|
||||
token: str = "freecc",
|
||||
claude_bin: str = "claude-test",
|
||||
) -> Settings:
|
||||
return Settings.model_construct(
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
anthropic_auth_token=token,
|
||||
claude_cli_bin=claude_bin,
|
||||
)
|
||||
|
||||
|
||||
def _run_init(tmp_home: Path) -> tuple[str, Path]:
|
||||
@@ -78,7 +99,142 @@ def test_init_skips_if_env_already_exists(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_init_prints_next_step_hint(tmp_path: Path) -> None:
|
||||
"""init() tells the user to run free-claude-code after editing .env."""
|
||||
"""init() tells the user to run fcc-server after editing .env."""
|
||||
output, _ = _run_init(tmp_path)
|
||||
|
||||
assert "free-claude-code" in output
|
||||
assert "fcc-server" in output
|
||||
|
||||
|
||||
def test_cli_scripts_are_registered() -> None:
|
||||
pyproject = tomllib.loads(
|
||||
(Path(__file__).resolve().parents[2] / "pyproject.toml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
scripts = pyproject["project"]["scripts"]
|
||||
assert scripts["fcc-server"] == "cli.entrypoints:serve"
|
||||
assert scripts["free-claude-code"] == "cli.entrypoints:serve"
|
||||
assert scripts["fcc-claude"] == "cli.entrypoints:launch_claude"
|
||||
|
||||
|
||||
def test_serve_supervisor_restarts_when_app_requests_restart() -> None:
|
||||
from cli import entrypoints
|
||||
|
||||
settings = _launcher_settings()
|
||||
get_settings = MagicMock(side_effect=[settings, settings])
|
||||
get_settings.cache_clear = MagicMock()
|
||||
servers: list[object] = []
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.should_exit = False
|
||||
servers.append(self)
|
||||
|
||||
def run(self):
|
||||
if len(servers) == 1:
|
||||
self.config.app.app.state.admin_restart_callback()
|
||||
assert self.should_exit is True
|
||||
|
||||
def fake_config(app, **kwargs):
|
||||
return SimpleNamespace(app=app, kwargs=kwargs)
|
||||
|
||||
with (
|
||||
patch.object(entrypoints, "get_settings", get_settings),
|
||||
patch.object(entrypoints.uvicorn, "Config", side_effect=fake_config),
|
||||
patch.object(entrypoints.uvicorn, "Server", side_effect=FakeServer),
|
||||
patch.object(entrypoints, "kill_all_best_effort") as kill_all,
|
||||
):
|
||||
entrypoints.serve()
|
||||
|
||||
assert len(servers) == 2
|
||||
get_settings.cache_clear.assert_called_once()
|
||||
kill_all.assert_called_once()
|
||||
|
||||
|
||||
def test_claude_child_env_targets_current_proxy_config() -> None:
|
||||
from cli.entrypoints import _claude_child_env
|
||||
|
||||
env = _claude_child_env(
|
||||
_launcher_settings(port=9090, token=" proxy-token "),
|
||||
{
|
||||
"PATH": "keep",
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "old-token",
|
||||
"ANTHROPIC_API_KEY": "official-key",
|
||||
},
|
||||
)
|
||||
|
||||
assert env["PATH"] == "keep"
|
||||
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9090"
|
||||
assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
|
||||
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
def test_claude_child_env_removes_blank_configured_auth_token() -> None:
|
||||
from cli.entrypoints import _claude_child_env
|
||||
|
||||
env = _claude_child_env(
|
||||
_launcher_settings(token=""),
|
||||
{
|
||||
"ANTHROPIC_AUTH_TOKEN": "inherited-token",
|
||||
"ANTHROPIC_API_KEY": "official-key",
|
||||
},
|
||||
)
|
||||
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
def test_launch_claude_passes_args_and_child_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from cli.entrypoints import launch_claude
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "old-token")
|
||||
monkeypatch.setenv("KEEP_ME", "yes")
|
||||
settings = _launcher_settings(port=9191, token="proxy-token")
|
||||
|
||||
with (
|
||||
patch("cli.entrypoints.get_settings", return_value=settings),
|
||||
patch("cli.entrypoints._preflight_proxy", return_value=None),
|
||||
patch(
|
||||
"cli.entrypoints.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(["claude-test"], 7),
|
||||
) as run,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
launch_claude(["--model", "sonnet"])
|
||||
|
||||
assert exc_info.value.code == 7
|
||||
run.assert_called_once()
|
||||
assert run.call_args.args[0] == ["claude-test", "--model", "sonnet"]
|
||||
child_env = run.call_args.kwargs["env"]
|
||||
assert child_env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191"
|
||||
assert child_env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
|
||||
assert child_env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
||||
assert child_env["KEEP_ME"] == "yes"
|
||||
|
||||
|
||||
def test_launch_claude_unreachable_proxy_exits_with_hint(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
from cli.entrypoints import launch_claude
|
||||
|
||||
settings = _launcher_settings(port=9393)
|
||||
with (
|
||||
patch("cli.entrypoints.get_settings", return_value=settings),
|
||||
patch("cli.entrypoints._preflight_proxy", return_value="connection refused"),
|
||||
patch("cli.entrypoints.subprocess.run") as run,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
launch_claude([])
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
run.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "http://127.0.0.1:9393" in captured.err
|
||||
assert "fcc-server" in captured.err
|
||||
|
||||
Reference in New Issue
Block a user