coderouter-cli 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/__init__.py +17 -0
- coderouter/__main__.py +6 -0
- coderouter/adapters/__init__.py +23 -0
- coderouter/adapters/anthropic_native.py +502 -0
- coderouter/adapters/base.py +220 -0
- coderouter/adapters/openai_compat.py +395 -0
- coderouter/adapters/registry.py +17 -0
- coderouter/cli.py +345 -0
- coderouter/cli_stats.py +751 -0
- coderouter/config/__init__.py +10 -0
- coderouter/config/capability_registry.py +339 -0
- coderouter/config/env_file.py +295 -0
- coderouter/config/loader.py +73 -0
- coderouter/config/schemas.py +515 -0
- coderouter/data/__init__.py +7 -0
- coderouter/data/model-capabilities.yaml +86 -0
- coderouter/doctor.py +1596 -0
- coderouter/env_security.py +434 -0
- coderouter/errors.py +29 -0
- coderouter/ingress/__init__.py +5 -0
- coderouter/ingress/anthropic_routes.py +205 -0
- coderouter/ingress/app.py +144 -0
- coderouter/ingress/dashboard_routes.py +493 -0
- coderouter/ingress/metrics_routes.py +92 -0
- coderouter/ingress/openai_routes.py +153 -0
- coderouter/logging.py +315 -0
- coderouter/metrics/__init__.py +39 -0
- coderouter/metrics/collector.py +471 -0
- coderouter/metrics/prometheus.py +221 -0
- coderouter/output_filters.py +407 -0
- coderouter/routing/__init__.py +13 -0
- coderouter/routing/auto_router.py +244 -0
- coderouter/routing/capability.py +285 -0
- coderouter/routing/fallback.py +611 -0
- coderouter/translation/__init__.py +57 -0
- coderouter/translation/anthropic.py +204 -0
- coderouter/translation/convert.py +1291 -0
- coderouter/translation/tool_repair.py +236 -0
- coderouter_cli-1.7.0.dist-info/METADATA +509 -0
- coderouter_cli-1.7.0.dist-info/RECORD +43 -0
- coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
- coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
- coderouter_cli-1.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""FastAPI app factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from coderouter import __version__
|
|
12
|
+
from coderouter.config import load_config
|
|
13
|
+
from coderouter.ingress.anthropic_routes import router as anthropic_router
|
|
14
|
+
from coderouter.ingress.dashboard_routes import router as dashboard_router
|
|
15
|
+
from coderouter.ingress.metrics_routes import router as metrics_router
|
|
16
|
+
from coderouter.ingress.openai_routes import router as openai_router
|
|
17
|
+
from coderouter.logging import configure_logging, get_logger
|
|
18
|
+
from coderouter.metrics import install_collector
|
|
19
|
+
from coderouter.routing import FallbackEngine
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_app(config_path: str | None = None) -> FastAPI:
|
|
25
|
+
"""Build a FastAPI app with routes, engine, and lifespan installed.
|
|
26
|
+
|
|
27
|
+
``config_path`` (optional) is passed through to
|
|
28
|
+
:func:`coderouter.config.load_config`; when ``None`` the loader
|
|
29
|
+
falls through to ``$CODEROUTER_CONFIG`` / ``./providers.yaml``. The
|
|
30
|
+
engine and config are attached to ``app.state`` so route handlers
|
|
31
|
+
can reach them without re-parsing YAML per request.
|
|
32
|
+
"""
|
|
33
|
+
configure_logging()
|
|
34
|
+
# v1.5-A: attach the MetricsCollector before the first log line so the
|
|
35
|
+
# startup ``coderouter-startup`` record is already counted. Idempotent,
|
|
36
|
+
# so multiple create_app() calls (tests) don't stack handlers.
|
|
37
|
+
install_collector()
|
|
38
|
+
config = load_config(config_path)
|
|
39
|
+
engine = FallbackEngine(config)
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
43
|
+
"""Log a structured startup line, yield to serve, log shutdown.
|
|
44
|
+
|
|
45
|
+
The startup payload captures the effective default profile and
|
|
46
|
+
whether it came from the YAML file or from ``$CODEROUTER_MODE``
|
|
47
|
+
— useful when a shell env is unknowingly overriding the
|
|
48
|
+
committed config.
|
|
49
|
+
"""
|
|
50
|
+
# v0.6-A: surface the effective default_profile + where it came from,
|
|
51
|
+
# so operators can tell at a glance whether a shell env is driving the
|
|
52
|
+
# server ("oh, my .envrc set CODEROUTER_MODE") vs the YAML file
|
|
53
|
+
# ("default_profile: coding was committed").
|
|
54
|
+
mode_source = "env" if os.environ.get("CODEROUTER_MODE", "").strip() else "config"
|
|
55
|
+
logger.info(
|
|
56
|
+
"coderouter-startup",
|
|
57
|
+
extra={
|
|
58
|
+
"version": __version__,
|
|
59
|
+
"providers": [p.name for p in config.providers],
|
|
60
|
+
"profiles": [pr.name for pr in config.profiles],
|
|
61
|
+
"allow_paid": config.allow_paid,
|
|
62
|
+
"default_profile": config.default_profile,
|
|
63
|
+
"mode_source": mode_source,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
yield
|
|
67
|
+
logger.info("coderouter-shutdown")
|
|
68
|
+
|
|
69
|
+
app = FastAPI(
|
|
70
|
+
title="CodeRouter",
|
|
71
|
+
version=__version__,
|
|
72
|
+
description="Local-first, free-first, fallback-built-in LLM router.",
|
|
73
|
+
lifespan=lifespan,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Inject engine + config so route handlers can reach them via app.state
|
|
77
|
+
app.state.engine = engine
|
|
78
|
+
app.state.config = config
|
|
79
|
+
|
|
80
|
+
@app.get("/healthz")
|
|
81
|
+
async def healthz() -> dict[str, object]:
|
|
82
|
+
"""Lightweight liveness / config snapshot endpoint.
|
|
83
|
+
|
|
84
|
+
Reports the running version plus the effective provider names
|
|
85
|
+
and paid-gate state. Intended for readiness probes and for
|
|
86
|
+
quick operator inspection — does NOT touch upstream providers.
|
|
87
|
+
"""
|
|
88
|
+
return {
|
|
89
|
+
"status": "ok",
|
|
90
|
+
"version": __version__,
|
|
91
|
+
"providers": [p.name for p in config.providers],
|
|
92
|
+
"allow_paid": config.allow_paid,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Claude Code and similar SDKs probe the base URL with HEAD / or GET /
|
|
96
|
+
# at startup. Return a tiny identifier instead of 404 so those probes
|
|
97
|
+
# succeed cleanly. Non-functional beyond that.
|
|
98
|
+
@app.api_route("/", methods=["GET", "HEAD"])
|
|
99
|
+
async def root() -> dict[str, str]:
|
|
100
|
+
"""Minimal identifier for SDK base-URL probes (GET / and HEAD /).
|
|
101
|
+
|
|
102
|
+
Claude Code and similar SDKs HEAD/GET the base URL at startup
|
|
103
|
+
to verify reachability. Returning a tiny JSON payload instead
|
|
104
|
+
of 404 keeps those probes from logging scary warnings.
|
|
105
|
+
"""
|
|
106
|
+
return {"service": "coderouter", "version": __version__}
|
|
107
|
+
|
|
108
|
+
app.include_router(openai_router, prefix="/v1", tags=["openai-compat"])
|
|
109
|
+
app.include_router(anthropic_router, prefix="/v1", tags=["anthropic-compat"])
|
|
110
|
+
# v1.5-A: /metrics.json sits at the root (no /v1 prefix) — metrics are not
|
|
111
|
+
# part of the OpenAI / Anthropic API surface, and Prometheus-style
|
|
112
|
+
# endpoints conventionally live at the root in v1.5-B.
|
|
113
|
+
app.include_router(metrics_router, tags=["metrics"])
|
|
114
|
+
# v1.5-D: single-page HTML view over the same collector snapshot.
|
|
115
|
+
# Same root-level mount as /metrics.json — the dashboard is a UI
|
|
116
|
+
# concern and doesn't belong under the /v1 API surface.
|
|
117
|
+
app.include_router(dashboard_router, tags=["dashboard"])
|
|
118
|
+
|
|
119
|
+
return app
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Lazy module-level `app` attribute so `uvicorn coderouter.ingress.app:app …`
|
|
123
|
+
# works, but importing this module in tests does NOT immediately load
|
|
124
|
+
# providers.yaml. The FastAPI instance is built on first attribute access.
|
|
125
|
+
#
|
|
126
|
+
# Config path is resolved then — from $CODEROUTER_CONFIG or ./providers.yaml;
|
|
127
|
+
# see coderouter.config.loader._candidate_paths for the full search order.
|
|
128
|
+
_lazy_app: FastAPI | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def __getattr__(name: str) -> object:
|
|
132
|
+
"""PEP 562 module ``__getattr__`` — lazy FastAPI instance on first access.
|
|
133
|
+
|
|
134
|
+
Makes ``uvicorn coderouter.ingress.app:app …`` work without having
|
|
135
|
+
``import coderouter.ingress.app`` load ``providers.yaml`` at import
|
|
136
|
+
time. Tests can import the module without side effects and call
|
|
137
|
+
:func:`create_app` explicitly with a temp config.
|
|
138
|
+
"""
|
|
139
|
+
global _lazy_app
|
|
140
|
+
if name == "app":
|
|
141
|
+
if _lazy_app is None:
|
|
142
|
+
_lazy_app = create_app()
|
|
143
|
+
return _lazy_app
|
|
144
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Dashboard endpoint — ``GET /dashboard`` (v1.5-D).
|
|
2
|
+
|
|
3
|
+
Single-page HTML view over the same :class:`MetricsCollector` snapshot
|
|
4
|
+
that ``/metrics.json`` exposes. Self-refreshing via vanilla JS + fetch
|
|
5
|
+
on a 2-second timer — no websocket, no SSE, no HTML fragment swapping.
|
|
6
|
+
|
|
7
|
+
Design choices (plan.md §12.3.5 + §12.3.6)
|
|
8
|
+
- **tailwind CDN only**. No React, no htmx, no d3. The original
|
|
9
|
+
design memo suggested htmx; we dropped it because `hx-swap=none`
|
|
10
|
+
+ JS listeners ends up the same size as `setInterval` + `fetch`
|
|
11
|
+
with one less CDN request and one less concept to grok.
|
|
12
|
+
- **One file, no separate template dir**. The HTML template is a
|
|
13
|
+
module-level string rendered via ``.format()``. Keeps the route
|
|
14
|
+
wiring trivial (no Jinja2, no StaticFiles mount).
|
|
15
|
+
- **Sparkline is hand-rolled SVG**. 60-sample ring in JS, polyline
|
|
16
|
+
with stroke + gradient area. Same trick as the static mockup.
|
|
17
|
+
- **Dark theme default**. Matches the TUI and avoids flashing
|
|
18
|
+
white on dev monitors.
|
|
19
|
+
- **No per-user state**. Dashboard is stateless-on-server — every
|
|
20
|
+
render reads the same shared MetricsCollector; the ring of
|
|
21
|
+
throughput samples lives client-side in JS.
|
|
22
|
+
- **v1.5-E: configurable display timezone**. The underlying
|
|
23
|
+
``/metrics.json`` snapshot keeps UTC ISO timestamps (stable wire
|
|
24
|
+
format); conversion to ``Asia/Tokyo`` or any IANA zone happens
|
|
25
|
+
client-side via ``Intl.DateTimeFormat`` when
|
|
26
|
+
``config.display_timezone`` is set. Unset → UTC.
|
|
27
|
+
|
|
28
|
+
Why inline JS / CSS
|
|
29
|
+
This dashboard is served from the same process as the API, on
|
|
30
|
+
``localhost:4040`` by default. External hosting / CSP is explicitly
|
|
31
|
+
out of scope (§12.3.7: "認証は v1.5 スコープ外, reverse proxy で
|
|
32
|
+
挟む前提"). Inlining keeps the single-file shipping story clean.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from fastapi import APIRouter
|
|
38
|
+
from fastapi.responses import HTMLResponse
|
|
39
|
+
|
|
40
|
+
router = APIRouter()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Static HTML template.
|
|
45
|
+
#
|
|
46
|
+
# Single-page document with tailwind (CDN) for styling and a ~120-line
|
|
47
|
+
# inline script that fetches /metrics.json every 2s and updates the DOM
|
|
48
|
+
# in place. All data-bound elements carry a ``data-bind="<path>"``
|
|
49
|
+
# attribute so the updater is a single generic walker instead of 40
|
|
50
|
+
# hand-written ``document.getElementById()`` calls.
|
|
51
|
+
#
|
|
52
|
+
# The template uses ``{{`` / ``}}`` to escape CSS / JS braces through
|
|
53
|
+
# Python's ``.format()`` — currently there are no format slots (this
|
|
54
|
+
# page takes no server-side variables), so strictly ``.format()`` is a
|
|
55
|
+
# no-op. Left in place as an extension point for future server-side
|
|
56
|
+
# injection (e.g. embedding the initial snapshot to eliminate the
|
|
57
|
+
# first-poll flicker).
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DASHBOARD_HTML = r"""<!doctype html>
|
|
62
|
+
<html lang="en">
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="utf-8" />
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
66
|
+
<title>CodeRouter Dashboard</title>
|
|
67
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
68
|
+
<style>
|
|
69
|
+
.spark { width: 100%; height: 60px; }
|
|
70
|
+
.dot { width: .5rem; height: .5rem; border-radius: 9999px; display: inline-block; }
|
|
71
|
+
.tabnum { font-variant-numeric: tabular-nums; }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body class="bg-slate-950 text-slate-100 min-h-screen font-sans">
|
|
75
|
+
|
|
76
|
+
<header class="border-b border-slate-800 px-6 py-3">
|
|
77
|
+
<div class="max-w-7xl mx-auto flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
|
78
|
+
<span class="text-lg font-semibold tracking-tight">CodeRouter</span>
|
|
79
|
+
<span class="text-slate-400">profile: <span data-bind="profile" class="text-slate-100 font-mono">—</span></span>
|
|
80
|
+
<span class="text-slate-400">uptime: <span data-bind="uptime" class="text-slate-100 font-mono tabnum">—</span></span>
|
|
81
|
+
<span class="text-slate-400">requests: <span data-bind="requests_total" class="text-slate-100 font-mono tabnum">0</span></span>
|
|
82
|
+
<span class="text-slate-400">tz: <span data-bind="display_timezone" class="text-slate-100 font-mono">UTC</span></span>
|
|
83
|
+
<span id="health-badge" class="ml-auto inline-flex items-center gap-2 text-slate-400">
|
|
84
|
+
<span class="dot bg-slate-500"></span><span data-bind="health_text">connecting…</span>
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
</header>
|
|
88
|
+
|
|
89
|
+
<main class="max-w-7xl mx-auto p-4 md:p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
90
|
+
|
|
91
|
+
<!-- Panel 1: Providers -->
|
|
92
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
93
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Providers</h2>
|
|
94
|
+
<table class="w-full text-sm tabnum">
|
|
95
|
+
<thead class="text-slate-500">
|
|
96
|
+
<tr class="text-left">
|
|
97
|
+
<th class="font-medium pb-2">provider</th>
|
|
98
|
+
<th class="font-medium pb-2 text-right">att</th>
|
|
99
|
+
<th class="font-medium pb-2 text-right">ok%</th>
|
|
100
|
+
<th class="font-medium pb-2 text-right">failed</th>
|
|
101
|
+
<th class="font-medium pb-2">last error</th>
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody id="providers-body" class="divide-y divide-slate-800">
|
|
105
|
+
<tr><td colspan="5" class="py-3 text-slate-500">no requests seen yet</td></tr>
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</section>
|
|
109
|
+
|
|
110
|
+
<!-- Panel 2: Fallback & Gates -->
|
|
111
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
112
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Fallback & Gates</h2>
|
|
113
|
+
<div class="grid grid-cols-2 gap-3">
|
|
114
|
+
<div class="rounded-md bg-slate-800/50 p-3">
|
|
115
|
+
<div class="text-xs text-slate-400">Fallback rate</div>
|
|
116
|
+
<div class="text-2xl font-semibold tabnum" data-bind="fallback_rate">0.0%</div>
|
|
117
|
+
<div class="text-xs text-slate-500 tabnum" data-bind="fallback_fraction">0 / 0</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="rounded-md bg-slate-800/50 p-3">
|
|
120
|
+
<div class="text-xs text-slate-400">Paid-gate blocked</div>
|
|
121
|
+
<div class="text-2xl font-semibold tabnum" data-bind="paid_gate_blocked">0</div>
|
|
122
|
+
<div class="text-xs text-slate-500" data-bind="allow_paid_state">ALLOW_PAID=?</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="rounded-md bg-slate-800/50 p-3">
|
|
125
|
+
<div class="text-xs text-slate-400">Capability degraded</div>
|
|
126
|
+
<div class="text-2xl font-semibold tabnum" data-bind="degraded_total">0</div>
|
|
127
|
+
<div class="text-xs text-slate-500" data-bind="degraded_breakdown">—</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="rounded-md bg-slate-800/50 p-3">
|
|
130
|
+
<div class="text-xs text-slate-400">Output-filter applied</div>
|
|
131
|
+
<div class="text-2xl font-semibold tabnum" data-bind="filters_total">0</div>
|
|
132
|
+
<div class="text-xs text-slate-500" data-bind="filters_breakdown">—</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</section>
|
|
136
|
+
|
|
137
|
+
<!-- Panel 3: Throughput sparkline -->
|
|
138
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
139
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Requests / min (last 60 samples)</h2>
|
|
140
|
+
<div class="flex items-baseline gap-4 mb-2">
|
|
141
|
+
<span class="text-3xl font-semibold tabnum text-green-400" data-bind="rate_last">0</span>
|
|
142
|
+
<span class="text-xs text-slate-500 tabnum" data-bind="rate_meta">avg 0 · peak 0</span>
|
|
143
|
+
</div>
|
|
144
|
+
<svg class="spark" viewBox="0 0 300 60" preserveAspectRatio="none" aria-hidden="true">
|
|
145
|
+
<defs>
|
|
146
|
+
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
|
147
|
+
<stop offset="0%" stop-color="#22c55e" stop-opacity="0.35" />
|
|
148
|
+
<stop offset="100%" stop-color="#22c55e" stop-opacity="0" />
|
|
149
|
+
</linearGradient>
|
|
150
|
+
</defs>
|
|
151
|
+
<polygon id="spark-area" fill="url(#g)" points="0,60 300,60" />
|
|
152
|
+
<polyline id="spark-line" fill="none" stroke="#22c55e" stroke-width="2" points="" />
|
|
153
|
+
</svg>
|
|
154
|
+
</section>
|
|
155
|
+
|
|
156
|
+
<!-- Panel 4: Recent requests -->
|
|
157
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
158
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Recent Events</h2>
|
|
159
|
+
<ul id="recent-list" class="text-xs font-mono space-y-1 tabnum">
|
|
160
|
+
<li class="text-slate-500">no events yet</li>
|
|
161
|
+
</ul>
|
|
162
|
+
</section>
|
|
163
|
+
|
|
164
|
+
</main>
|
|
165
|
+
|
|
166
|
+
<footer class="max-w-7xl mx-auto px-4 md:px-6 pb-8">
|
|
167
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
168
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-3">Usage Mix</h2>
|
|
169
|
+
<div id="usage-bar" class="flex h-3 rounded-full overflow-hidden bg-slate-800" role="img" aria-label="usage mix"></div>
|
|
170
|
+
<div id="usage-legend" class="flex justify-between text-xs mt-2 text-slate-400 tabnum">
|
|
171
|
+
<span class="text-slate-500">no classified providers yet</span>
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
<p class="text-xs text-slate-500 mt-3">
|
|
175
|
+
v1.5-D · polls <code>/metrics.json</code> every 2s · <a class="underline" href="/metrics">Prometheus</a> · <a class="underline" href="/metrics.json">JSON</a>
|
|
176
|
+
</p>
|
|
177
|
+
</footer>
|
|
178
|
+
|
|
179
|
+
<script>
|
|
180
|
+
(() => {
|
|
181
|
+
"use strict";
|
|
182
|
+
|
|
183
|
+
// Poll interval — 2s matches plan.md §12.3.5. Short enough to feel
|
|
184
|
+
// live on a local dev machine, long enough that HTTP overhead is noise.
|
|
185
|
+
const POLL_MS = 2000;
|
|
186
|
+
// Sparkline ring — 60 samples at 2s/sample → 2 minutes of history.
|
|
187
|
+
const SPARK_POINTS = 60;
|
|
188
|
+
const sparkBuffer = [];
|
|
189
|
+
// Previous cumulative requests_total; used to derive a delta-per-poll
|
|
190
|
+
// rate that the sparkline plots. First fetch seeds the value and
|
|
191
|
+
// pushes 0, so the line starts flat and moves up when real traffic
|
|
192
|
+
// arrives (avoids a huge first spike from the "0 → N" step).
|
|
193
|
+
let prevRequestsTotal = null;
|
|
194
|
+
|
|
195
|
+
// v1.5-E: timezone formatter cache keyed by IANA zone name. Intl
|
|
196
|
+
// DateTimeFormat construction is O(ms) — cheap, but not free at 2Hz,
|
|
197
|
+
// so we memoize. A malformed zone (e.g. a typo that slipped past the
|
|
198
|
+
// server-side zoneinfo validator) would throw from the constructor;
|
|
199
|
+
// we swallow it here so the page still renders with UTC fallback
|
|
200
|
+
// instead of blanking out.
|
|
201
|
+
const _tzFormatters = new Map();
|
|
202
|
+
const getTzFormatter = (tz) => {
|
|
203
|
+
const key = tz || "UTC";
|
|
204
|
+
if (_tzFormatters.has(key)) return _tzFormatters.get(key);
|
|
205
|
+
let fmt;
|
|
206
|
+
try {
|
|
207
|
+
fmt = new Intl.DateTimeFormat("en-GB", {
|
|
208
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
209
|
+
hour12: false, timeZone: key,
|
|
210
|
+
});
|
|
211
|
+
} catch (_) {
|
|
212
|
+
fmt = null; // fall back to naive slice in fmtTs
|
|
213
|
+
}
|
|
214
|
+
_tzFormatters.set(key, fmt);
|
|
215
|
+
return fmt;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// tsUtc is the server-side "YYYY-MM-DDTHH:MM:SS" (no Z suffix — UTC
|
|
219
|
+
// by convention, matches JsonLineFormatter). Treat as UTC and render
|
|
220
|
+
// in the requested zone. If anything goes wrong (empty input, bad
|
|
221
|
+
// date, unsupported zone) we fall back to the raw HH:MM:SS slice so
|
|
222
|
+
// the panel always shows SOMETHING.
|
|
223
|
+
const fmtTs = (tsUtc, tz) => {
|
|
224
|
+
if (!tsUtc) return "";
|
|
225
|
+
const naive = tsUtc.split("T")[1] || tsUtc;
|
|
226
|
+
const fmt = getTzFormatter(tz);
|
|
227
|
+
if (!fmt) return naive;
|
|
228
|
+
const d = new Date(tsUtc + "Z");
|
|
229
|
+
if (isNaN(d.getTime())) return naive;
|
|
230
|
+
try {
|
|
231
|
+
return fmt.format(d);
|
|
232
|
+
} catch (_) {
|
|
233
|
+
return naive;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const fmtUptime = (s) => {
|
|
238
|
+
s = Math.floor(s || 0);
|
|
239
|
+
if (s < 60) return s + "s";
|
|
240
|
+
if (s < 3600) return Math.floor(s / 60) + "m " + String(s % 60).padStart(2, "0") + "s";
|
|
241
|
+
const h = Math.floor(s / 3600);
|
|
242
|
+
const m = Math.floor((s % 3600) / 60);
|
|
243
|
+
return h + "h " + String(m).padStart(2, "0") + "m";
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const fmtBreakdown = (obj) => {
|
|
247
|
+
const entries = Object.entries(obj || {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
248
|
+
return entries.length ? entries.map(([k, v]) => k + ":" + v).join(", ") : "—";
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const setBind = (key, value) => {
|
|
252
|
+
document.querySelectorAll('[data-bind="' + key + '"]').forEach((el) => {
|
|
253
|
+
el.textContent = value;
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const healthFromRate = (rate, total) => {
|
|
258
|
+
if (!total || total <= 0) return ["gray", "idle"];
|
|
259
|
+
if (rate < 5) return ["green", "healthy"];
|
|
260
|
+
if (rate < 20) return ["yellow", "degraded"];
|
|
261
|
+
return ["red", "unhealthy"];
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const healthClasses = {
|
|
265
|
+
green: { dot: "bg-green-500", text: "text-green-400" },
|
|
266
|
+
yellow: { dot: "bg-yellow-500", text: "text-yellow-400" },
|
|
267
|
+
red: { dot: "bg-red-500", text: "text-red-400" },
|
|
268
|
+
gray: { dot: "bg-slate-500", text: "text-slate-400" },
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const rowHealth = (attempts, ok, failedMid) => {
|
|
272
|
+
if (!attempts) return "gray";
|
|
273
|
+
if (failedMid > 0) return "red";
|
|
274
|
+
const r = ok / attempts;
|
|
275
|
+
if (r >= 0.95) return "green";
|
|
276
|
+
if (r >= 0.80) return "yellow";
|
|
277
|
+
return "red";
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const renderHealthBadge = (token, label) => {
|
|
281
|
+
const badge = document.getElementById("health-badge");
|
|
282
|
+
badge.className = "ml-auto inline-flex items-center gap-2 " + healthClasses[token].text;
|
|
283
|
+
const dot = badge.querySelector(".dot");
|
|
284
|
+
dot.className = "dot " + healthClasses[token].dot;
|
|
285
|
+
setBind("health_text", label);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const renderProviders = (snap) => {
|
|
289
|
+
const providers = (snap.providers || []).slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
290
|
+
const tbody = document.getElementById("providers-body");
|
|
291
|
+
if (!providers.length) {
|
|
292
|
+
tbody.innerHTML = '<tr><td colspan="5" class="py-3 text-slate-500">no requests seen yet</td></tr>';
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
tbody.innerHTML = providers.map((p) => {
|
|
296
|
+
const attempts = p.attempts || 0;
|
|
297
|
+
const outcomes = p.outcomes || {};
|
|
298
|
+
const ok = outcomes.ok || 0;
|
|
299
|
+
const failed = (outcomes.failed || 0) + (outcomes.failed_midstream || 0);
|
|
300
|
+
const okPct = attempts > 0 ? Math.round(ok * 100 / attempts) : 100;
|
|
301
|
+
const health = rowHealth(attempts, ok, outcomes.failed_midstream || 0);
|
|
302
|
+
const dotCls = healthClasses[health].dot;
|
|
303
|
+
const pctCls = healthClasses[health].text;
|
|
304
|
+
const lastErr = p.last_error
|
|
305
|
+
? (p.last_error.status ? p.last_error.status + " " : "") + (p.last_error.error || "")
|
|
306
|
+
: "—";
|
|
307
|
+
return (
|
|
308
|
+
'<tr>' +
|
|
309
|
+
'<td class="py-2"><span class="dot ' + dotCls + ' mr-2"></span>' + escapeHTML(p.name) + '</td>' +
|
|
310
|
+
'<td class="text-right">' + attempts + '</td>' +
|
|
311
|
+
'<td class="text-right ' + pctCls + '">' + okPct + '%</td>' +
|
|
312
|
+
'<td class="text-right">' + failed + '</td>' +
|
|
313
|
+
'<td class="text-slate-400 truncate max-w-[12rem]">' + escapeHTML(lastErr) + '</td>' +
|
|
314
|
+
'</tr>'
|
|
315
|
+
);
|
|
316
|
+
}).join("");
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const renderGates = (snap) => {
|
|
320
|
+
const counters = snap.counters || {};
|
|
321
|
+
const total = counters.requests_total || 0;
|
|
322
|
+
let totalFailed = 0;
|
|
323
|
+
for (const v of Object.values(counters.provider_outcomes || {})) {
|
|
324
|
+
totalFailed += (v.failed || 0) + (v.failed_midstream || 0);
|
|
325
|
+
}
|
|
326
|
+
const rate = total > 0 ? (totalFailed * 100 / total) : 0;
|
|
327
|
+
setBind("fallback_rate", rate.toFixed(1) + "%");
|
|
328
|
+
setBind("fallback_fraction", totalFailed + " / " + total);
|
|
329
|
+
setBind("paid_gate_blocked", counters.chain_paid_gate_blocked_total || 0);
|
|
330
|
+
const cfg = snap.config || {};
|
|
331
|
+
setBind("allow_paid_state", "ALLOW_PAID=" + (cfg.allow_paid ? "true" : "false"));
|
|
332
|
+
const degraded = counters.capability_degraded || {};
|
|
333
|
+
setBind("degraded_total", Object.values(degraded).reduce((a, b) => a + b, 0));
|
|
334
|
+
setBind("degraded_breakdown", fmtBreakdown(degraded));
|
|
335
|
+
const filters = counters.output_filter_applied || {};
|
|
336
|
+
setBind("filters_total", Object.values(filters).reduce((a, b) => a + b, 0));
|
|
337
|
+
setBind("filters_breakdown", fmtBreakdown(filters));
|
|
338
|
+
|
|
339
|
+
// Top-line health badge — driven by fallback rate + total.
|
|
340
|
+
const [token, label] = healthFromRate(rate, total);
|
|
341
|
+
renderHealthBadge(token, label);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const renderSparkline = (snap) => {
|
|
345
|
+
const total = (snap.counters || {}).requests_total || 0;
|
|
346
|
+
const delta = prevRequestsTotal === null ? 0 : Math.max(0, total - prevRequestsTotal);
|
|
347
|
+
prevRequestsTotal = total;
|
|
348
|
+
sparkBuffer.push(delta);
|
|
349
|
+
while (sparkBuffer.length > SPARK_POINTS) sparkBuffer.shift();
|
|
350
|
+
|
|
351
|
+
const peak = Math.max(1, ...sparkBuffer);
|
|
352
|
+
const avg = sparkBuffer.reduce((a, b) => a + b, 0) / sparkBuffer.length;
|
|
353
|
+
setBind("rate_last", delta);
|
|
354
|
+
setBind("rate_meta", "avg " + avg.toFixed(1) + " · peak " + peak);
|
|
355
|
+
|
|
356
|
+
// Map samples to SVG coordinates. viewBox is 300x60; 0 is at the
|
|
357
|
+
// top in SVG, so we invert the y-axis. When only a few samples
|
|
358
|
+
// exist we still draw — left-justified so the movement is visible.
|
|
359
|
+
const n = sparkBuffer.length;
|
|
360
|
+
const dx = n > 1 ? 300 / (SPARK_POINTS - 1) : 0;
|
|
361
|
+
const pts = sparkBuffer.map((v, i) => {
|
|
362
|
+
const x = i * dx;
|
|
363
|
+
const y = 60 - (v / peak) * 55;
|
|
364
|
+
return x.toFixed(1) + "," + y.toFixed(1);
|
|
365
|
+
});
|
|
366
|
+
document.getElementById("spark-line").setAttribute("points", pts.join(" "));
|
|
367
|
+
if (pts.length) {
|
|
368
|
+
const areaPts = ["0,60", ...pts, ((n - 1) * dx).toFixed(1) + ",60"];
|
|
369
|
+
document.getElementById("spark-area").setAttribute("points", areaPts.join(" "));
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const renderRecent = (snap) => {
|
|
374
|
+
const list = document.getElementById("recent-list");
|
|
375
|
+
const recent = (snap.recent || []).slice().reverse(); // newest first
|
|
376
|
+
if (!recent.length) {
|
|
377
|
+
list.innerHTML = '<li class="text-slate-500">no events yet</li>';
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const tz = (snap.config || {}).display_timezone || "UTC";
|
|
381
|
+
list.innerHTML = recent.slice(0, 15).map((r) => {
|
|
382
|
+
const ts = fmtTs(r.ts || "", tz);
|
|
383
|
+
const isFailure = (r.event || "").startsWith("provider-failed");
|
|
384
|
+
const rowCls = isFailure ? "bg-red-950/40 rounded px-1 " : "";
|
|
385
|
+
const statusText = (() => {
|
|
386
|
+
if (r.event === "provider-ok") return '<span class="text-green-400">ok</span>';
|
|
387
|
+
if (r.event === "try-provider") return '<span class="text-slate-400">try</span>';
|
|
388
|
+
if (isFailure) return '<span class="text-red-400">FAIL' + (r.status ? " (" + r.status + ")" : "") + '</span>';
|
|
389
|
+
return '<span class="text-slate-400">' + escapeHTML(r.event || "") + '</span>';
|
|
390
|
+
})();
|
|
391
|
+
return (
|
|
392
|
+
'<li class="' + rowCls + 'grid grid-cols-[auto_auto_1fr_auto] gap-x-3 items-center">' +
|
|
393
|
+
'<span class="text-slate-500">' + escapeHTML(ts) + '</span>' +
|
|
394
|
+
'<span class="text-slate-300">' + escapeHTML(r.event || "") + '</span>' +
|
|
395
|
+
'<span>' + escapeHTML(r.provider || "") + '</span>' +
|
|
396
|
+
statusText +
|
|
397
|
+
'</li>'
|
|
398
|
+
);
|
|
399
|
+
}).join("");
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const renderUsageMix = (snap) => {
|
|
403
|
+
const cfg = snap.config || {};
|
|
404
|
+
const byName = Object.fromEntries((cfg.providers || []).map((p) => [p.name, p]));
|
|
405
|
+
const counts = (snap.counters || {}).provider_attempts || {};
|
|
406
|
+
let local = 0, free = 0, paid = 0;
|
|
407
|
+
for (const [name, n] of Object.entries(counts)) {
|
|
408
|
+
const p = byName[name];
|
|
409
|
+
if (!p) continue;
|
|
410
|
+
if (p.paid) paid += n;
|
|
411
|
+
else if ((p.base_url || "").includes("localhost") || (p.base_url || "").includes("127.0.0.1")) local += n;
|
|
412
|
+
else free += n;
|
|
413
|
+
}
|
|
414
|
+
const total = local + free + paid;
|
|
415
|
+
const bar = document.getElementById("usage-bar");
|
|
416
|
+
const legend = document.getElementById("usage-legend");
|
|
417
|
+
if (total === 0) {
|
|
418
|
+
bar.innerHTML = "";
|
|
419
|
+
legend.innerHTML = '<span class="text-slate-500">no classified providers yet</span>';
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const pct = (x) => (x * 100 / total).toFixed(0);
|
|
423
|
+
bar.innerHTML =
|
|
424
|
+
'<div class="bg-green-500" style="width: ' + pct(local) + '%"></div>' +
|
|
425
|
+
'<div class="bg-sky-500" style="width: ' + pct(free) + '%"></div>' +
|
|
426
|
+
'<div class="bg-amber-500" style="width: ' + pct(paid) + '%"></div>';
|
|
427
|
+
legend.innerHTML =
|
|
428
|
+
'<span><span class="dot bg-green-500 mr-1"></span>local ' + pct(local) + '% (' + local + ')</span>' +
|
|
429
|
+
'<span><span class="dot bg-sky-500 mr-1"></span>free ' + pct(free) + '% (' + free + ')</span>' +
|
|
430
|
+
'<span><span class="dot bg-amber-500 mr-1"></span>paid ' + pct(paid) + '% (' + paid + ')</span>';
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const escapeHTML = (s) => String(s).replace(/[&<>"']/g, (c) => (
|
|
434
|
+
{"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]
|
|
435
|
+
));
|
|
436
|
+
|
|
437
|
+
const renderSnapshot = (snap) => {
|
|
438
|
+
const startup = snap.startup || {};
|
|
439
|
+
const cfg = snap.config || {};
|
|
440
|
+
const profile = startup.default_profile || cfg.default_profile || "—";
|
|
441
|
+
setBind("profile", profile);
|
|
442
|
+
setBind("uptime", fmtUptime(snap.uptime_s || 0));
|
|
443
|
+
setBind("requests_total", (snap.counters || {}).requests_total || 0);
|
|
444
|
+
// v1.5-E: surface the active display TZ in the header so operators
|
|
445
|
+
// can tell at a glance "I'm looking at events in Asia/Tokyo" vs UTC.
|
|
446
|
+
setBind("display_timezone", cfg.display_timezone || "UTC");
|
|
447
|
+
|
|
448
|
+
renderProviders(snap);
|
|
449
|
+
renderGates(snap);
|
|
450
|
+
renderSparkline(snap);
|
|
451
|
+
renderRecent(snap);
|
|
452
|
+
renderUsageMix(snap);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const renderError = (msg) => {
|
|
456
|
+
renderHealthBadge("red", "error: " + msg);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const poll = async () => {
|
|
460
|
+
try {
|
|
461
|
+
const resp = await fetch("/metrics.json", {cache: "no-store"});
|
|
462
|
+
if (!resp.ok) {
|
|
463
|
+
renderError("HTTP " + resp.status);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const snap = await resp.json();
|
|
467
|
+
renderSnapshot(snap);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
renderError(String(e && e.message || e));
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
poll();
|
|
474
|
+
setInterval(poll, POLL_MS);
|
|
475
|
+
})();
|
|
476
|
+
</script>
|
|
477
|
+
|
|
478
|
+
</body>
|
|
479
|
+
</html>
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@router.get("/dashboard", response_class=HTMLResponse)
|
|
484
|
+
async def dashboard() -> HTMLResponse:
|
|
485
|
+
"""Serve the single-page dashboard HTML.
|
|
486
|
+
|
|
487
|
+
The page is entirely static — all data comes from polling
|
|
488
|
+
``/metrics.json`` on a 2s timer (see the inline script). We return
|
|
489
|
+
an :class:`HTMLResponse` directly (not a ``FileResponse``) because
|
|
490
|
+
the template lives in this module as a string, keeping the shipping
|
|
491
|
+
unit a single Python file with no template dir to configure.
|
|
492
|
+
"""
|
|
493
|
+
return HTMLResponse(content=_DASHBOARD_HTML)
|