Files
Auto-Trader/agent/api_server.py
T
Haozhe Wu 035673b065 revert: back out IRR-AGL reliability/governance stack (#405, #416)
Reverts merge commits 0b867a7 (#405) and 474f5e4 (#416), and removes the
now-orphaned #406 artifact metadata store (src/reliability + tests, zero
references left outside its own tests).

Why: the stack wrapped the tool registry in every session path (even
governance mode=off) without exposing the registry surface AgentContext
reads, breaking all session-runtime chats within a day (#433, #431). It
also modified protected core files (src/agent/loop.py, src/session/
service.py), shipped 12 internal-convention docs/ files into the public
tree, and its enforcement layer already has reported bypass paths (#420).
Trading safety stays on the single mandate-gate design (#31, #144).

README news entries for 2026-07-07/07-06 updated accordingly.
2026-07-08 14:15:56 +08:00

1102 lines
39 KiB
Python

#!/usr/bin/env python3
"""Vibe-Trading API Server - RESTful API for finance research and backtesting.
V5: ReAct Agent + async /run + CORS env + SSE tool events.
"""
from __future__ import annotations
import asyncio
import hmac
import ipaddress
import json
import logging
import os
import re
import signal
import time
import csv
import uuid
import urllib.parse
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request, Security, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
from rich.console import Console
from cli._version import __version__ as APP_VERSION
from src.ui_services import build_run_analysis, load_run_context
# UTF-8 on Windows
import sys as _sys
for _s in ("stdout", "stderr"):
_r = getattr(getattr(_sys, _s, None), "reconfigure", None)
if callable(_r):
_r(encoding="utf-8", errors="replace")
RUNS_DIR = Path(__file__).resolve().parent / "runs"
SESSIONS_DIR = Path(__file__).resolve().parent / "sessions"
UPLOADS_DIR = Path(__file__).resolve().parent / "uploads"
AGENT_DIR = Path(__file__).resolve().parent
ENV_PATH = AGENT_DIR / ".env"
ENV_EXAMPLE_PATH = AGENT_DIR / ".env.example"
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
console = Console()
logger = logging.getLogger(__name__)
# ============================================================================
# Pydantic Models
# ============================================================================
class Artifact(BaseModel):
"""Artifact file metadata."""
name: str = Field(..., description="File name")
path: str = Field(..., description="File path")
type: str = Field(..., description="File type: csv, json, txt, etc.")
size: int = Field(..., description="Size in bytes")
exists: bool = Field(..., description="Whether the file exists")
class BacktestMetrics(BaseModel):
"""Backtest summary metrics."""
model_config = {"extra": "allow"}
final_value: float = Field(..., description="Ending portfolio value")
total_return: float = Field(..., description="Total return")
annual_return: float = Field(..., description="Annualized return")
max_drawdown: float = Field(..., description="Max drawdown")
sharpe: float = Field(..., description="Sharpe ratio")
win_rate: float = Field(..., description="Win rate")
trade_count: int = Field(..., description="Number of trades")
class RAGSelection(BaseModel):
"""RAG routing result."""
selected_api: str = Field(..., description="Selected API code")
selected_name: str = Field(..., description="Selected API name")
selected_score: float = Field(..., description="Match score")
class RunInfo(BaseModel):
"""Compact run row for list views."""
run_id: str
status: str
created_at: str
prompt: Optional[str] = None
total_return: Optional[float] = None
sharpe: Optional[float] = None
codes: List[str] = Field(default_factory=list)
start_date: Optional[str] = None
end_date: Optional[str] = None
class RunResponse(BaseModel):
"""API response payload for a single run."""
status: str = Field(..., description="Run status: success, failed, aborted")
run_id: str = Field(..., description="Run identifier")
elapsed_seconds: float = Field(..., description="Execution time in seconds")
reason: Optional[str] = Field(None, description="Failure reason when available")
planner_output: Optional[Dict[str, Any]] = Field(None, description="Planner output")
strategy_spec: Optional[Dict[str, Any]] = Field(None, description="Strategy specification")
rag_selection: Optional[RAGSelection] = Field(None, description="Selected RAG metadata")
metrics: Optional[BacktestMetrics] = Field(None, description="Backtest metrics")
artifacts: List[Artifact] = Field(default_factory=list, description="Run artifacts")
run_card: Optional[Dict[str, Any]] = Field(None, description="Trust Layer run card payload")
llm_usage: Optional[Dict[str, Any]] = Field(None, description="Provider-reported AgentLoop usage summary")
equity_curve: Optional[List[Dict[str, Any]]] = Field(None, description="Equity preview")
trade_log: Optional[List[Dict[str, Any]]] = Field(None, description="Trade preview")
artifacts_equity_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full equity rows")
artifacts_metrics_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full metrics rows")
artifacts_trades_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full trade rows")
validation: Optional[Dict[str, Any]] = Field(None, description="Statistical validation results")
run_directory: str = Field(..., description="Run directory path")
run_stage: Optional[str] = Field(None, description="UI-facing run stage")
run_context: Optional[Dict[str, Any]] = Field(None, description="Normalized request context")
price_series: Optional[Dict[str, List[Dict[str, Any]]]] = Field(None, description="Grouped OHLC series")
indicator_series: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = Field(
None,
description="Grouped indicator overlays",
)
trade_markers: Optional[List[Dict[str, Any]]] = Field(None, description="Trade markers for charts")
run_logs: Optional[List[Dict[str, Any]]] = Field(None, description="Structured stdout/stderr lines")
# Session/goal Pydantic models are defined in src/api/sessions_routes.py.
# Live-trading Pydantic models are defined in src/api/live_routes.py.
# ============================================================================
# FastAPI Application
# ============================================================================
app = FastAPI(
title="Vibe-Trading API",
description="Vibe-Trading API: natural-language finance research, backtesting, and swarm workflows",
version=APP_VERSION,
docs_url="/docs",
redoc_url="/redoc"
)
_DEFAULT_CORS_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8000",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
]
_DEFAULT_LOOPBACK_HOSTS = frozenset({
"localhost",
"127.0.0.1",
"::1",
"[::1]",
# Starlette/FastAPI TestClient default host; included so unit tests exercise
# the API without having to override Host on every request.
"testserver",
})
def _parse_cors_origins(raw: Optional[str]) -> List[str]:
"""Parse CORS origins and reject credentialed wildcard configuration.
Args:
raw: Comma-separated CORS origins from ``CORS_ORIGINS``. ``None`` or a
blank value uses the loopback development defaults.
Returns:
Explicit CORS origins accepted by the API server.
Raises:
RuntimeError: If a wildcard origin is configured while credentials are
enabled.
"""
if raw is None or not raw.strip():
return list(_DEFAULT_CORS_ORIGINS)
origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
if "*" in origins:
raise RuntimeError(
"CORS_ORIGINS='*' is not allowed while credentials are enabled; "
"configure explicit Web UI origins instead."
)
return origins
def _parse_extra_loopback_hosts(raw: Optional[str]) -> set[str]:
"""Return additional trusted Host names for loopback API traffic."""
if raw is None or not raw.strip():
return set()
return {host.strip().lower().rstrip(".") for host in raw.split(",") if host.strip()}
_EXTRA_LOOPBACK_HOSTS = _parse_extra_loopback_hosts(os.getenv("API_ALLOWED_HOSTS"))
def _host_without_port(host: str) -> str:
"""Normalize a Host header to a lowercase hostname without a port."""
value = host.strip().lower().rstrip(".")
if not value:
return ""
if value.startswith("["):
end = value.find("]")
if end != -1:
return value[: end + 1]
return value
if value.count(":") == 1:
return value.rsplit(":", 1)[0]
return value
def _is_allowed_loopback_host(host: str) -> bool:
"""Return whether ``host`` is allowed for loopback-trusted API requests."""
normalized = _host_without_port(host)
return normalized in _DEFAULT_LOOPBACK_HOSTS or normalized in _EXTRA_LOOPBACK_HOSTS
def _is_loopback_bind_host(host: str) -> bool:
"""Return whether ``host`` resolves to a loopback interface."""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host == "localhost"
# CORS: override with CORS_ORIGINS (comma-separated explicit origins)
_CORS_ORIGINS = _parse_cors_origins(os.getenv("CORS_ORIGINS"))
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def _reject_untrusted_loopback_host(request: Request, call_next):
"""Block DNS-rebinding Host headers before loopback auth bypasses run."""
if _is_local_client(request) and not _is_allowed_loopback_host(request.headers.get("host", "")):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Untrusted local API host"},
)
return await call_next(request)
# ----------------------------------------------------------------------------
# SPA deep-link fallback
# ----------------------------------------------------------------------------
# A handful of API routes share their path with frontend SPA routes (e.g.
# ``/runs/{id}`` and ``/correlation``). Because FastAPI matches registered
# routes before the static SPA mount, a browser that refreshes or bookmarks
# one of these URLs would receive JSON (or 401/422) instead of the SPA shell.
# The middleware below serves ``frontend/dist/index.html`` when the request
# clearly came from a browser (``Accept`` contains ``text/html``); programmatic
# clients are routed to the real API handler as before.
#
# Patterns are written narrowly so the SPA shell only shadows paths that
# actually correspond to frontend pages. In particular ``/runs/{id}`` is
# the RunDetail page, but ``/runs/{id}/code`` and ``/runs/{id}/pine`` are
# API-only endpoints with no SPA route — using a broad ``/runs/`` prefix
# here would incorrectly hijack those when the browser sets ``Accept:
# text/html`` (e.g. a user pasting the URL into the address bar).
_FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
_SPA_HTML_EXACT_PATHS: frozenset[str] = frozenset({"/correlation"})
# Each regex matches a complete request path. Trailing slash optional.
_SPA_HTML_PATH_REGEX: tuple[re.Pattern[str], ...] = (
# ``/runs/{run_id}`` — RunDetail page. Excludes ``/runs/{id}/code``,
# ``/runs/{id}/pine`` (API only) and ``/runs`` (collection endpoint).
re.compile(r"^/runs/[^/]+/?$"),
)
def _is_spa_html_route(path: str) -> bool:
"""Return True when ``path`` corresponds to a frontend SPA page that
shadows an API endpoint and should fall back to ``index.html`` on
browser navigation."""
if path in _SPA_HTML_EXACT_PATHS:
return True
return any(pattern.match(path) for pattern in _SPA_HTML_PATH_REGEX)
@app.middleware("http")
async def _spa_html_deep_link_fallback(request: Request, call_next):
"""Serve ``frontend/dist/index.html`` when a browser navigates directly to
an SPA path that also exists as an API endpoint.
Conflicts: ``/runs/{id}`` (RunDetail page vs API) and ``/correlation``
(Correlation page vs API). Programmatic clients (``Accept: */*`` or
``application/json``) still hit the real API handler.
"""
if request.method == "GET":
accept = request.headers.get("accept", "")
if "text/html" in accept and _is_spa_html_route(request.url.path):
index = _FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(str(index))
return await call_next(request)
# ============================================================================
# Channel routes - defined in src/api/channels_routes.py
# Lifecycle functions imported early for startup/shutdown hooks
# ============================================================================
from src.api.channels_routes import ( # noqa: E402
_start_channel_runtime,
_stop_channel_runtime,
)
from src.api.scheduled_routes import ( # noqa: E402
_start_scheduled_research_executor,
_stop_scheduled_research_executor,
)
@app.on_event("startup")
async def _run_startup_preflight() -> None:
"""Run preflight checks on server startup."""
from src.preflight import run_preflight
run_preflight(console)
_start_scheduled_research_executor()
if os.getenv("VIBE_TRADING_CHANNELS_AUTO_START", "").strip().lower() in {"1", "true", "yes"}:
await _start_channel_runtime()
@app.on_event("shutdown")
async def _stop_scheduled_research_on_shutdown() -> None:
"""Stop the scheduled research executor on server shutdown."""
await _stop_channel_runtime()
await _stop_scheduled_research_executor()
# ============================================================================
# API Key Authentication
# ============================================================================
_security = HTTPBearer(auto_error=False)
_API_KEY = os.getenv("API_AUTH_KEY")
_SHELL_TOOLS_ENV = "VIBE_TRADING_ENABLE_SHELL_TOOLS"
_DOCKER_LOOPBACK_ENV = "VIBE_TRADING_TRUST_DOCKER_LOOPBACK"
def _configured_api_key() -> str:
"""Return the current API auth key, if configured."""
return os.getenv("API_AUTH_KEY") or _API_KEY or ""
async def require_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Validate Bearer token for sensitive API endpoints.
Args:
request: Incoming HTTP request.
cred: HTTP Bearer credentials extracted from the Authorization header.
Raises:
HTTPException: 403 when dev-mode auth is reached from a non-local client.
HTTPException: 401 when API_AUTH_KEY is set but the token is missing or wrong.
"""
_validate_api_auth(request=request, cred=cred)
async def require_event_stream_auth(
request: Request,
api_key: Optional[str] = Query(None),
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Validate auth for browser EventSource streams.
Native EventSource cannot send custom Authorization headers, so event
stream endpoints may accept the API key from the query string. Normal JSON
endpoints must continue to use Bearer auth only.
Args:
request: Incoming HTTP request.
api_key: Optional query-string API key for EventSource clients.
cred: HTTP Bearer credentials extracted from the Authorization header.
"""
_validate_api_auth(request=request, cred=cred, query_api_key=api_key, allow_query=True)
def _auth_credential_from_header_or_query(
cred: Optional[HTTPAuthorizationCredentials],
query_api_key: Optional[str],
*,
allow_query: bool,
) -> str:
"""Return the supplied API credential from the permitted source."""
if cred and cred.credentials:
return cred.credentials
if allow_query and query_api_key:
return query_api_key
return ""
def _is_loopback_origin(origin: str) -> bool:
"""Return whether a browser Origin header names a loopback web UI."""
try:
parsed = urllib.parse.urlsplit(origin)
except ValueError:
return False
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
host = parsed.hostname.rstrip(".").lower()
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _origin_matches_request_host(origin: str, request: Request) -> bool:
"""Return whether ``origin`` is the same site serving this request."""
try:
parsed = urllib.parse.urlsplit(origin)
except ValueError:
return False
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
origin_host = parsed.hostname.rstrip(".").lower()
origin_port = parsed.port
request_host = _host_without_port(request.headers.get("host", ""))
if origin_host != request_host:
return False
if origin_port is None:
origin_port = 443 if parsed.scheme == "https" else 80
request_port = request.url.port
if request_port is None:
request_port = 443 if request.url.scheme == "https" else 80
return origin_port == request_port
def _reject_cross_site_browser_request(request: Request) -> None:
"""Reject unsafe browser requests from untrusted cross-site origins.
CORS protects response reads, not blind form/fetch side effects. Keep local
CLI/curl clients and same-origin browser UI deployments working while
refusing browser-originated cross-site POSTs to local control-plane actions
such as shutdown.
"""
sec_fetch_site = request.headers.get("sec-fetch-site", "").lower()
if sec_fetch_site == "cross-site":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
origin = request.headers.get("origin")
if origin and not (_is_loopback_origin(origin) or _origin_matches_request_host(origin, request)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
def _require_shutdown_authorization(
*,
request: Request,
cred: Optional[HTTPAuthorizationCredentials],
) -> None:
"""Authorize the local shutdown control-plane action.
Loopback peer IP alone is not enough for this browser-reachable, destructive
action. When API_AUTH_KEY is configured, require the Bearer token even for
loopback requests; otherwise preserve local dev-mode shutdown for direct
loopback clients while rejecting cross-site browser requests.
"""
_reject_cross_site_browser_request(request)
api_key = _configured_api_key()
if api_key:
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API_AUTH_KEY is required for non-local API access",
)
_SAFE_BROWSER_METHODS = {"GET", "HEAD", "OPTIONS"}
def _validate_api_auth(
*,
request: Request,
cred: Optional[HTTPAuthorizationCredentials],
query_api_key: Optional[str] = None,
allow_query: bool = False,
) -> None:
"""Validate configured auth, preserving loopback-only dev mode."""
# CORS protects response reads, not blind side effects. Reject unsafe
# browser-originated cross-site requests before honoring loopback dev-mode
# trust, otherwise a malicious page can drive local POST/PUT/DELETE routes.
if request.method.upper() not in _SAFE_BROWSER_METHODS:
_reject_cross_site_browser_request(request)
# Loopback clients are always trusted, even when API_AUTH_KEY is set.
# The key only gates non-local (LAN/remote) access.
if _is_local_client(request):
return
api_key = _configured_api_key()
if not api_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API_AUTH_KEY is required for non-local API access",
)
token = _auth_credential_from_header_or_query(cred, query_api_key, allow_query=allow_query)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
def _is_local_client(request: Request) -> bool:
"""Return whether the request originates from a loopback client."""
host = request.client.host if request.client else ""
if host in {"localhost", "testclient"}:
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
if ip.is_loopback:
return True
return _trusted_docker_loopback_ip(ip)
def _env_flag_enabled(name: str) -> bool:
"""Return whether a boolean environment flag is enabled."""
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _default_gateway_ips() -> set[ipaddress.IPv4Address]:
"""Return IPv4 default gateway addresses from Linux procfs."""
gateways: set[ipaddress.IPv4Address] = set()
try:
lines = Path("/proc/net/route").read_text(encoding="utf-8").splitlines()
except OSError:
return gateways
for line in lines[1:]:
fields = line.split()
if len(fields) < 3 or fields[1] != "00000000":
continue
try:
raw = int(fields[2], 16).to_bytes(4, byteorder="little")
gateways.add(ipaddress.IPv4Address(raw))
except ValueError:
continue
return gateways
def _trusted_docker_loopback_ip(ip: ipaddress._BaseAddress) -> bool:
"""Return whether an IP is the trusted Docker host gateway.
Docker Desktop presents host requests to a container as the bridge gateway
instead of 127.0.0.1. This escape hatch is safe only when the published
port is bound to host loopback, so the official compose file enables it
together with a 127.0.0.1 port binding.
"""
if not isinstance(ip, ipaddress.IPv4Address):
return False
if not _env_flag_enabled(_DOCKER_LOOPBACK_ENV):
return False
return ip in _default_gateway_ips()
def _env_shell_tools_enabled() -> bool:
"""Return whether server-side shell tools are explicitly enabled."""
return _env_flag_enabled(_SHELL_TOOLS_ENV)
def _shell_tools_enabled_for_request(request: Request) -> bool:
"""Return whether this API request may expose shell tools to the agent."""
# Shell-capable tools execute commands on the host as the API process user.
# Do not infer that privilege from peer IP alone: browser DNS rebinding can
# make attacker-controlled pages appear as loopback clients. Operators who
# intentionally want API-started agents or swarm workers to receive shell
# tools must opt in explicitly.
return _env_shell_tools_enabled()
async def require_local_or_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Protect settings access when dev-mode auth is disabled.
If API_AUTH_KEY is configured, require the bearer token. If not, allow only
loopback clients so an API server bound to 0.0.0.0 cannot accept remote
credential reads or writes in dev mode.
"""
if _configured_api_key():
await require_auth(request, cred)
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Settings access requires API_AUTH_KEY or a local loopback client",
)
async def require_settings_write_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Require explicit authorization before changing credential-routing settings.
Settings writes can redirect stored provider credentials to a different
endpoint. When an API key is configured, loopback peer IP alone is not a
sufficient user-intent signal because a browser can reach local APIs after
DNS rebinding.
"""
api_key = _configured_api_key()
if api_key:
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Settings writes require API_AUTH_KEY or a local loopback client",
)
# ============================================================================
# Workflow Factory
# ============================================================================
# ============================================================================
# Helper Functions
# ============================================================================
def _ensure_agent_env_file() -> Path:
"""Ensure the project-local agent/.env exists."""
if not ENV_PATH.exists():
ENV_PATH.write_text("# Created by Vibe-Trading Web UI settings.\n", encoding="utf-8")
return ENV_PATH
def _strip_env_value(value: str) -> str:
"""Remove basic dotenv quotes and inline comments."""
value = value.strip()
if " #" in value:
value = value.split(" #", 1)[0].rstrip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
return value.strip()
def _read_env_values(path: Path) -> Dict[str, str]:
"""Read active KEY=value entries from a dotenv file."""
values: Dict[str, str] = {}
if not path.exists():
return values
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key:
values[key] = _strip_env_value(value)
return values
def _project_relative_path(path: Path) -> str:
"""Return a project-relative display path without leaking an absolute path."""
try:
return path.resolve().relative_to(AGENT_DIR.parent.resolve()).as_posix()
except ValueError:
return path.name
def _format_env_value(value: str) -> str:
"""Format a dotenv value without allowing multiline injection."""
if "\n" in value or "\r" in value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Environment values cannot contain newlines")
value = value.strip()
if not value:
return ""
if any(ch.isspace() for ch in value) or "#" in value:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
return value
def _write_env_values(path: Path, updates: Dict[str, str]) -> None:
"""Upsert active dotenv values while preserving comments and ordering."""
_ensure_agent_env_file()
lines = path.read_text(encoding="utf-8").splitlines()
seen: set[str] = set()
for index, raw in enumerate(lines):
stripped = raw.lstrip()
is_comment = stripped.startswith("#")
candidate = stripped[1:].lstrip() if is_comment else stripped
if "=" not in candidate:
continue
key = candidate.split("=", 1)[0].strip()
if key in updates and key not in seen:
lines[index] = f"{key}={_format_env_value(updates[key])}"
seen.add(key)
missing = [key for key in updates if key not in seen]
if missing:
if lines and lines[-1].strip():
lines.append("")
lines.append("# Updated from Web UI")
for key in missing:
lines.append(f"{key}={_format_env_value(updates[key])}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _is_configured_secret(value: str, placeholders: set[str]) -> bool:
"""Return True when a secret is set and not a documented placeholder."""
normalized = value.strip().strip('"').strip("'")
if not normalized:
return False
return normalized.lower() not in {placeholder.lower() for placeholder in placeholders}
def _coerce_float(value: str, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _coerce_int(value: str, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
# ============================================================================
# Path-parameter validation
# ============================================================================
# ``run_id`` and ``session_id`` flow directly into filesystem paths
# (``RUNS_DIR / run_id`` etc.). Restrict to a safe character class so that
# values like ``..`` or ``foo/../bar`` cannot escape the parent directory.
_SAFE_PATH_PARAM_RE = __import__("re").compile(r"^[A-Za-z0-9_-]{1,128}$")
def _validate_path_param(value: str, kind: str) -> None:
"""Reject path parameters that could escape the parent directory.
Args:
value: User-supplied path-parameter value.
kind: Parameter name, used in the error detail.
Raises:
HTTPException: 400 when ``value`` does not match the safe character
class, mirroring the existing ``_SHADOW_ID_RE`` check.
"""
if not _SAFE_PATH_PARAM_RE.fullmatch(value or ""):
raise HTTPException(status_code=400, detail=f"invalid {kind}")
# ============================================================================
# Runs routes - defined in src/api/runs_routes.py
# ============================================================================
from src.api.runs_routes import register_runs_routes # noqa: E402
register_runs_routes(app)
# Re-export for test access via api_server.*
from src.api.runs_routes import ( # noqa: F401, E402
_load_json_file,
_load_csv_to_dict,
_build_response_from_run_dir,
)
# ============================================================================
# Session service (shared by session routes, channels, scheduled, live)
# ============================================================================
_session_service = None
_channel_runtime = None
_channel_bus = None
_channel_manager = None
def _get_session_service():
"""Lazy-init session service when ENABLE_SESSION_RUNTIME=true."""
global _session_service
if _session_service is not None:
return _session_service
if os.getenv("ENABLE_SESSION_RUNTIME", "true").lower() != "true":
return None
import asyncio
from src.session.store import SessionStore
from src.session.events import EventBus
from src.session.service import SessionService
store = SessionStore(base_dir=SESSIONS_DIR)
event_bus = EventBus()
try:
loop = asyncio.get_event_loop()
event_bus.set_loop(loop)
except RuntimeError:
pass
_session_service = SessionService(
store=store,
event_bus=event_bus,
runs_dir=RUNS_DIR,
)
return _session_service
def _get_channel_runtime():
"""Lazy-init IM channel runtime without starting platform adapters."""
global _channel_runtime, _channel_bus, _channel_manager
if _channel_runtime is not None:
return _channel_runtime
from src.channels.bus.queue import MessageBus
from src.channels.config import load_channels_config
from src.channels.manager import ChannelManager
from src.channels.runtime import ChannelRuntime
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
_channel_bus = MessageBus()
config = load_channels_config()
_channel_manager = ChannelManager(config, _channel_bus, session_service=svc)
_channel_runtime = ChannelRuntime(
bus=_channel_bus,
session_service=svc,
manager=_channel_manager,
reply_timeout_s=config["reply_timeout_s"],
)
return _channel_runtime
# ============================================================================
# Session routes - defined in src/api/sessions_routes.py
# ============================================================================
from src.api.sessions_routes import register_sessions_routes # noqa: E402
register_sessions_routes(app)
# Re-export for test monkeypatch compatibility
from src.api.sessions_routes import ( # noqa: F401, E402
_goal_store,
_live_action_frame_from_tool_result,
_mandate_proposal_frame_from_tool_result,
)
# ============================================================================
# System routes - defined in src/api/system_routes.py
# ============================================================================
from src.api.system_routes import register_system_routes # noqa: E402
register_system_routes(app)
# Re-export for test monkeypatch compatibility
from src.api.system_routes import _terminate_current_process # noqa: F401, E402
# ============================================================================
# Settings routes - defined in src/api/settings_routes.py
# ============================================================================
from src.api.settings_routes import register_settings_routes # noqa: E402
register_settings_routes(app)
# Re-export for test monkeypatch compatibility
from src.api.settings_routes import ( # noqa: F401, E402
_baostock_supported,
_baostock_installed,
_load_llm_providers,
)
# ============================================================================
# Upload routes - defined in src/api/uploads_routes.py
# ============================================================================
from src.api.uploads_routes import register_uploads_routes # noqa: E402
register_uploads_routes(app)
# Re-export upload constants for test access via ``api_server.*``.
from src.api.uploads_routes import ( # noqa: E402
MAX_UPLOAD_SIZE,
UPLOADS_DIR,
_BLOCKED_UPLOAD_EXT,
_BLOCKED_UPLOAD_NAMES,
_SHADOW_ID_RE,
_UPLOAD_CHUNK_SIZE,
)
# ============================================================================
# Channel routes registration - after require_auth is defined
# ============================================================================
from src.api.channels_routes import register_channels_routes # noqa: E402
register_channels_routes(app)
from src.api.qveris_routes import qveris_router # noqa: E402 # QVERIS-INTEGRATION
app.include_router(qveris_router) # QVERIS-INTEGRATION
# Re-export for test monkeypatch compatibility
from src.api.channels_routes import ( # noqa: F401, E402
ChannelPairingCommandRequest,
)
# ============================================================================
# Swarm routes - defined in src/api/swarm_routes.py
# ============================================================================
from src.api.swarm_routes import register_swarm_routes # noqa: E402
register_swarm_routes(app)
# Re-export for test monkeypatch compatibility
from src.api.swarm_routes import _get_swarm_runtime # noqa: F401, E402
# ============================================================================
# Live trading routes - defined in src/api/live_routes.py
# ============================================================================
from src.api.live_routes import register_live_routes # noqa: E402
register_live_routes(app)
# Re-export for test monkeypatch compatibility
from src.api.live_routes import ( # noqa: F401, E402
CommitMandateRequest,
LiveHaltRequest,
LiveAuthorizeRequest,
LiveRunnerControlRequest,
BrokerAuthState,
MandateLimits,
ActiveMandateState,
RunnerLivenessState,
LiveBrokerStatus,
LiveStatusResponse,
LiveRunnerUnavailable,
_runner_tasks,
_runner_factory,
_emit_live_event,
_fetch_broker_ceilings,
_known_live_brokers,
_oauth_token_present,
_active_mandate_state,
_runner_liveness_state,
_live_broker_adapter,
_build_live_runner,
_drive_runner,
)
# ============================================================================
# Alpha Zoo routes (Web UI) — defined in src/api/alpha_routes.py
# ============================================================================
from src.api.alpha_routes import register_alpha_routes # noqa: E402
register_alpha_routes(app)
# ============================================================================
# Scheduled Research Routes - defined in src/api/scheduled_routes.py
# ============================================================================
#
# Lightweight CRUD endpoints backed by ScheduledResearchJobStore. The endpoint
# handlers only record and expose jobs; the optional executor lifecycle is
# guarded separately by VIBE_TRADING_ENABLE_SCHEDULER.
from src.api.scheduled_routes import register_scheduled_routes # noqa: E402
register_scheduled_routes(app)
# Re-exported for backward-compatibility / external consumers
from src.api.scheduled_routes import ( # noqa: E402, F401
CreateScheduledRunRequest,
ScheduledRunResponse,
_dispatch_scheduled_research_job,
_get_scheduled_research_executor,
_get_scheduled_research_store,
_scheduled_research_scheduler_enabled,
)
# ============================================================================
# Main Entry Point
# ============================================================================
def serve_main(argv: list[str] | None = None) -> int:
"""Start the API server from CLI-style arguments."""
import argparse
import subprocess
import uvicorn
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
class SPAStaticFiles(StaticFiles):
"""Serve index.html for browser refreshes on client-side routes."""
async def get_response(self, path: str, scope: Dict[str, Any]):
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code != status.HTTP_404_NOT_FOUND:
raise
return await super().get_response("index.html", scope)
parser = argparse.ArgumentParser(description="Vibe-Trading Server")
parser.add_argument("--port", type=int, default=8000, help="Listen port (default 8000)")
parser.add_argument("--host", default="127.0.0.1", help="Bind address")
parser.add_argument("--dev", action="store_true", help="Dev mode: spawn Vite on :5173")
try:
args = parser.parse_args(argv)
except SystemExit as exc:
return int(exc.code) if isinstance(exc.code, int) else 2
if not _is_loopback_bind_host(args.host) and not _configured_api_key():
print(
f"[warn] Binding to {args.host} without API_AUTH_KEY set. "
f"Remote requests are rejected by the loopback peer-IP check, "
f"but consider using --host 127.0.0.1 for local-only access."
)
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
frontend_root = Path(__file__).resolve().parent.parent / "frontend"
vite_proc = None
if args.dev and frontend_root.exists():
print("[dev] Starting Vite dev server on :5173 ...")
vite_proc = subprocess.Popen(
["npx", "vite", "--host", "0.0.0.0"],
cwd=str(frontend_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print(f"[dev] Vite PID={vite_proc.pid}")
print("[dev] Frontend: http://localhost:5173")
print(f"[dev] API: http://localhost:{args.port}")
elif frontend_dist.exists():
if not any(route.path == "/" for route in app.routes):
app.mount("/", SPAStaticFiles(directory=str(frontend_dist), html=True), name="frontend")
print(f"[prod] Frontend served from {frontend_dist}")
else:
print(f"[warn] No frontend build found at {frontend_dist}")
print("[warn] Run: cd frontend && npm run build")
print("=" * 50)
print(" Vibe-Trading Server")
print(f" http://127.0.0.1:{args.port}")
print("=" * 50)
try:
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
finally:
if vite_proc:
vite_proc.terminate()
print("[dev] Vite stopped")
return 0
if __name__ == "__main__":
raise SystemExit(serve_main())