diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index cc6d0df..733cdde 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -48,6 +48,7 @@ jobs:
WORKER_URL: ${{ secrets.WORKER_URL }}
LITELLM_URL: ${{ secrets.LITELLM_URL }}
PROXY_GUEST_TOKEN: ${{ secrets.PROXY_GUEST_TOKEN }}
+ TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }}
run: |
python3 <<'PY'
import json
@@ -61,6 +62,7 @@ jobs:
endpoints.append(val)
guest_token = os.environ.get("PROXY_GUEST_TOKEN") or "llm-fallbacks-public"
+ turnstile_site_key = (os.environ.get("TURNSTILE_SITE_KEY") or "").strip()
chat_proxy_path = Path("configs/chat_proxy.json")
if chat_proxy_path.is_file():
chat_proxy = json.loads(chat_proxy_path.read_text(encoding="utf-8"))
@@ -80,6 +82,8 @@ jobs:
"maxTokens": 512,
"appVersion": os.environ.get("APP_VERSION", ""),
}
+ if turnstile_site_key:
+ cfg["turnstileSiteKey"] = turnstile_site_key
body = "window.LLM_FALLBACKS_CONFIG = " + json.dumps(cfg, indent=2) + ";\n"
open("docs/config.js", "w", encoding="utf-8").write(body)
PY
@@ -123,7 +127,7 @@ jobs:
run: |
cd webui && npm ci && npm run build
cd ..
- npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts
+ npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts
- name: Run live Pages chat e2e (real proxy)
env:
diff --git a/CONCEPTS.md b/CONCEPTS.md
index cce3ac3..57ed00b 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -34,8 +34,10 @@ Shared vocabulary for the static chat gateway and Python library.
|------|---------|
| **Render secondary** | LiteLLM on Render (`llm-fallbacks-gateway.onrender.com` pattern) |
| **loadRuntimeConfig** | `readRuntimeConfig()` + `mergeChatProxyArtifact()` — single bootstrap path for `FailoverProvider` |
-| **Routing chip** | Per-reply UI showing endpoint, resolved model, and fallback hops (planned) |
+| **Routing chip** | Per-reply UI showing endpoint, resolved model, and fallback hops |
| **Model selector** | Composer picker over `free_models.json`; separate from Failover endpoint settings |
+| **Endpoint health probe** | Client-side GET to `/health` (or LiteLLM `/health/liveliness` on Render); ok / slow / fail |
+| **Turnstile session** | Optional bot check at Worker; 1h KV pass per IP after successful siteverify |
## Learnings index
diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md
index aca2e73..80640bb 100644
--- a/docs/CAVEATS.md
+++ b/docs/CAVEATS.md
@@ -13,6 +13,8 @@ Honest limits for the public chat demo and the Python library.
| **Backup server auth** | The browser sends the same guest token to every endpoint. The Worker accepts it; the Render backup may return 401 until a LiteLLM virtual key is set up. |
| **Saved settings** | If you changed proxy URLs in the browser, old values stay in localStorage. Clear site data if the failover list looks wrong after an update. |
| **Usage stats** | We count sessions and completions without storing message text. This is not full product analytics. |
+| **Routing headers** | The routing chip reads `x-llm-fallbacks-endpoint` and LiteLLM headers from proxy responses. After edge changes, redeploy the Worker (`Deploy Proxies` workflow) for production to expose them. |
+| **Turnstile** | Optional bot check when `TURNSTILE_SECRET` is set on the Worker and `turnstileSiteKey` is in `docs/config.js`. Skipped in local dev when secrets are absent. |
## Library and CI
diff --git a/docs/assets/chat.js b/docs/assets/chat.js
index 38c30dc..80dde14 100644
--- a/docs/assets/chat.js
+++ b/docs/assets/chat.js
@@ -5338,6 +5338,166 @@ function shouldFallbackToProxy(browserErr) {
return msg === "BROWSER_UNAVAILABLE" || msg === "PROXY_UNAVAILABLE" || /^no API key for /i.test(msg) || /^unsupported provider:/i.test(msg);
}
+// src/turnstile-session.ts
+var siteKey;
+var widgetId;
+var currentToken = null;
+var loadPromise = null;
+var pendingResolve = null;
+var SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback";
+function loadTurnstileScript() {
+ if (loadPromise) return loadPromise;
+ loadPromise = new Promise((resolve, reject) => {
+ if (window.turnstile) {
+ resolve();
+ return;
+ }
+ window.onloadTurnstileCallback = () => resolve();
+ const script = document.createElement("script");
+ script.src = SCRIPT_URL;
+ script.async = true;
+ script.defer = true;
+ script.onerror = () => reject(new Error("Turnstile script failed to load"));
+ document.head.appendChild(script);
+ });
+ return loadPromise;
+}
+function renderWidget(mount) {
+ if (!window.turnstile || !siteKey || widgetId) return;
+ widgetId = window.turnstile.render(mount, {
+ sitekey: siteKey,
+ size: "compact",
+ theme: "dark",
+ callback: (token) => {
+ currentToken = token;
+ pendingResolve?.(token);
+ pendingResolve = null;
+ },
+ "error-callback": () => {
+ pendingResolve?.("");
+ pendingResolve = null;
+ }
+ });
+}
+function initTurnstile(key, mount) {
+ siteKey = key?.trim() || void 0;
+ if (!siteKey) return;
+ void loadTurnstileScript().then(() => renderWidget(mount));
+}
+async function ensureTurnstileToken() {
+ if (!siteKey) return void 0;
+ if (currentToken) return currentToken;
+ await loadTurnstileScript();
+ if (!widgetId || !window.turnstile) return void 0;
+ return new Promise((resolve) => {
+ pendingResolve = (token) => resolve(token || void 0);
+ window.turnstile.execute(widgetId);
+ });
+}
+
+// src/health-probe.ts
+var SLOW_MS = 2e3;
+var TIMEOUT_MS = 5e3;
+function healthPathForBase(base) {
+ const trimmed = base.replace(/\/$/, "");
+ try {
+ const host = new URL(trimmed).hostname;
+ if (host.includes("onrender.com")) {
+ return `${trimmed}/health/liveliness`;
+ }
+ } catch {
+ }
+ return `${trimmed}/health`;
+}
+async function probeEndpoint(base, fetchFn = fetch) {
+ const url = healthPathForBase(base);
+ const start = Date.now();
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetchFn(url, { method: "GET", signal: controller.signal, mode: "cors" });
+ const ms = Date.now() - start;
+ clearTimeout(timeout);
+ if (res.status === 401 || res.status === 403) {
+ return { state: "fail", ms, statusCode: res.status, authFailure: true };
+ }
+ if (!res.ok) {
+ return { state: "fail", ms, statusCode: res.status };
+ }
+ if (ms > SLOW_MS) {
+ return { state: "slow", ms, statusCode: res.status };
+ }
+ return { state: "ok", ms, statusCode: res.status };
+ } catch {
+ clearTimeout(timeout);
+ return { state: "fail", ms: Date.now() - start };
+ }
+}
+
+// src/plugins/status-strip/index.ts
+function StatusStripPlugin() {
+ return {
+ name: "status-strip",
+ onMount() {
+ const mount = document.getElementById("lfStatusStrip") ?? (() => {
+ const el2 = document.createElement("div");
+ el2.id = "lfStatusStrip";
+ el2.className = "lf-status-strip";
+ el2.setAttribute("aria-live", "polite");
+ const credits = document.querySelector(".credits-content");
+ const actions = credits?.querySelector(".credits-actions");
+ if (credits && actions) {
+ credits.insertBefore(el2, actions);
+ } else {
+ document.body.prepend(el2);
+ }
+ return el2;
+ })();
+ void refreshStatusStrip(mount);
+ }
+ };
+}
+async function refreshStatusStrip(mount) {
+ const config = await loadRuntimeConfig();
+ const base = config.endpoints[0];
+ if (!base) {
+ mount.hidden = true;
+ return;
+ }
+ mount.hidden = false;
+ mount.textContent = "Checking proxy\u2026";
+ let healthOk = false;
+ try {
+ const healthUrl = healthPathForBase(base);
+ const healthRes = await fetch(healthUrl, { method: "GET", mode: "cors" });
+ healthOk = healthRes.ok;
+ } catch {
+ healthOk = false;
+ }
+ let chatCount = 0;
+ try {
+ const metricsUrl = `${base.replace(/\/$/, "")}/v1/metrics?days=1`;
+ const metricsRes = await fetch(metricsUrl, {
+ headers: { Authorization: `Bearer ${config.guestToken}` }
+ });
+ if (metricsRes.ok) {
+ const metrics = await metricsRes.json();
+ chatCount = metrics.events?.chat_completion_success ?? 0;
+ }
+ } catch {
+ }
+ const dotClass = healthOk ? "lf-status-ok" : "lf-status-fail";
+ const statusText = healthOk ? "Proxy OK" : "Proxy unreachable";
+ const countText = chatCount > 0 ? ` \xB7 ${chatCount} chat${chatCount === 1 ? "" : "s"} today` : "";
+ mount.innerHTML = `${statusText}${countText}`;
+}
+function showRateLimitBanner(seconds) {
+ const mount = document.getElementById("lfStatusStrip");
+ if (!mount) return;
+ const suffix = seconds !== void 0 && seconds > 0 ? ` Try again in ${seconds} second${seconds === 1 ? "" : "s"}.` : " Wait and try again.";
+ mount.innerHTML = `Rate limited.${suffix}`;
+}
+
// src/providers/errors.ts
var ChatRouteError = class extends Error {
kind;
@@ -5379,13 +5539,48 @@ var ProxyUnavailableError = class extends ChatRouteError {
};
var QUOTA_RE = /quota|credit|insufficient|billing|exhausted/i;
var COLD_START_RE = /cold start|starting up|still deploying|proxy pending|503|502/i;
-function mapHttpError(status, bodyText, endpoint) {
+function formatRateLimitMessage(endpoint, info) {
+ const scope = info?.scope;
+ const seconds = info?.retryAfterSeconds;
+ let prefix = `Rate limit exceeded at ${endpoint}.`;
+ if (scope === "day") {
+ prefix = `Daily rate limit reached at ${endpoint}.`;
+ } else if (scope === "minute") {
+ prefix = `Per-minute rate limit reached at ${endpoint}.`;
+ }
+ if (seconds !== void 0 && seconds > 0) {
+ const unit = seconds === 1 ? "second" : "seconds";
+ return `${prefix} Try again in ${seconds} ${unit}.`;
+ }
+ return `${prefix} Wait and try again.`;
+}
+function parseRateLimitScope(bodyText) {
+ try {
+ const parsed = JSON.parse(bodyText);
+ const message = parsed.error?.message ?? "";
+ if (/daily|per day|\(day\)/i.test(message)) return "day";
+ if (/minute|\(minute\)/i.test(message)) return "minute";
+ if (parsed.error?.type === "rate_limit") {
+ if (/day/i.test(message)) return "day";
+ if (/minute/i.test(message)) return "minute";
+ }
+ } catch {
+ }
+ return void 0;
+}
+function mapHttpError(status, bodyText, endpoint, rateLimit) {
const snippet = bodyText.slice(0, 200);
if (status === 401 || status === 403) {
+ if (/turnstile/i.test(bodyText)) {
+ return new AuthError(
+ `Turnstile verification required at ${endpoint}. Complete the check and try again.`
+ );
+ }
return new AuthError(`Authentication failed at ${endpoint}. Check your guest token in Server settings.`);
}
if (status === 429) {
- return new RateLimitError(`Rate limit exceeded at ${endpoint}. Wait and try again.`);
+ const scope = rateLimit?.scope ?? parseRateLimitScope(bodyText);
+ return new RateLimitError(formatRateLimitMessage(endpoint, { ...rateLimit, scope }));
}
if (QUOTA_RE.test(bodyText)) {
return new QuotaError(`Quota exhausted at ${endpoint}. ${snippet}`);
@@ -5395,11 +5590,19 @@ function mapHttpError(status, bodyText, endpoint) {
}
return new ProxyUnavailableError(`${endpoint}: HTTP ${status} \u2014 ${snippet}`);
}
-function mapProxyChainFailure(lastError) {
+function endpointFromChainError(lastError) {
+ const match = lastError.match(/^(.+?): HTTP \d+/);
+ return match?.[1]?.trim() || "proxy";
+}
+function mapProxyChainFailure(lastError, rateLimit) {
if (/401|403|Unauthorized/i.test(lastError)) {
return new AuthError(lastError);
}
if (/429|rate limit/i.test(lastError)) {
+ const endpoint = endpointFromChainError(lastError);
+ if (rateLimit?.retryAfterSeconds || rateLimit?.scope) {
+ return new RateLimitError(formatRateLimitMessage(endpoint, rateLimit));
+ }
return new RateLimitError(lastError);
}
if (QUOTA_RE.test(lastError)) {
@@ -5555,6 +5758,31 @@ function readRoutingHeaders(res) {
function endpointLabel(base, res) {
return res.headers.get("x-llm-fallbacks-endpoint") || base;
}
+function parseRetryAfter(res) {
+ const raw = res.headers.get("Retry-After") ?? res.headers.get("retry-after");
+ if (!raw) return void 0;
+ const seconds = Number.parseInt(raw, 10);
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : void 0;
+}
+function parseRateLimitScopeFromBody(bodyText) {
+ try {
+ const parsed = JSON.parse(bodyText);
+ const message = parsed.error?.message ?? "";
+ if (/daily|\(day\)/i.test(message)) return "day";
+ if (/minute|\(minute\)/i.test(message)) return "minute";
+ } catch {
+ }
+ return void 0;
+}
+function parseRetryAfterFromBody(bodyText) {
+ try {
+ const parsed = JSON.parse(bodyText);
+ const raw = parsed.retry_after ?? parsed.error?.retry_after;
+ if (typeof raw === "number" && raw > 0) return raw;
+ } catch {
+ }
+ return void 0;
+}
var FailoverProvider = class {
config;
catalog = [];
@@ -5611,12 +5839,17 @@ var FailoverProvider = class {
return sessionModel2 || this.config.defaultModel || "free";
}
async chatViaProxy(base, body, guestToken, signal) {
+ const turnstileToken = await ensureTurnstileToken();
+ const headers = {
+ Authorization: `Bearer ${guestToken}`,
+ "Content-Type": "application/json"
+ };
+ if (turnstileToken) {
+ headers["CF-Turnstile-Response"] = turnstileToken;
+ }
return fetch(endpointUrl(base), {
method: "POST",
- headers: {
- Authorization: `Bearer ${guestToken}`,
- "Content-Type": "application/json"
- },
+ headers,
body: JSON.stringify({ ...body, stream: true }),
signal
});
@@ -5625,6 +5858,7 @@ var FailoverProvider = class {
if (!config.endpoints.length) throw mapProxyChainFailure("PROXY_UNAVAILABLE");
let lastError = "All proxy endpoints failed";
let hopIndex = 0;
+ let lastRateLimit;
for (const base of config.endpoints) {
this.setStatus(`proxy: ${base} \u2026`);
try {
@@ -5642,10 +5876,25 @@ var FailoverProvider = class {
await emitOpenAiSseAsStreamEvents(res, onEvent);
return;
}
- const errText = await res.text();
- lastError = `${base}: HTTP ${res.status} \u2014 ${errText.slice(0, 160)}`;
- if (!RETRYABLE.has(res.status)) {
- throw mapHttpError(res.status, errText, base);
+ if (!res.ok) {
+ const retryAfterHeader = res.status === 429 ? parseRetryAfter(res) : void 0;
+ const errText = await res.text();
+ const retryAfter = retryAfterHeader ?? (res.status === 429 ? parseRetryAfterFromBody(errText) : void 0);
+ const rateScope = res.status === 429 ? parseRateLimitScopeFromBody(errText) : void 0;
+ lastError = `${base}: HTTP ${res.status} \u2014 ${errText.slice(0, 160)}`;
+ if (res.status === 429) {
+ lastRateLimit = {
+ retryAfterSeconds: retryAfter,
+ scope: rateScope
+ };
+ showRateLimitBanner(retryAfter);
+ }
+ if (!RETRYABLE.has(res.status)) {
+ throw mapHttpError(res.status, errText, base, {
+ retryAfterSeconds: retryAfter,
+ scope: rateScope
+ });
+ }
}
} catch (err) {
if (signal.aborted) throw err;
@@ -5656,7 +5905,7 @@ var FailoverProvider = class {
}
hopIndex += 1;
}
- throw mapProxyChainFailure(lastError);
+ throw mapProxyChainFailure(lastError, lastRateLimit);
}
async streamChat(request, onEvent) {
const config = this.getRuntimeConfig();
@@ -5742,6 +5991,23 @@ var FailoverProvider = class {
};
// src/plugins/failover-settings/index.ts
+function stateLabel(state) {
+ if (state === "ok") return "Reachable";
+ if (state === "slow") return "Degraded";
+ return "Unreachable";
+}
+function renderHealthRow(base, result) {
+ const hint = result.authFailure ? `Auth failed \u2014 see docs/CAVEATS.md` : "";
+ const statusCode = result.statusCode ? `HTTP ${result.statusCode}` : "No response";
+ return `
+
+
+ ${base}
+ ${stateLabel(result.state)} \xB7 ${result.ms}ms \xB7 ${statusCode}
+ ${hint}
+
+ `;
+}
function FailoverSettingsPlugin(deps) {
return {
name: "failover-settings",
@@ -5760,6 +6026,14 @@ function FailoverSettingsPlugin(deps) {
+
@@ -5776,17 +6050,44 @@ function FailoverSettingsPlugin(deps) {
const guestEl = root.querySelector("#guestTokenInput");
const modelEl = root.querySelector("#defaultModelInput");
const statusEl = root.querySelector("#routeStatus");
+ const healthListEl = root.querySelector("#endpointHealthList");
+ const healthCheckedEl = root.querySelector("#endpointHealthChecked");
fillPanelFromConfig(deps.provider.getConfig(), endpointsEl, guestEl, modelEl);
void loadRuntimeConfig().then((config) => {
fillPanelFromConfig(config, endpointsEl, guestEl, modelEl);
deps.provider.updateConfig(config);
});
+ const runHealthChecks = async () => {
+ const endpoints = normalizeEndpoints(
+ endpointsEl.value.split("\n").map((l) => l.trim()).filter(Boolean)
+ );
+ if (!endpoints.length) {
+ healthListEl.innerHTML = `No endpoints configured`;
+ healthCheckedEl.textContent = "";
+ return;
+ }
+ healthListEl.innerHTML = endpoints.map(
+ (base) => `${base} Checking\u2026`
+ ).join("");
+ const results = await Promise.all(
+ endpoints.map(async (base) => ({ base, result: await probeEndpoint(base) }))
+ );
+ healthListEl.innerHTML = results.map(({ base, result }) => renderHealthRow(base, result)).join("");
+ healthCheckedEl.textContent = `Last checked ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
+ };
+ let healthDebounce;
+ const scheduleHealthCheck = () => {
+ clearTimeout(healthDebounce);
+ healthDebounce = setTimeout(() => void runHealthChecks(), 400);
+ };
document.getElementById("sysSetting")?.addEventListener("click", () => {
void loadRuntimeConfig().then((config) => {
fillPanelFromConfig(config, endpointsEl, guestEl, modelEl);
deps.provider.updateConfig(config);
+ scheduleHealthCheck();
});
});
+ root.querySelector("#checkEndpointsBtn")?.addEventListener("click", () => void runHealthChecks());
deps.provider.onStatus((s) => {
statusEl.textContent = `Status: ${s}`;
});
@@ -5805,6 +6106,7 @@ function FailoverSettingsPlugin(deps) {
deps.provider.updateConfig(await loadRuntimeConfig());
deps.onConfigSaved();
statusEl.textContent = `Saved ${endpoints.length} endpoint(s)`;
+ scheduleHealthCheck();
});
root.querySelector("#testConnectionBtn")?.addEventListener("click", async () => {
const endpoints = normalizeEndpoints(
@@ -5836,6 +6138,7 @@ function FailoverSettingsPlugin(deps) {
statusEl.textContent = `Error: ${err instanceof Error ? err.message : String(err)}`;
}
});
+ scheduleHealthCheck();
});
}
};
@@ -6231,10 +6534,12 @@ var ICON_REGEN = ``;
function MessageActionsPlugin() {
let engine = null;
+ let root = null;
return {
name: "message-actions",
onMount(ctx) {
engine = ctx.engine;
+ root = ctx.container;
const origStop = ctx.engine.stopGeneration.bind(ctx.engine);
ctx.engine.stopGeneration = async () => {
const generatingId = ctx.engine.state.generatingMessageId;
@@ -6243,6 +6548,10 @@ function MessageActionsPlugin() {
const pending = ctx.engine.state.messages.find((m2) => m2.id === generatingId);
if (pending) partialText = extractText(pending);
}
+ if (!partialText.trim() && root) {
+ const live = root.querySelector(".mur-message-assistant.mur-generating .mur-message-blocks-wrapper");
+ partialText = (live?.textContent ?? "").trim();
+ }
await origStop();
await new Promise((r) => setTimeout(r, 50));
if (partialText.trim() && !ctx.engine.isBusy) {
@@ -6303,6 +6612,26 @@ function MessageActionsPlugin() {
};
}
+// src/plugins/turnstile-gate/index.ts
+function TurnstileGatePlugin() {
+ return {
+ name: "turnstile-gate",
+ onMount() {
+ const siteKey2 = window.LLM_FALLBACKS_CONFIG?.turnstileSiteKey;
+ if (!siteKey2) return;
+ let mount = document.getElementById("lf-turnstile-mount");
+ if (!mount) {
+ mount = document.createElement("div");
+ mount.id = "lf-turnstile-mount";
+ mount.className = "lf-turnstile-mount";
+ mount.setAttribute("aria-label", "Bot check");
+ document.body.appendChild(mount);
+ }
+ initTurnstile(siteKey2, mount);
+ }
+ };
+}
+
// src/shell-panels.ts
var panels = /* @__PURE__ */ new Map();
function registerShellPanel(id, init) {
@@ -6404,6 +6733,8 @@ async function bootstrap() {
ModelPickerPlugin(),
MessageActionsPlugin(),
RoutingChipPlugin(),
+ StatusStripPlugin(),
+ TurnstileGatePlugin(),
FailoverSettingsPlugin({
provider,
onConfigSaved: async () => {
diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css
index 44ed31c..5274155 100644
--- a/docs/assets/shell/chat-overrides.css
+++ b/docs/assets/shell/chat-overrides.css
@@ -640,3 +640,106 @@ body.lf-chat-page::before {
outline: 2px solid rgba(199, 125, 255, 0.65);
outline-offset: 2px;
}
+
+/* ── Wave 2: endpoint health + status strip ─────────────────── */
+.lf-endpoint-health {
+ margin: 0.75rem 0 1rem;
+}
+
+.lf-endpoint-health-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.35rem;
+ font-size: 0.85rem;
+ color: #c8c8dc;
+}
+
+.lf-endpoint-health-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.lf-health-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.5rem;
+ font-size: 0.78rem;
+ color: #b8b8cc;
+}
+
+.lf-health-dot {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.lf-health-ok { background: #4ade80; }
+.lf-health-slow { background: #fbbf24; }
+.lf-health-fail { background: #f87171; }
+.lf-health-pending { opacity: 0.7; }
+
+.lf-health-url {
+ font-family: ui-monospace, monospace;
+ font-size: 0.72rem;
+ word-break: break-all;
+}
+
+.lf-health-hint {
+ flex: 1 1 100%;
+ font-size: 0.72rem;
+ color: #fbbf24;
+}
+
+.lf-status-strip {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ color: #a8a8bc;
+ min-width: 0;
+ flex: 0 1 auto;
+}
+
+.lf-status-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.lf-status-ok { background: #4ade80; }
+.lf-status-fail { background: #f87171; }
+.lf-status-warn { background: #fbbf24; }
+
+.lf-status-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 14rem;
+}
+
+.lf-turnstile-mount {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ z-index: 9999;
+}
+
+@media (max-width: 768px) {
+ .lf-status-strip {
+ flex: 1 1 100%;
+ justify-content: center;
+ }
+
+ .lf-status-text {
+ max-width: 100%;
+ }
+}
diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md
index 3b61c68..7b6902c 100644
--- a/docs/chat-ui-plugins.md
+++ b/docs/chat-ui-plugins.md
@@ -32,6 +32,8 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha
| `model-picker` | Composer | Dropdown for `free`, `openrouter/free`, and top catalog models |
| `routing-chip` | Messages | Endpoint / model / fallback metadata under assistant replies |
| `message-actions` | Messages | Regenerate, edit user message; stop preserves partial output when present |
+| `status-strip` | Top bar | Proxy liveness dot + optional daily chat count from `/v1/metrics` |
+| `turnstile-gate` | Body (optional) | Cloudflare Turnstile widget when `turnstileSiteKey` is in config |
Plugins register murm-ui hooks **and** optional slide panels via `registerShellPanel(id, initFn)` — see `webui/src/shell-panels.ts`.
diff --git a/docs/index.html b/docs/index.html
index ecb7ebf..2f40af3 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -20,6 +20,7 @@
llm-fallbacks — chat with free AI models. No signup.
+
Server
Your keys
diff --git a/docs/plans/2026-07-24-005-feat-chat-ui-wave2-trust-ops-plan.md b/docs/plans/2026-07-24-005-feat-chat-ui-wave2-trust-ops-plan.md
new file mode 100644
index 0000000..93eba32
--- /dev/null
+++ b/docs/plans/2026-07-24-005-feat-chat-ui-wave2-trust-ops-plan.md
@@ -0,0 +1,282 @@
+---
+title: "feat: Chat UI Wave 2 — health panel, Turnstile, rate-limit UX"
+status: completed
+date: 2026-07-24
+type: feat
+origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md
+strategy: STRATEGY.md
+wave: 2
+requirements: R7,R9,R15-R16
+prior_plan: docs/plans/2026-07-24-004-feat-chat-ui-wave1-ux-plan.md
+---
+
+# feat: Chat UI Wave 2 — health panel, Turnstile, rate-limit UX
+
+> **Origin:** [Chat UI improvements brainstorm](../brainstorms/2026-07-24-chat-ui-improvements-requirements.md) — Wave B (trust & ops). Wave C (catalog richness, export, hash routing) remains out of scope.
+
+## Summary
+
+Harden the public demo for sustainability and operator trust: **live endpoint health** in the Server panel, a lightweight **status strip** from existing Worker `/health` and `/v1/metrics`, **Cloudflare Turnstile** gating guest chat, and **rate-limit UX** that surfaces `Retry-After` — plus **Worker redeploy** to finish Wave 1 routing-header passthrough (R7).
+
+## Problem Frame
+
+Wave 1 made the chat feel like a real product. Wave 2 addresses what visitors cannot see but operators need: knowing which proxy is up, absorbing bot abuse without burning quota, and getting actionable feedback when limits hit. STRATEGY keeps static Pages + edge proxy; no accounts backend.
+
+## Requirements (Wave 2 traceability)
+
+| ID | Source | Wave 2 requirement |
+|----|--------|-------------------|
+| R7 | Brainstorm (carryover) | Worker exposes LiteLLM/Worker metadata headers to browser (code merged; **deploy + verify live**) |
+| R9 | Brainstorm | Failover settings show **live endpoint status** (reachable / degraded / unreachable) from lightweight checks |
+| R15 | Brainstorm | Public demo evaluates **Turnstile → short-lived session** at Worker before guest-token chat (fail-open in local dev) |
+| R16 | Brainstorm | KV rate limits remain; UI communicates **429** with retry guidance (extend Wave 1 error taxonomy) |
+| R9b | Brainstorm B | Minimal **status strip** fed by `/health` + `/v1/metrics` (operator-visible, optional for visitors) |
+
+**Explicitly deferred (Wave 3 / C):** R10–R14 catalog differentiation, export/share, hash routing, model compare.
+
+## Key Technical Decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| KTD1 | **Client-side health probes** from failover panel | Each configured base URL gets `GET {base}/health` (or `/health/liveliness` for LiteLLM) with 5s timeout; no new Worker route required |
+| KTD2 | **Health states:** ok / slow (>2s) / fail | Three-state UX matches “reachable / degraded / unreachable” without synthetic traffic to `/v1/chat/completions` |
+| KTD3 | **Turnstile verify at Worker** on first chat per session | Pages loads Turnstile widget once; Worker validates token via siteverify, stores `turnstile:ok:{ip}` in METRICS_KV (TTL 1h); skip when `TURNSTILE_SECRET` unset |
+| KTD4 | **Fail-open without Turnstile secret** | Local dev and CI mocks unchanged; production requires secret in wrangler + Pages site key in `docs/config.js` |
+| KTD5 | **Status strip in credits bar** | Single line: Worker liveness + optional 24h `chat_completion_success` from `/v1/metrics?days=1` — collapses on mobile |
+| KTD6 | **429 UX reads `Retry-After` header** | FailoverProvider maps header seconds into RateLimitError message; no client-side rate counter |
+| KTD7 | **Render health uses `/health/liveliness`** | Worker uses `/health`; Render LiteLLM uses liveliness path per deploy docs |
+| KTD8 | **No Turnstile on `/v1/events` or `/v1/metrics`** | Analytics and pulse endpoints stay guest-token only |
+
+## High-Level Technical Design
+
+```mermaid
+flowchart TB
+ subgraph ui [webui]
+ Failover[failover-settings plugin]
+ Strip[status-strip plugin]
+ FP[FailoverProvider]
+ TurnstileWidget[Turnstile on first chat]
+ Failover --> Probe[health-probe.ts]
+ Strip --> Metrics["/v1/metrics"]
+ Strip --> Health["/health"]
+ TurnstileWidget --> FP
+ FP --> Chat["/v1/chat/completions"]
+ end
+
+ subgraph edge [edge Worker]
+ Verify[turnstile siteverify]
+ KV[(METRICS_KV)]
+ Chat --> RL[rate limit]
+ Verify --> KV
+ RL --> KV
+ end
+
+ Probe --> Health
+ FP --> Chat
+ Chat --> Verify
+```
+
+## Implementation Units
+
+### U1. Worker deploy + R7 verification (R7 carryover)
+
+**Goal:** Production Worker serves CORS-exposed routing headers from Wave 1 edge changes.
+
+**Files:**
+- `edge/src/http.ts`, `edge/src/index.ts` (already merged)
+- `.github/workflows/deploy-proxies.yml` — confirm edge path triggers deploy
+- `docs/CAVEATS.md` — note header availability post-deploy
+
+**Approach:**
+- Trigger `deploy-proxies` workflow (or `workflow_dispatch`) after Wave 2 branch merges edge-touching changes.
+- Manual verify: browser fetch to Worker chat with guest token; confirm `x-llm-fallbacks-endpoint` visible in response headers.
+- Routing chip on live Pages should show model header when LiteLLM emits `x-litellm-model-name`.
+
+**Test scenarios:**
+- OPTIONS preflight exposes routing header names.
+- Live curl/chat sees `x-llm-fallbacks-endpoint` on success response.
+
+**Verification:** `cd edge && npm test`; live smoke on production Worker URL.
+
+---
+
+### U2. Health probe module + Server panel UI (R9)
+
+**Goal:** Per-endpoint status in Failover settings with manual refresh.
+
+**Files:**
+- `webui/src/health-probe.ts` (new)
+- `webui/src/plugins/failover-settings/index.ts`
+- `webui/shell/chat-overrides.css` — status dot styles
+
+**Approach:**
+- Export `probeEndpoint(base: string): Promise<{ state: 'ok'|'slow'|'fail'; ms: number }>`.
+- Map paths: Worker/unknown → `{base}/health`; hosts containing `onrender.com` → `{base}/health/liveliness`.
+- Failover panel: render status row under Server URLs textarea; **Check endpoints** button runs parallel probes.
+- Auto-probe on panel open (debounced); show last-checked timestamp.
+- Degraded auth (401 on Render) surfaces as **fail** with hint linking to `docs/CAVEATS.md`.
+
+**Test scenarios:**
+- Mock 200 in <500ms → ok (green).
+- Mock 200 in >2s → slow (amber).
+- Mock timeout / 503 → fail (red).
+- Three endpoints in textarea → three status rows.
+
+**Verification:** Vitest on `health-probe.ts` with mocked fetch; Playwright mocked probe in failover panel.
+
+---
+
+### U3. Status strip plugin (R9b)
+
+**Goal:** Compact operator-facing liveness + engagement hint in top credits bar.
+
+**Files:**
+- `webui/src/plugins/status-strip/index.ts` (new)
+- `webui/src/main.ts`
+- `webui/shell/chat-overrides.css`
+- `webui/index.template.html` — optional mount hook in credits bar
+
+**Approach:**
+- On load (once per session): `GET {first endpoint}/health` → green/red dot + “Proxy OK” / “Proxy unreachable”.
+- Optional: `GET {first endpoint}/v1/metrics?days=1` with guest token → show `chat_completion_success` count if >0 (“N chats today”).
+- Hide strip entirely when health fetch fails and zero-config has no endpoints (edge case).
+- Do not block chat on strip failure.
+
+**Test scenarios:**
+- Mock health 200 → strip shows OK.
+- Mock metrics `{ events: { chat_completion_success: 42 } }` → strip includes count.
+- Mobile viewport → strip text truncates without layout break.
+
+**Verification:** Playwright mock; manual check on live Pages.
+
+---
+
+### U4. Turnstile gate at Worker (R15)
+
+**Goal:** Bot friction before guest chat; short-lived pass in KV.
+
+**Files:**
+- `edge/src/turnstile.ts` (new)
+- `edge/src/index.ts` — verify before rate limit on chat POST
+- `edge/src/types.ts` — `TURNSTILE_SECRET`, `TURNSTILE_SITE_KEY` (var)
+- `edge/test/turnstile.test.ts` (new)
+- `webui/src/plugins/turnstile-gate/index.ts` (new)
+- `webui/src/main.ts`
+- `docs/config.js` generation in `.github/workflows/deploy-pages.yml` — optional `turnstileSiteKey` from secret
+- `edge/README.md`, `docs/CAVEATS.md`
+
+**Approach:**
+- **Client:** Invisible/managed Turnstile widget on first `sendMessage`; obtain token; send as header `CF-Turnstile-Response` or body field on chat request (prefer header).
+- **Worker:** If `TURNSTILE_SECRET` set, require valid token OR existing KV pass `ts:pass:{ip}` with TTL 3600. On success, call Cloudflare siteverify, set KV pass on success.
+- If secret unset → skip verification (fail-open for dev).
+- Widget site key from `window.LLM_FALLBACKS_CONFIG.turnstileSiteKey`; omit widget when key absent.
+
+**Test scenarios:**
+- No secret → chat works without token (dev).
+- Secret set + invalid token → 403 with clear message.
+- Secret set + valid token (mock siteverify) → chat proceeds; second request within TTL skips widget.
+
+**Verification:** `cd edge && npm test`; Playwright with Turnstile mocked/disabled.
+
+**Execution note:** Requires Cloudflare dashboard Turnstile site + secrets in GitHub. Document manual setup in `edge/README.md`.
+
+---
+
+### U5. Rate-limit UX polish (R16)
+
+**Goal:** 429 responses show human retry time from `Retry-After`.
+
+**Files:**
+- `webui/src/providers/FailoverProvider.ts`
+- `webui/src/providers/errors.ts`
+- `webui/src/plugins/status-strip/index.ts` — optional “rate limited” banner on 429
+
+**Approach:**
+- On non-OK proxy response, read `Retry-After` header; pass seconds into `RateLimitError` message (“Try again in N seconds”).
+- Distinguish minute vs day scope when error body includes `rate_limit` type and scope from Worker JSON.
+- Wave 1 copy remains; this adds timing specificity.
+
+**Test scenarios:**
+- Mock 429 + `Retry-After: 45` → UI shows ~45s guidance.
+- Mock 429 day scope → message mentions daily limit.
+
+**Verification:** Vitest on error mapping; extend `tests/e2e/failover-dual-endpoint.spec.ts` or new mock 429 spec.
+
+---
+
+### U6. E2E, docs, and CONCEPTS (success criteria)
+
+**Goal:** CI coverage and operator docs for Wave 2.
+
+**Files:**
+- `tests/e2e/health-panel.spec.ts` (new)
+- `tests/e2e/turnstile-gate.spec.ts` (new, Turnstile disabled path)
+- `.github/workflows/deploy-pages.yml` — include new specs in mocked e2e job
+- `docs/chat-ui-plugins.md`
+- `CONCEPTS.md` — **Endpoint health probe**, **Turnstile session** (if not present)
+- `configs/README.md` — Turnstile optional config
+
+**Test scenarios:**
+- AE-W2a: Open Server panel → health dots appear (mocked probes).
+- AE-W2b: Mock 429 → chat shows retry message with seconds.
+- AE-W2c: Turnstile disabled (no site key) → chat unchanged.
+
+**Verification:** Full mocked e2e suite green in deploy-pages CI.
+
+## Sequencing
+
+```mermaid
+flowchart LR
+ U1[U1 Worker deploy R7] --> U6[U6 e2e docs]
+ U2[U2 health panel] --> U6
+ U3[U3 status strip] --> U6
+ U4[U4 Turnstile] --> U6
+ U5[U5 rate limit UX] --> U6
+ U2 --> U3
+```
+
+**Recommended order:** U1 → U2 → U5 → U3 → U4 → U6
+
+Turnstile (U4) last — requires external dashboard setup and is highest integration risk.
+
+## Scope Boundaries
+
+**In scope:** `webui/`, `edge/` Turnstile + docs, Playwright e2e, `docs/config.js` Turnstile site key, Worker redeploy verification.
+
+**Out of scope:** R10–R14 Wave C, virtual-key automation (SG-01), Open WebUI, paid HA, new rate-limit tiers, WAF rules beyond Turnstile.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Turnstile secrets not configured | Fail-open when unset; document setup; U4 skippable in dev |
+| Render `/health/liveliness` slow on cold start | Classify as **slow**, not fail; CAVEATS already document cold start |
+| CORS blocks client health probe to Render | Probe from Worker origin only if needed; fallback: Worker-only probes in strip |
+| Turnstile adds friction to legitimate users | Managed/invisible widget; 1h KV pass reduces repeat challenges |
+| CF API token missing blocks Worker deploy | U1 documents manual `wrangler deploy`; R7 stays partial until deploy |
+
+## Acceptance Examples
+
+- **AE1.** Operator opens Server panel, clicks **Check endpoints**, sees Worker green and Render amber/red with latency ms.
+- **AE2.** Visitor hits rate limit → chat shows “Try again in 60 seconds” (not generic Error).
+- **AE3.** Production with Turnstile configured: first chat completes widget; subsequent chats in same hour skip it.
+- **AE4.** Credits bar shows “Proxy OK · 12 chats today” when metrics available.
+- **AE5.** Live routing chip includes LiteLLM model name after Worker redeploy.
+
+## Open Questions
+
+| ID | Question | Owner | Default |
+|----|----------|-------|---------|
+| Q1 | Turnstile widget: managed vs invisible? | U4 | Managed (checkbox) for accessibility |
+| Q2 | Status strip visible to all visitors or operators only? | U3 | All visitors (subtle); hide metrics count if 0 |
+| Q3 | Fail-closed Turnstile in prod after soak? | Deploy | Fail-open until 1 week metrics stable (brainstorm Q3) |
+
+## Sources / Research
+
+- `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` — Wave B scope, R9/R15/R16
+- `docs/plans/2026-07-24-004-feat-chat-ui-wave1-ux-plan.md` — patterns, deferred items
+- `edge/README.md` — `/health`, `/v1/metrics`
+- `edge/src/rate-limit.ts` — existing KV limits
+- [Chris House — Turnstile + edge proxy](https://blog.chrishouse.io/cloudflare-ai-gateway-turnstile/)
+- [LiteLLM health checks](https://docs.litellm.ai/docs/proxy/health)
+- `docs/CAVEATS.md` — Render auth, cold start
diff --git a/edge/README.md b/edge/README.md
index 240d452..30e54b0 100644
--- a/edge/README.md
+++ b/edge/README.md
@@ -37,6 +37,7 @@ Update `wrangler.toml` `MODEL_CHAIN` and `ALLOWED_MODELS`, or let CI set them fr
## Rate limits and allowlist
- **Rate limits:** KV-backed per-IP caps (`RATE_LIMIT_PER_MINUTE`, `RATE_LIMIT_PER_DAY`). Returns HTTP 429 with `Retry-After`.
+- **Turnstile (optional):** Set `TURNSTILE_SECRET` via `wrangler secret put TURNSTILE_SECRET`. Chat requests require a valid `CF-Turnstile-Response` header or an existing KV pass (`ts:pass:{ip}`, TTL 1h). When the secret is unset, verification is skipped (local dev / CI).
- **Model allowlist:** Explicit `model` values must appear in `ALLOWED_MODELS` or `MODEL_CHAIN`; alias `free` is always allowed.
- **Guest token:** Public demo gate in `docs/config.js` — not user auth. Visible in view-source; CORS does not stop server-side abuse.
diff --git a/edge/src/index.ts b/edge/src/index.ts
index d105a75..8d71349 100644
--- a/edge/src/index.ts
+++ b/edge/src/index.ts
@@ -10,6 +10,7 @@ import { isModelAllowed } from "./allowlist";
import { corsHeaders, jsonError, parseOrigins, unauthorized } from "./http";
import { handleEventsPost, handleMetricsGet } from "./events";
import { checkRateLimit } from "./rate-limit";
+import { checkTurnstile } from "./turnstile";
import {
isChainModelSupported,
modelChain,
@@ -157,6 +158,11 @@ export default {
return unauthorized(origin, allowed);
}
+ const turnstile = await checkTurnstile(request, env, origin, allowed);
+ if (!turnstile.ok) {
+ return turnstile.response;
+ }
+
const rate = await checkRateLimit(request, env);
if (!rate.allowed) {
return new Response(
diff --git a/edge/src/turnstile.ts b/edge/src/turnstile.ts
new file mode 100644
index 0000000..8ecf422
--- /dev/null
+++ b/edge/src/turnstile.ts
@@ -0,0 +1,77 @@
+import { corsHeaders, jsonError } from "./http";
+import type { Env } from "./types";
+
+function clientIp(request: Request): string {
+ return (
+ request.headers.get("CF-Connecting-IP") ??
+ request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ??
+ "unknown"
+ );
+}
+
+type TurnstileVerifyResult = { success: boolean };
+
+export async function checkTurnstile(
+ request: Request,
+ env: Env,
+ origin: string | null,
+ allowed: string[]
+): Promise<{ ok: true } | { ok: false; response: Response }> {
+ if (!env.TURNSTILE_SECRET) {
+ return { ok: true };
+ }
+
+ const ip = clientIp(request);
+ const passKey = `ts:pass:${ip}`;
+
+ if (env.METRICS_KV) {
+ const existing = await env.METRICS_KV.get(passKey);
+ if (existing === "1") {
+ return { ok: true };
+ }
+ }
+
+ const token = request.headers.get("CF-Turnstile-Response")?.trim() ?? "";
+ if (!token) {
+ return {
+ ok: false,
+ response: jsonError(
+ "Turnstile verification required. Complete the check and try again.",
+ 403,
+ origin,
+ allowed
+ ),
+ };
+ }
+
+ const verifyRes = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ secret: env.TURNSTILE_SECRET,
+ response: token,
+ remoteip: ip,
+ }),
+ });
+
+ let verified = false;
+ try {
+ const body = (await verifyRes.json()) as TurnstileVerifyResult;
+ verified = Boolean(body.success);
+ } catch {
+ verified = false;
+ }
+
+ if (!verified) {
+ return {
+ ok: false,
+ response: jsonError("Turnstile verification failed. Try again.", 403, origin, allowed),
+ };
+ }
+
+ if (env.METRICS_KV) {
+ await env.METRICS_KV.put(passKey, "1", { expirationTtl: 3600 });
+ }
+
+ return { ok: true };
+}
diff --git a/edge/src/types.ts b/edge/src/types.ts
index eae6fcb..eae58cf 100644
--- a/edge/src/types.ts
+++ b/edge/src/types.ts
@@ -12,6 +12,7 @@ export interface Env {
RATE_LIMIT_PER_DAY?: string;
RATE_LIMIT_WINDOW_SECONDS?: string;
WORKERS_AI_MODEL?: string;
+ TURNSTILE_SECRET?: string;
}
export type ChatMessage = { role: string; content: string };
diff --git a/edge/test/turnstile.test.ts b/edge/test/turnstile.test.ts
new file mode 100644
index 0000000..737dcef
--- /dev/null
+++ b/edge/test/turnstile.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from "vitest";
+import worker from "../src/index";
+import type { Env } from "../src/types";
+import { checkTurnstile } from "../src/turnstile";
+
+class MemoryKV {
+ private store = new Map
();
+
+ async get(key: string): Promise {
+ return this.store.get(key) ?? null;
+ }
+
+ async put(key: string, value: string, _opts?: { expirationTtl?: number }): Promise {
+ this.store.set(key, value);
+ }
+}
+
+function baseEnv(kv: MemoryKV, overrides: Partial = {}): Env {
+ return {
+ AI: {} as Ai,
+ METRICS_KV: kv as unknown as KVNamespace,
+ PROXY_GUEST_TOKEN: "guest-token",
+ ALLOWED_ORIGINS: "https://bodecloud.github.io",
+ MODEL_CHAIN: "openrouter/free",
+ ALLOWED_MODELS: "openrouter/free",
+ MAX_TOKENS_CAP: "1024",
+ ...overrides,
+ };
+}
+
+describe("turnstile", () => {
+ it("skips verification when TURNSTILE_SECRET is unset", async () => {
+ const req = new Request("https://proxy.example/v1/chat/completions", {
+ headers: { "CF-Connecting-IP": "203.0.113.1" },
+ });
+ const result = await checkTurnstile(req, baseEnv(new MemoryKV()), "https://bodecloud.github.io", [
+ "https://bodecloud.github.io",
+ ]);
+ expect(result.ok).toBe(true);
+ });
+
+ it("rejects chat when secret set and token missing", async () => {
+ const req = new Request("https://proxy.example/v1/chat/completions", {
+ headers: { "CF-Connecting-IP": "203.0.113.2", Origin: "https://bodecloud.github.io" },
+ });
+ const result = await checkTurnstile(
+ req,
+ baseEnv(new MemoryKV(), { TURNSTILE_SECRET: "test-secret" }),
+ "https://bodecloud.github.io",
+ ["https://bodecloud.github.io"]
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.response.status).toBe(403);
+ }
+ });
+
+ it("allows chat when KV pass exists", async () => {
+ const kv = new MemoryKV();
+ await kv.put("ts:pass:203.0.113.3", "1");
+ const req = new Request("https://proxy.example/v1/chat/completions", {
+ headers: { "CF-Connecting-IP": "203.0.113.3" },
+ });
+ const result = await checkTurnstile(
+ req,
+ baseEnv(kv, { TURNSTILE_SECRET: "test-secret" }),
+ "https://bodecloud.github.io",
+ ["https://bodecloud.github.io"]
+ );
+ expect(result.ok).toBe(true);
+ });
+
+ it("verifies token via siteverify and stores KV pass", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ )
+ );
+
+ const kv = new MemoryKV();
+ const req = new Request("https://proxy.example/v1/chat/completions", {
+ headers: {
+ "CF-Connecting-IP": "203.0.113.4",
+ "CF-Turnstile-Response": "valid-token",
+ },
+ });
+ const result = await checkTurnstile(
+ req,
+ baseEnv(kv, { TURNSTILE_SECRET: "test-secret" }),
+ "https://bodecloud.github.io",
+ ["https://bodecloud.github.io"]
+ );
+ expect(result.ok).toBe(true);
+ expect(await kv.get("ts:pass:203.0.113.4")).toBe("1");
+
+ vi.unstubAllGlobals();
+ });
+
+ it("chat handler returns 403 without turnstile when secret set", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("siteverify")) {
+ return new Response(JSON.stringify({ success: false }), {
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify({ error: { message: "upstream" } }), { status: 503 });
+ })
+ );
+
+ const res = await worker.fetch(
+ new Request("https://proxy.example/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer guest-token",
+ "Content-Type": "application/json",
+ Origin: "https://bodecloud.github.io",
+ "CF-Connecting-IP": "203.0.113.5",
+ },
+ body: JSON.stringify({ model: "free", messages: [{ role: "user", content: "hi" }] }),
+ }),
+ baseEnv(new MemoryKV(), { TURNSTILE_SECRET: "test-secret", OPENROUTER_API_KEY: "or-key" })
+ );
+ expect(res.status).toBe(403);
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/tests/e2e/health-panel.spec.ts b/tests/e2e/health-panel.spec.ts
new file mode 100644
index 0000000..85e127d
--- /dev/null
+++ b/tests/e2e/health-panel.spec.ts
@@ -0,0 +1,38 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installTestConfigMock,
+} from "./helpers";
+
+test.describe("Wave 2 — health panel", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await page.route(`${DEMO_PROXY}/health`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ status: "ok" }),
+ });
+ });
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream",
+ body: 'data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
+ });
+ });
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ });
+
+ test("Server panel shows endpoint health after check", async ({ page }) => {
+ await page.locator("#sysSetting").click();
+ await expect(page.locator(".shell-panel.open")).toBeVisible({ timeout: 10_000 });
+ await page.locator("#checkEndpointsBtn").click();
+ await expect(page.locator(".lf-health-dot.lf-health-ok")).toHaveCount(1, { timeout: 15_000 });
+ await expect(page.locator(".lf-health-meta")).toContainText(/Reachable/i);
+ });
+});
diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts
index f20b89f..8779225 100644
--- a/tests/e2e/helpers.ts
+++ b/tests/e2e/helpers.ts
@@ -36,6 +36,14 @@ export async function installDemoProxyMock(page: Page, reply = "42 — zero-conf
}
export async function installTestConfigMock(page: Page) {
+ await page.route(`${DEMO_PROXY}/health`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ status: "ok" }),
+ });
+ });
+
await page.route("**/config.js*", async (route) => {
await route.fulfill({
status: 200,
diff --git a/tests/e2e/turnstile-gate.spec.ts b/tests/e2e/turnstile-gate.spec.ts
new file mode 100644
index 0000000..9955de0
--- /dev/null
+++ b/tests/e2e/turnstile-gate.spec.ts
@@ -0,0 +1,70 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installDemoProxyMock,
+ installLocalChatBundle,
+ installTestConfigMock,
+ waitForAssistantText,
+} from "./helpers";
+
+test.describe("Wave 2 — rate limit UX", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ await route.fulfill({
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": "45",
+ },
+ body: JSON.stringify({
+ error: {
+ type: "rate_limit",
+ message: "Rate limit exceeded (minute). Try again later.",
+ retry_after: 45,
+ },
+ retry_after: 45,
+ }),
+ });
+ });
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#chatinput, .mur-chat-input").first()).toBeVisible({
+ timeout: 45_000,
+ });
+ });
+
+ test("429 shows retry seconds in chat error", async ({ page }) => {
+ const input = page.locator("#chatinput, .mur-chat-input").first();
+ await input.fill("hello");
+ await page.locator("#sendbutton, .mur-send-btn").first().click();
+ await expect(page.locator(".mur-message-assistant, .mur-message").last()).toContainText(
+ /45 seconds/i,
+ { timeout: 30_000 }
+ );
+ await expect(page.locator("#lfStatusStrip")).toContainText(/Rate limited/i);
+ });
+});
+
+test.describe("Wave 2 — turnstile disabled", () => {
+ test("chat works without turnstileSiteKey", async ({ page }) => {
+ await installTestConfigMock(page);
+ await installDemoProxyMock(page);
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#chatinput, .mur-chat-input").first()).toBeVisible({
+ timeout: 45_000,
+ });
+
+ const input = page.locator("#chatinput, .mur-chat-input").first();
+ await input.fill("ping");
+ await page.locator("#sendbutton, .mur-send-btn").first().click();
+ const reply = await waitForAssistantText(page);
+ expect(reply).toMatch(/zero-config proxy reply/i);
+ await expect(page.locator("#lf-turnstile-mount")).toHaveCount(0);
+ });
+});
diff --git a/webui/index.template.html b/webui/index.template.html
index a3edba9..a666087 100644
--- a/webui/index.template.html
+++ b/webui/index.template.html
@@ -20,6 +20,7 @@
llm-fallbacks — chat with free AI models. No signup.
+
Server
Your keys
diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css
index 44ed31c..5274155 100644
--- a/webui/shell/chat-overrides.css
+++ b/webui/shell/chat-overrides.css
@@ -640,3 +640,106 @@ body.lf-chat-page::before {
outline: 2px solid rgba(199, 125, 255, 0.65);
outline-offset: 2px;
}
+
+/* ── Wave 2: endpoint health + status strip ─────────────────── */
+.lf-endpoint-health {
+ margin: 0.75rem 0 1rem;
+}
+
+.lf-endpoint-health-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.35rem;
+ font-size: 0.85rem;
+ color: #c8c8dc;
+}
+
+.lf-endpoint-health-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.lf-health-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.5rem;
+ font-size: 0.78rem;
+ color: #b8b8cc;
+}
+
+.lf-health-dot {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.lf-health-ok { background: #4ade80; }
+.lf-health-slow { background: #fbbf24; }
+.lf-health-fail { background: #f87171; }
+.lf-health-pending { opacity: 0.7; }
+
+.lf-health-url {
+ font-family: ui-monospace, monospace;
+ font-size: 0.72rem;
+ word-break: break-all;
+}
+
+.lf-health-hint {
+ flex: 1 1 100%;
+ font-size: 0.72rem;
+ color: #fbbf24;
+}
+
+.lf-status-strip {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ color: #a8a8bc;
+ min-width: 0;
+ flex: 0 1 auto;
+}
+
+.lf-status-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.lf-status-ok { background: #4ade80; }
+.lf-status-fail { background: #f87171; }
+.lf-status-warn { background: #fbbf24; }
+
+.lf-status-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 14rem;
+}
+
+.lf-turnstile-mount {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ z-index: 9999;
+}
+
+@media (max-width: 768px) {
+ .lf-status-strip {
+ flex: 1 1 100%;
+ justify-content: center;
+ }
+
+ .lf-status-text {
+ max-width: 100%;
+ }
+}
diff --git a/webui/src/global.d.ts b/webui/src/global.d.ts
index c7e8c86..5efb3a3 100644
--- a/webui/src/global.d.ts
+++ b/webui/src/global.d.ts
@@ -11,6 +11,7 @@ declare global {
chatProxyUrl?: string;
maxTokens: number;
appVersion?: string;
+ turnstileSiteKey?: string;
};
LLM_FALLBACKS_ROUTE?: string;
registerShellPanel?: (
diff --git a/webui/src/health-probe.test.ts b/webui/src/health-probe.test.ts
new file mode 100644
index 0000000..50bb329
--- /dev/null
+++ b/webui/src/health-probe.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it, vi } from "vitest";
+import { healthPathForBase, probeEndpoint } from "./health-probe";
+
+describe("health-probe", () => {
+ it("uses /health for worker URLs", () => {
+ expect(healthPathForBase("https://proxy.workers.dev")).toBe(
+ "https://proxy.workers.dev/health"
+ );
+ });
+
+ it("uses /health/liveliness for Render hosts", () => {
+ expect(healthPathForBase("https://app.onrender.com")).toBe(
+ "https://app.onrender.com/health/liveliness"
+ );
+ });
+
+ it("classifies fast 200 as ok", async () => {
+ const fetchFn = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ const result = await probeEndpoint("https://proxy.test", fetchFn);
+ expect(result.state).toBe("ok");
+ expect(result.ms).toBeGreaterThanOrEqual(0);
+ });
+
+ it("classifies slow 200 as slow", async () => {
+ const fetchFn = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve({ ok: true, status: 200 }), 2100);
+ })
+ );
+ const result = await probeEndpoint("https://proxy.test", fetchFn);
+ expect(result.state).toBe("slow");
+ });
+
+ it("classifies 401 as fail with authFailure", async () => {
+ const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 401 });
+ const result = await probeEndpoint("https://proxy.test", fetchFn);
+ expect(result.state).toBe("fail");
+ expect(result.authFailure).toBe(true);
+ });
+
+ it("classifies network error as fail", async () => {
+ const fetchFn = vi.fn().mockRejectedValue(new Error("network"));
+ const result = await probeEndpoint("https://proxy.test", fetchFn);
+ expect(result.state).toBe("fail");
+ });
+});
diff --git a/webui/src/health-probe.ts b/webui/src/health-probe.ts
new file mode 100644
index 0000000..7c44b3a
--- /dev/null
+++ b/webui/src/health-probe.ts
@@ -0,0 +1,67 @@
+export type HealthState = "ok" | "slow" | "fail";
+
+export interface HealthProbeResult {
+ state: HealthState;
+ ms: number;
+ statusCode?: number;
+ authFailure?: boolean;
+}
+
+const SLOW_MS = 2000;
+const TIMEOUT_MS = 5000;
+
+export function healthPathForBase(base: string): string {
+ const trimmed = base.replace(/\/$/, "");
+ try {
+ const host = new URL(trimmed).hostname;
+ if (host.includes("onrender.com")) {
+ return `${trimmed}/health/liveliness`;
+ }
+ } catch {
+ /* invalid URL — fall through */
+ }
+ return `${trimmed}/health`;
+}
+
+export async function probeEndpoint(
+ base: string,
+ fetchFn: typeof fetch = fetch
+): Promise
{
+ const url = healthPathForBase(base);
+ const start = Date.now();
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
+
+ try {
+ const res = await fetchFn(url, { method: "GET", signal: controller.signal, mode: "cors" });
+ const ms = Date.now() - start;
+ clearTimeout(timeout);
+
+ if (res.status === 401 || res.status === 403) {
+ return { state: "fail", ms, statusCode: res.status, authFailure: true };
+ }
+ if (!res.ok) {
+ return { state: "fail", ms, statusCode: res.status };
+ }
+ if (ms > SLOW_MS) {
+ return { state: "slow", ms, statusCode: res.status };
+ }
+ return { state: "ok", ms, statusCode: res.status };
+ } catch {
+ clearTimeout(timeout);
+ return { state: "fail", ms: Date.now() - start };
+ }
+}
+
+export async function probeEndpoints(
+ bases: string[],
+ fetchFn: typeof fetch = fetch
+): Promise