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) { +
    +
    + Endpoint health + +
    + +

    +
    @@ -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> { + const results = new Map(); + await Promise.all( + bases.map(async (base) => { + results.set(base, await probeEndpoint(base, fetchFn)); + }) + ); + return results; +} diff --git a/webui/src/main.ts b/webui/src/main.ts index 495bb18..f7f2dd6 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -14,6 +14,8 @@ import { ModelExplorerPlugin } from "./plugins/model-explorer"; import { ModelPickerPlugin } from "./plugins/model-picker"; import { RoutingChipPlugin } from "./plugins/routing-chip"; import { MessageActionsPlugin } from "./plugins/message-actions"; +import { StatusStripPlugin } from "./plugins/status-strip"; +import { TurnstileGatePlugin } from "./plugins/turnstile-gate"; import { initModelSelection, setCatalogRef } from "./model-selection"; import { ANALYTICS_EVENTS, @@ -92,6 +94,8 @@ async function bootstrap(): Promise { ModelPickerPlugin(), MessageActionsPlugin(), RoutingChipPlugin(), + StatusStripPlugin(), + TurnstileGatePlugin(), FailoverSettingsPlugin({ provider, onConfigSaved: async () => { diff --git a/webui/src/plugins/failover-settings/index.ts b/webui/src/plugins/failover-settings/index.ts index cea1599..7a6420d 100644 --- a/webui/src/plugins/failover-settings/index.ts +++ b/webui/src/plugins/failover-settings/index.ts @@ -1,8 +1,30 @@ import type { ChatPlugin } from "murm-ui"; import { loadRuntimeConfig, normalizeEndpoints } from "../../config"; +import { healthPathForBase, probeEndpoint, type HealthProbeResult } from "../../health-probe"; import type { FailoverProvider } from "../../providers/FailoverProvider"; import { STORAGE_KEYS, saveJson } from "../../storage-keys"; +function stateLabel(state: HealthProbeResult["state"]): string { + if (state === "ok") return "Reachable"; + if (state === "slow") return "Degraded"; + return "Unreachable"; +} + +function renderHealthRow(base: string, result: HealthProbeResult): string { + const hint = result.authFailure + ? `Auth failed — see docs/CAVEATS.md` + : ""; + const statusCode = result.statusCode ? `HTTP ${result.statusCode}` : "No response"; + return ` +
  • + + ${base} + ${stateLabel(result.state)} · ${result.ms}ms · ${statusCode} + ${hint} +
  • + `; +} + export function FailoverSettingsPlugin(deps: { provider: FailoverProvider; onConfigSaved: () => void; @@ -30,6 +52,14 @@ export function FailoverSettingsPlugin(deps: { +
    +
    + Endpoint health + +
    +
      +

      +
      @@ -47,6 +77,8 @@ export 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) => { @@ -54,13 +86,49 @@ export function FailoverSettingsPlugin(deps: { 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…
    • ` + ) + .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 ${new Date().toLocaleTimeString()}`; + }; + + let healthDebounce: ReturnType | undefined; + 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}`; }); @@ -84,6 +152,7 @@ export function FailoverSettingsPlugin(deps: { deps.provider.updateConfig(await loadRuntimeConfig()); deps.onConfigSaved(); statusEl.textContent = `Saved ${endpoints.length} endpoint(s)`; + scheduleHealthCheck(); }); root.querySelector("#testConnectionBtn")?.addEventListener("click", async () => { @@ -120,6 +189,8 @@ export function FailoverSettingsPlugin(deps: { statusEl.textContent = `Error: ${err instanceof Error ? err.message : String(err)}`; } }); + + scheduleHealthCheck(); }); }, }; diff --git a/webui/src/plugins/status-strip/index.ts b/webui/src/plugins/status-strip/index.ts new file mode 100644 index 0000000..05e823c --- /dev/null +++ b/webui/src/plugins/status-strip/index.ts @@ -0,0 +1,81 @@ +import type { ChatPlugin } from "murm-ui"; +import { loadRuntimeConfig } from "../../config"; +import { healthPathForBase } from "../../health-probe"; + +export function StatusStripPlugin(): ChatPlugin { + return { + name: "status-strip", + onMount() { + const mount = + document.getElementById("lfStatusStrip") ?? + (() => { + const el = document.createElement("div"); + el.id = "lfStatusStrip"; + el.className = "lf-status-strip"; + el.setAttribute("aria-live", "polite"); + const credits = document.querySelector(".credits-content"); + const actions = credits?.querySelector(".credits-actions"); + if (credits && actions) { + credits.insertBefore(el, actions); + } else { + document.body.prepend(el); + } + return el; + })(); + + void refreshStatusStrip(mount); + }, + }; +} + +async function refreshStatusStrip(mount: HTMLElement): Promise { + const config = await loadRuntimeConfig(); + const base = config.endpoints[0]; + if (!base) { + mount.hidden = true; + return; + } + + mount.hidden = false; + mount.textContent = "Checking proxy…"; + + 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()) as { + events?: Record; + }; + chatCount = metrics.events?.chat_completion_success ?? 0; + } + } catch { + /* optional */ + } + + const dotClass = healthOk ? "lf-status-ok" : "lf-status-fail"; + const statusText = healthOk ? "Proxy OK" : "Proxy unreachable"; + const countText = chatCount > 0 ? ` · ${chatCount} chat${chatCount === 1 ? "" : "s"} today` : ""; + mount.innerHTML = `${statusText}${countText}`; +} + +export function showRateLimitBanner(seconds?: number): void { + const mount = document.getElementById("lfStatusStrip"); + if (!mount) return; + const suffix = + seconds !== undefined && seconds > 0 + ? ` Try again in ${seconds} second${seconds === 1 ? "" : "s"}.` + : " Wait and try again."; + mount.innerHTML = `Rate limited.${suffix}`; +} diff --git a/webui/src/plugins/turnstile-gate/index.ts b/webui/src/plugins/turnstile-gate/index.ts new file mode 100644 index 0000000..c6a682d --- /dev/null +++ b/webui/src/plugins/turnstile-gate/index.ts @@ -0,0 +1,22 @@ +import type { ChatPlugin } from "murm-ui"; +import { initTurnstile } from "../../turnstile-session"; + +export function TurnstileGatePlugin(): ChatPlugin { + return { + name: "turnstile-gate", + onMount() { + const siteKey = window.LLM_FALLBACKS_CONFIG?.turnstileSiteKey; + if (!siteKey) 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(siteKey, mount); + }, + }; +} diff --git a/webui/src/providers/FailoverProvider.ts b/webui/src/providers/FailoverProvider.ts index 01cf782..2605664 100644 --- a/webui/src/providers/FailoverProvider.ts +++ b/webui/src/providers/FailoverProvider.ts @@ -11,7 +11,9 @@ import { shouldFallbackToProxy, shouldTryBrowser, } from "./browser-router"; -import { ChatRouteError, mapHttpError, mapProxyChainFailure } from "./errors"; +import { ensureTurnstileToken } from "../turnstile-session"; +import { showRateLimitBanner } from "../plugins/status-strip"; +import { ChatRouteError, mapHttpError, mapProxyChainFailure, type RateLimitInfo, type RateLimitScope } from "./errors"; import { setLastCompletionMeta, type CompletionMeta } from "./routing-metadata"; import { emitOpenAiSseAsStreamEvents, emitTextAsStreamEvents } from "./sse"; @@ -51,6 +53,39 @@ function endpointLabel(base: string, res: Response): string { return res.headers.get("x-llm-fallbacks-endpoint") || base; } +function parseRetryAfter(res: Response): number | undefined { + const raw = res.headers.get("Retry-After") ?? res.headers.get("retry-after"); + if (!raw) return undefined; + const seconds = Number.parseInt(raw, 10); + return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined; +} + +function parseRateLimitScopeFromBody(bodyText: string): RateLimitScope | undefined { + try { + const parsed = JSON.parse(bodyText) as { error?: { message?: string } }; + const message = parsed.error?.message ?? ""; + if (/daily|\(day\)/i.test(message)) return "day"; + if (/minute|\(minute\)/i.test(message)) return "minute"; + } catch { + /* ignore */ + } + return undefined; +} + +function parseRetryAfterFromBody(bodyText: string): number | undefined { + try { + const parsed = JSON.parse(bodyText) as { + retry_after?: number; + error?: { retry_after?: number }; + }; + const raw = parsed.retry_after ?? parsed.error?.retry_after; + if (typeof raw === "number" && raw > 0) return raw; + } catch { + /* ignore */ + } + return undefined; +} + export class FailoverProvider implements ChatProvider { private config: AppConfig; private catalog: CatalogEntry[] = []; @@ -127,12 +162,17 @@ export class FailoverProvider implements ChatProvider { guestToken: string, signal: AbortSignal ): Promise { + const turnstileToken = await ensureTurnstileToken(); + const headers: Record = { + 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, }); @@ -148,6 +188,7 @@ export class FailoverProvider implements ChatProvider { let lastError = "All proxy endpoints failed"; let hopIndex = 0; + let lastRateLimit: RateLimitInfo | undefined; for (const base of config.endpoints) { this.setStatus(`proxy: ${base} …`); try { @@ -165,10 +206,27 @@ export class FailoverProvider implements ChatProvider { await emitOpenAiSseAsStreamEvents(res, onEvent); return; } - const errText = await res.text(); - lastError = `${base}: HTTP ${res.status} — ${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) : undefined; + const errText = await res.text(); + const retryAfter = + retryAfterHeader ?? + (res.status === 429 ? parseRetryAfterFromBody(errText) : undefined); + const rateScope = res.status === 429 ? parseRateLimitScopeFromBody(errText) : undefined; + lastError = `${base}: HTTP ${res.status} — ${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; @@ -179,7 +237,7 @@ export class FailoverProvider implements ChatProvider { } hopIndex += 1; } - throw mapProxyChainFailure(lastError); + throw mapProxyChainFailure(lastError, lastRateLimit); } async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { diff --git a/webui/src/providers/errors.test.ts b/webui/src/providers/errors.test.ts index 6cbb697..5d3bce4 100644 --- a/webui/src/providers/errors.test.ts +++ b/webui/src/providers/errors.test.ts @@ -14,6 +14,26 @@ describe("errors", () => { expect(err).toBeInstanceOf(RateLimitError); }); + it("maps 429 with Retry-After to timed message", () => { + const err = mapHttpError(429, '{"error":{"type":"rate_limit","message":"Rate limit exceeded (minute)."}}', "https://proxy.test", { + retryAfterSeconds: 45, + scope: "minute", + }); + expect(err.message).toMatch(/45 seconds/); + expect(err.message).toMatch(/minute/i); + }); + + it("maps 429 day scope", () => { + const err = mapHttpError( + 429, + '{"error":{"type":"rate_limit","message":"Rate limit exceeded (day)."}}', + "https://proxy.test", + { retryAfterSeconds: 3600, scope: "day" } + ); + expect(err.message).toMatch(/Daily rate limit/); + expect(err.message).toMatch(/3600 seconds/); + }); + it("maps 401 to AuthError", () => { const err = mapHttpError(401, "Unauthorized", "https://proxy.test"); expect(err).toBeInstanceOf(AuthError); diff --git a/webui/src/providers/errors.ts b/webui/src/providers/errors.ts index 86041d4..5e7ed72 100644 --- a/webui/src/providers/errors.ts +++ b/webui/src/providers/errors.ts @@ -46,13 +46,65 @@ export class ProxyUnavailableError extends ChatRouteError { const QUOTA_RE = /quota|credit|insufficient|billing|exhausted/i; const COLD_START_RE = /cold start|starting up|still deploying|proxy pending|503|502/i; -export function mapHttpError(status: number, bodyText: string, endpoint: string): ChatRouteError { +export type RateLimitScope = "minute" | "day"; + +export interface RateLimitInfo { + retryAfterSeconds?: number; + scope?: RateLimitScope; +} + +function formatRateLimitMessage(endpoint: string, info?: RateLimitInfo): string { + 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 !== undefined && seconds > 0) { + const unit = seconds === 1 ? "second" : "seconds"; + return `${prefix} Try again in ${seconds} ${unit}.`; + } + return `${prefix} Wait and try again.`; +} + +function parseRateLimitScope(bodyText: string): RateLimitScope | undefined { + try { + const parsed = JSON.parse(bodyText) as { + error?: { type?: string; message?: string }; + }; + 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 { + /* not JSON */ + } + return undefined; +} + +export function mapHttpError( + status: number, + bodyText: string, + endpoint: string, + rateLimit?: RateLimitInfo +): ChatRouteError { 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}`); @@ -63,11 +115,20 @@ export function mapHttpError(status: number, bodyText: string, endpoint: string) return new ProxyUnavailableError(`${endpoint}: HTTP ${status} — ${snippet}`); } -export function mapProxyChainFailure(lastError: string): ChatRouteError { +function endpointFromChainError(lastError: string): string { + const match = lastError.match(/^(.+?): HTTP \d+/); + return match?.[1]?.trim() || "proxy"; +} + +export function mapProxyChainFailure(lastError: string, rateLimit?: RateLimitInfo): ChatRouteError { 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)) { diff --git a/webui/src/turnstile-session.ts b/webui/src/turnstile-session.ts new file mode 100644 index 0000000..b0d06e7 --- /dev/null +++ b/webui/src/turnstile-session.ts @@ -0,0 +1,96 @@ +declare global { + interface Window { + turnstile?: { + render: ( + container: string | HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + "error-callback"?: () => void; + size?: "normal" | "compact" | "flexible"; + theme?: "light" | "dark" | "auto"; + } + ) => string; + execute: (widgetId: string) => void; + reset: (widgetId: string) => void; + }; + onloadTurnstileCallback?: () => void; + } +} + +let siteKey: string | undefined; +let widgetId: string | undefined; +let currentToken: string | null = null; +let loadPromise: Promise | null = null; +let pendingResolve: ((token: string) => void) | null = null; + +const SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback"; + +function loadTurnstileScript(): Promise { + 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: HTMLElement): void { + if (!window.turnstile || !siteKey || widgetId) return; + widgetId = window.turnstile.render(mount, { + sitekey: siteKey, + size: "compact", + theme: "dark", + callback: (token: string) => { + currentToken = token; + pendingResolve?.(token); + pendingResolve = null; + }, + "error-callback": () => { + pendingResolve?.(""); + pendingResolve = null; + }, + }); +} + +export function initTurnstile(key: string | undefined, mount: HTMLElement): void { + siteKey = key?.trim() || undefined; + if (!siteKey) return; + void loadTurnstileScript().then(() => renderWidget(mount)); +} + +export function hasTurnstile(): boolean { + return Boolean(siteKey); +} + +export function getTurnstileToken(): string | null { + return currentToken; +} + +export function clearTurnstileToken(): void { + currentToken = null; + if (widgetId && window.turnstile) { + window.turnstile.reset(widgetId); + } +} + +export async function ensureTurnstileToken(): Promise { + if (!siteKey) return undefined; + if (currentToken) return currentToken; + await loadTurnstileScript(); + if (!widgetId || !window.turnstile) return undefined; + + return new Promise((resolve) => { + pendingResolve = (token) => resolve(token || undefined); + window.turnstile!.execute(widgetId!); + }); +}