diff --git a/.gitignore b/.gitignore index a25a2e1d6a..8cf4e945b5 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,10 @@ http-client.private.env.json # Feature-triage ephemeral artifact (regenerated each run) _ideia/_triage.json +# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs) +scripts/i18n/_audit.json +scripts/i18n/_pending-keys.json + # Private workflow / skill / command implementations # These contain proprietary multi-phase logic and should not be committed .agents/workflows/implement-features-ag.md diff --git a/.i18n-state.json b/.i18n-state.json index 2922730e70..6f605b81aa 100644 --- a/.i18n-state.json +++ b/.i18n-state.json @@ -206,12 +206,12 @@ } }, "docs/architecture/ARCHITECTURE.md": { - "source_hash": "7e691870a4f6f25a535a023206cff5ef88aed619c9734851ebdacdd3a3bafcc8", + "source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059", "locales": { "pt-BR": { - "source_hash": "7e691870a4f6f25a535a023206cff5ef88aed619c9734851ebdacdd3a3bafcc8", - "target_hash": "3e2a58f341f20b29cf245330b094bec40ffeede43526b4d416c660c37abca8db", - "updated_at": "2026-05-13T22:58:05.984Z" + "source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059", + "target_hash": "7ee10dc8cba2668fd279af64ecca3a1760e974b6dc9672aee81e69c63ee41175", + "updated_at": "2026-05-20T05:34:41.826Z" } } } diff --git a/.semgrep/rules/cli-no-sqlite.yaml b/.semgrep/rules/cli-no-sqlite.yaml index 7c9e9fe508..5057d888c3 100644 --- a/.semgrep/rules/cli-no-sqlite.yaml +++ b/.semgrep/rules/cli-no-sqlite.yaml @@ -4,9 +4,9 @@ rules: - pattern: new Database(...) paths: include: - - "bin/**" + - "/bin/**" exclude: - - "bin/cli/sqlite.mjs" + - "/bin/cli/sqlite.mjs" message: > Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and @@ -21,9 +21,9 @@ rules: - pattern: $DB.prepare("UPDATE $TABLE SET ...") paths: include: - - "bin/**" + - "/bin/**" exclude: - - "bin/cli/sqlite.mjs" + - "/bin/cli/sqlite.mjs" message: > Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md hard rule #5 and bin/cli/CONVENTIONS.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e7f2a06a..b3ee711ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,201 @@ ## [Unreleased] -### Added +--- + +## [3.8.0] — 2026-05-06 + +### 🚀 Post-release hotfixes e contribuições (2026-05-06 → 2026-05-20) + +#### 2026-05-20 + +- **feat(batch):** implement 10 feature requests harvested from issues — T3 Chat Web executor (cookie-based), per-request exhausted-provider tracking (#1731) to skip quota-drained providers mid-combo, Zed Docker detection, API key rotator health dashboard, Kiro multi-account isolation, context-window model filtering, cost blending in combos, combo config tests, provider validation branches, and postinstall support scripts. ([#2414](https://github.com/diegosouzapw/OmniRoute/pull/2414)) +- **feat(combos):** add `falloverBeforeRetry` strategy — combo routing now falls over to the next target before retrying the same model, eliminating the tail-latency spike from exhausting all per-model retries on a failing endpoint. Also wraps the retry loop in a `setTry` outer loop for per-target retry coordination. ([#2417](https://github.com/diegosouzapw/OmniRoute/pull/2417) — thanks @hartmark) +- **fix(gamification):** resolve 6 implementation gaps — missing `SELECT` in `checkActionCountBadges` SQL (was silently skipping 8 badges), federation leaderboard auth enforcement, pagination `offset` parameter no longer silently discarded, admin anomaly view now computes real z-scores, `addXp` correctly calculates initial level from XP amount, and barrel `index.ts` for clean module exports. 72-test suite covering all fixes. ([#2421](https://github.com/diegosouzapw/OmniRoute/pull/2421) — thanks @oyi77) +- **docs:** add AgentRouter provider setup guide — step-by-step instructions for connecting OmniRoute to AgentRouter.org's Claude-compatible relay endpoint, covering API key configuration and wire-image headers. ([#2422](https://github.com/diegosouzapw/OmniRoute/pull/2422) — thanks @leninejunior) +- **fix(claude):** drop orphan `tool_result` blocks left behind when `fixToolAdjacency` strips a dangling `tool_use` — resolves HTTP 400 "unexpected tool_use_id in tool_result blocks" from the Anthropic API on truncated histories. `fixToolPairs` now re-runs after every `fixToolAdjacency` pass across all three call sites (`contextManager.ts`, `base.ts`, `claudeCodeCompatible.ts`). (discussion [#2410](https://github.com/diegosouzapw/OmniRoute/discussions/2410)) +- **fix(playground):** guard against `null`/non-string model IDs in Playground dropdowns — `typeof m?.id !== "string"` check prevents a silent crash in the provider discovery loop and `filteredModels` computation that was leaving all Playground dropdowns empty when `/v1/models` returned entries with `id: null`; adds deduplication via `Set` to eliminate duplicate React key warnings. +- **fix(mitm):** point MITM runtime manager re-export to the compiled `.js` entrypoint — fixes module resolution after build when the `.ts` source is no longer present. +- **fix(storage):** persist `STORAGE_ENCRYPTION_KEY` across upgrades (closes #1622) — ensures that SQLite encryption keys are preserved during version upgrades. ([#2428](https://github.com/diegosouzapw/OmniRoute/pull/2428) — thanks @Chewji9875) +- **fix(auth):** auto-reset credential `apiKeyHealth` status on successful connection test. ([#2427](https://github.com/diegosouzapw/OmniRoute/pull/2427) — thanks @clousky2020) +- **fix(mitm):** drop `.js` extension on `manager.runtime` re-export to fix webpack packaging issue. ([#2425](https://github.com/diegosouzapw/OmniRoute/pull/2425) — thanks @NomenAK) +- **fix(image):** support Antigravity image generation and add Gemini 3.5 Flash support. ([#2423](https://github.com/diegosouzapw/OmniRoute/pull/2423) — thanks @backryun) + +#### 2026-05-19 + +- **chore(i18n):** comprehensive dashboard i18n coverage — 6 rounds of parallel refactoring replacing hardcoded English/Portuguese text with `t()` calls across 57+ dashboard pages; 420+ new keys added to `en.json` covering `settings`, `playground`, `analytics`, `apiManager`, `providers`, `skills`, `memory`, `agents`, and 15 other namespaces (coverage: ~88%, up from ~20%). +- **fix(offline):** avoid SSR/CSR hydration mismatch on the offline status page — switches from a `useState` lazy initializer (which accessed `navigator.onLine` on the server) to `useSyncExternalStore` with a distinct `false` server snapshot, eliminating the React hydration warning. +- **fix(cli-tools):** guard `modelId` type before calling `.indexOf()` — prevents a `TypeError` when a model entry without a string `id` reaches the comparison logic in CLI Tools. +- **fix(providers):** add missing `isLocalProvider` import and update changelog. +- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020) +- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech) +- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405) — thanks @oyi77) +- **fix(providers):** Kilo Code provider no longer blocks on a missing local `kilocode` CLI binary — the provider uses OAuth device flow + direct HTTPS to `api.kilo.ai` and never required the CLI at runtime; the connection test was hard-failing with "Local CLI runtime is not installed" even when the OAuth token was valid. CLI Tools integration (`/api/cli-tools/kilo-settings`) keeps its own runtime check. ([#2404](https://github.com/diegosouzapw/OmniRoute/issues/2404) — thanks @Flexible78) +- **fix(db):** `bun add -g omniroute` (and other runtimes that skip postinstall) no longer surfaces a generic 500 — `isNativeSqliteLoadError` now also detects "Could not locate the bindings file" / `MODULE_NOT_FOUND`, so the user gets the friendly rebuild guide instead. ([#2358](https://github.com/diegosouzapw/OmniRoute/issues/2358) — thanks @yamansin) +- **fix(kiro):** enable Google OAuth login option in the Kiro auth modal — surfaces the Google SSO button alongside the existing identity providers. ([#2392](https://github.com/diegosouzapw/OmniRoute/pull/2392) — thanks @congvc-dev) +- **fix(security):** drop hashing layer in `sessionPoolKey` after switching to a non-cryptographic key derivation strategy that clears CodeQL alert #247. ([#2396](https://github.com/diegosouzapw/OmniRoute/pull/2396)) +- **feat(providers):** Gemini Web cookie-based provider — proxies google.com chat through a session cookie, allowing free Gemini access without API keys. ([#2380](https://github.com/diegosouzapw/OmniRoute/pull/2380) — thanks @oyi77) +- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun) +- **fix:** `tool_use` without adjacent `tool_result` causes Claude 400 — adjacency guard now also applies inside `compressContext`. ([#2383](https://github.com/diegosouzapw/OmniRoute/pull/2383) — thanks @oyi77) +- **build(deps):** bump `electron` from 42.0.1 to 42.1.0 in `/electron`. ([#2397](https://github.com/diegosouzapw/OmniRoute/pull/2397)) +- **build(deps):** production group bumps — 4 updates. ([#2398](https://github.com/diegosouzapw/OmniRoute/pull/2398)) +- **build(deps):** development group bumps — 4 updates. ([#2399](https://github.com/diegosouzapw/OmniRoute/pull/2399)) +- **chore:** sync `release/v3.8.0` with `main` (CodeQL hotfixes + Dependabot bumps) via merge commit. + +#### 2026-05-18 + +- **fix(security):** resolve CodeQL alerts #243/#244/#245 — incomplete URL substring sanitization and weak crypto signal hardening. ([#2391](https://github.com/diegosouzapw/OmniRoute/pull/2391)) +- **fix(security):** switch `sessionPoolKey` derivation to HMAC-SHA256 to clear CodeQL alert #246 (insecure hash for sensitive data). ([#2394](https://github.com/diegosouzapw/OmniRoute/pull/2394)) +- **docs(readme):** restore the 9router acknowledgment that was inadvertently dropped during the v3.8.0 README rework. ([#2393](https://github.com/diegosouzapw/OmniRoute/pull/2393)) +- **refactor(dashboard):** comprehensive nav, providers, endpoint, runtime, quota, pricing, budget redesign + quota sharing preview (sidebar restructure → 12 collapsible sections, 22 new routes). ([#2384](https://github.com/diegosouzapw/OmniRoute/pull/2384)) +- **fix(dashboard):** PR #2384 follow-up review fixes — Runtime quota i18n, Budget projection logic, QuotaShare i18n externalization, Provider Limits semantic markup, bulk endpoint usage. ([#2389](https://github.com/diegosouzapw/OmniRoute/pull/2389)) +- **feat(content):** add Haiper, Leonardo, Ideogram, Suno, and Udio as content/media providers. ([#2377](https://github.com/diegosouzapw/OmniRoute/pull/2377) — thanks @oyi77) +- **feat(@omniroute/opencode-provider):** expand config helpers, add MCP entry, live model fetch, and combo builder. ([#2375](https://github.com/diegosouzapw/OmniRoute/pull/2375) — thanks @mrmm) +- **fix(claude-oauth):** enable system-transforms pipeline for the native Claude executor (closes 400 billing-gate). ([#2370](https://github.com/diegosouzapw/OmniRoute/pull/2370) — thanks @thepigdestroyer) +- **feat(content):** extend providers with video, audio, TTS, music capabilities — Pollinations, MiniMax, Together, Replicate across Audio TTS and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77) +- **feat(providers):** add Veo AI Free as a web-wrapper provider for generating video, image, and TTS without an API key. ([#2366](https://github.com/diegosouzapw/OmniRoute/pull/2366) — thanks @oyi77) +- **feat(providers):** add Replicate as a free provider for OpenAI-compatible inference with community models. ([#2364](https://github.com/diegosouzapw/OmniRoute/pull/2364) — thanks @oyi77) +- **fix(claude):** avoided redundant deep cloning of Claude Code messages during semantic passthrough preparation, improving memory/CPU efficiency for large histories. ([#2362](https://github.com/diegosouzapw/OmniRoute/pull/2362) — thanks @terence71-glitch) +- **fix(providers):** register `llm7` in the executor registry and route Cohere via OpenAI-compatible layer. ([#2361](https://github.com/diegosouzapw/OmniRoute/pull/2361), [#2360](https://github.com/diegosouzapw/OmniRoute/pull/2360)) +- **fix(rate-limiter):** Redis is now opt-in — when `REDIS_URL` is unset, the rate limiter falls back to the in-memory store instead of spamming `ECONNREFUSED`. ([#2357](https://github.com/diegosouzapw/OmniRoute/pull/2357)) +- **fix(streaming):** emit protocol-aware stream errors — `createDisconnectAwareStream()` now emits native Responses API or Claude API SSE error blocks based on the client protocol. ([#2355](https://github.com/diegosouzapw/OmniRoute/pull/2355) — thanks @dhaern) +- **fix(combos):** allow bracketed combo names (e.g. `Claude [1m]`) by updating validation schemas. ([#2354](https://github.com/diegosouzapw/OmniRoute/pull/2354) — thanks @congvc-dev) +- **fix(claude-code):** semantic passthrough — preserve Claude Code `messages[]` structure for native Claude OAuth and relay routes. ([#2351](https://github.com/diegosouzapw/OmniRoute/pull/2351) — thanks @terence71-glitch) +- **fix(usage):** extract flat `cached_tokens` and `reasoning_tokens` from OpenAI-compatible usage objects. ([#2350](https://github.com/diegosouzapw/OmniRoute/pull/2350) — thanks @TF0rd) +- **fix(translator):** DeepSeek tool-call response lookup reads cached reasoning before falling back to empty string. ([#2349](https://github.com/diegosouzapw/OmniRoute/pull/2349) — thanks @herjarsa) +- **fix(ui/tooltip):** render in portal + clamp to viewport so tooltips aren't clipped in modal dialogs. ([#2352](https://github.com/diegosouzapw/OmniRoute/pull/2352) — thanks @slider23) +- **fix(auto-routing):** replace bare `getSettings()` with `getCachedSettings` to stop 500 on `auto/*` requests. ([#2346](https://github.com/diegosouzapw/OmniRoute/pull/2346)) +- **fix(docker):** ship Dashboard Docs markdown in the container image. ([#2348](https://github.com/diegosouzapw/OmniRoute/pull/2348)) +- **fix(combo/validator):** treat upstream responses carrying a non-empty `reasoning_content` as valid output. ([#2341](https://github.com/diegosouzapw/OmniRoute/pull/2341)) +- **fix(account-fallback):** classify Anthropic `Usage Limit Reached` as `QUOTA_EXHAUSTED` with a 1h cooldown. ([#2321](https://github.com/diegosouzapw/OmniRoute/pull/2321)) +- **feat(providers):** add GitHub Models as a free provider — GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3. ([#2344](https://github.com/diegosouzapw/OmniRoute/pull/2344) — thanks @oyi77) +- **feat(providers):** add Hackclub AI as a free provider — 30+ models, no credit card required. ([#2339](https://github.com/diegosouzapw/OmniRoute/pull/2339) — thanks @oyi77) +- **feat(providers):** add Microsoft Copilot Web executor — WebSocket-based provider. ([#2340](https://github.com/diegosouzapw/OmniRoute/pull/2340) — thanks @oyi77) +- **feat(routing):** LKGP stores last known good account `connectionId` alongside provider. ([#2338](https://github.com/diegosouzapw/OmniRoute/pull/2338) — thanks @oyi77) +- **feat(dashboard):** add Claude Code auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI). +- **feat(dashboard):** add Gemini CLI auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI). +- **fix(routing):** implement embedding combos, local provider validation bypass, and resolve migration collisions. +- **fix(build):** import Monaco ESM API to fix webpack `nls.messages-loader` error. +- **fix(ui):** v3.8.0 polish — connections border, sticky tabs, EN translations, save toasts, auto-combo catalog. ([#2305](https://github.com/diegosouzapw/OmniRoute/pull/2305) — thanks @mrmm) +- **fix(auth+build):** Bearer manage scope on management routes + lazy-load deepseek PoW solver. ([#2308](https://github.com/diegosouzapw/OmniRoute/pull/2308) — thanks @mrmm) +- **fix(claude):** guard orphan tool_use/tool_result pairs before upstream send. ([#2312](https://github.com/diegosouzapw/OmniRoute/pull/2312) — thanks @mrmm) +- **fix(ui):** remove count from batch removal button. ([#2309](https://github.com/diegosouzapw/OmniRoute/pull/2309) — thanks @hartmark) +- **fix:** remove implicit API key request caps — removes default 1K/5K/20K rate caps. ([#2289](https://github.com/diegosouzapw/OmniRoute/pull/2289) — thanks @josephvoxone) +- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, and `Transfer-Encoding` headers on non-streaming forward. ([#2264](https://github.com/diegosouzapw/OmniRoute/pull/2264) — thanks @gleber) +- **chore(providers):** refresh provider model metadata and ordering. ([#2318](https://github.com/diegosouzapw/OmniRoute/pull/2318) — thanks @backryun) +- **chore(providers):** consolidate Alibaba provider entries. ([#2319](https://github.com/diegosouzapw/OmniRoute/pull/2319) — thanks @backryun) +- **fix(streaming):** harden stream readiness detection. ([#2317](https://github.com/diegosouzapw/OmniRoute/pull/2317) — thanks @dhaern) +- **fix(v1/messages):** default to non-streaming when `stream` field is absent for Anthropic format. ([#2326](https://github.com/diegosouzapw/OmniRoute/pull/2326) — thanks @thepigdestroyer) +- **fix(claude):** `fitThinkingToMaxTokens` caps thinking budget to model's output ceiling. ([#2327](https://github.com/diegosouzapw/OmniRoute/pull/2327) — thanks @thepigdestroyer) +- **fix(codex):** Codex reasoning priority resolves `modelEffort` before `explicitReasoning`. ([#2335](https://github.com/diegosouzapw/OmniRoute/pull/2335) — thanks @terence71-glitch) +- **fix(providers):** providers page no longer deadlocks when no providers are configured. ([#2329](https://github.com/diegosouzapw/OmniRoute/pull/2329) — thanks @slider23) +- **chore(providers):** update HuggingFace to use the new `/v1/` router endpoint. ([#2322](https://github.com/diegosouzapw/OmniRoute/pull/2322) — thanks @backryun) +- **fix(security):** resolve CodeQL ReDoS + URL sanitization alerts. +- **fix(auth):** stop retrying unrecoverable token refresh failures and include connection id in token health check credentials. +- **fix(auth):** return synthetic credentials for noAuth free providers and show no-auth card in dashboard instead of OAuth modal. +- **fix(endpoint):** replace nested ` diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 5c6ad0a89f..e419e83b67 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -136,13 +136,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { fetchData(); }, [fetchData]); - // T07: Check for invalid API keys and show notification (once per session) - const notifiedInvalidKeys = useRef>(new Set()); + // T07: Check for unhealthy API keys and show notification (once per session) + const notifiedUnhealthyKeys = useRef>(new Set()); useEffect(() => { const checkApiKeyHealth = () => { - const newInvalidKeys = new Set(); - const invalidConnections: string[] = []; - let firstInvalidProviderId: string | null = null; + const newUnhealthyKeys = new Set(); + const unhealthyProviderIds = new Set(); + const unhealthyConnections: string[] = []; + let firstUnhealthyProviderId: string | null = null; + let hasWarning = false; for (const conn of providerConnections) { const health = conn.providerSpecificData?.apiKeyHealth as @@ -157,41 +159,63 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { | undefined; if (!health) continue; - const invalidKeys = Object.entries(health).filter(([_, h]) => h.status === "invalid"); + // Defense-in-depth: skip stale extra_N health entries whose index + // is out of range of the current extraApiKeys list. + // The backend cleans this up on PATCH, but existing stale data from + // before the fix or other code paths could still have orphan entries. + const extras: string[] = conn.providerSpecificData?.extraApiKeys ?? []; + const extraKeyCount = Array.isArray(extras) ? extras.length : 0; - if (invalidKeys.length > 0) { - for (const [keyId] of invalidKeys) { - newInvalidKeys.add(`${conn.id}:${keyId}`); + const unhealthyKeys = Object.entries(health).filter(([keyId, h]) => { + if (h.status !== "invalid" && h.status !== "warning") return false; + // extra_N entries: only flag if the index is still within bounds + if (keyId.startsWith("extra_")) { + const idx = parseInt(keyId.slice(6), 10); + if (isNaN(idx) || idx >= extraKeyCount) return false; } - if (firstInvalidProviderId === null) { - firstInvalidProviderId = conn.provider; + return true; + }); + + if (unhealthyKeys.length > 0) { + for (const [, h] of unhealthyKeys) { + if (h.status === "warning") hasWarning = true; + break; } - invalidConnections.push(conn.name || conn.id); + for (const [keyId] of unhealthyKeys) { + newUnhealthyKeys.add(`${conn.id}:${keyId}`); + } + if (firstUnhealthyProviderId === null) { + firstUnhealthyProviderId = conn.provider; + } + unhealthyConnections.push(conn.name || conn.id); + unhealthyProviderIds.add(conn.provider); } } - // Only notify for newly invalid keys (not already notified) - const hasNewInvalid = Array.from(newInvalidKeys).some( - (k) => !notifiedInvalidKeys.current.has(k) + // Only notify for newly unhealthy keys (not already notified) + const hasNewUnhealthy = Array.from(newUnhealthyKeys).some( + (k) => !notifiedUnhealthyKeys.current.has(k) ); - if (hasNewInvalid) { + if (hasNewUnhealthy) { const navigateTo = - newInvalidKeys.size === 1 && firstInvalidProviderId - ? `/dashboard/providers/${firstInvalidProviderId}` - : "/dashboard/providers"; + newUnhealthyKeys.size === 1 && firstUnhealthyProviderId + ? `/dashboard/providers/${firstUnhealthyProviderId}` + : `/dashboard/providers?search=${encodeURIComponent(Array.from(unhealthyProviderIds).join(" "))}`; + + const notificationType = hasWarning ? "warning" : "error"; useNotificationStore.getState().addNotification({ - type: "warning", - message: tp("apiKeyInvalidAlert", { - count: newInvalidKeys.size, - connections: invalidConnections.join(", "), + type: notificationType, + message: tp(hasWarning ? "apiKeyWarningAlert" : "apiKeyInvalidAlert", { + count: newUnhealthyKeys.size, + connections: unhealthyConnections.join(", "), }), - title: tp("apiKeyInvalidAlertTitle"), + title: tp(hasWarning ? "apiKeyWarningAlertTitle" : "apiKeyInvalidAlertTitle"), duration: 10000, onClick: () => router.push(navigateTo), }); - // Mark all current invalid keys as notified - newInvalidKeys.forEach((k) => notifiedInvalidKeys.current.add(k)); + // Mark all current unhealthy keys as notified + newUnhealthyKeys.forEach((k) => notifiedUnhealthyKeys.current.add(k)); } }; @@ -603,7 +627,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { check_circle {updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}

-

Reloading page automatically...

+

{t("reloadingPageAutomatically")}

)} @@ -788,7 +812,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
-

Provider Topology

+

{t("providerTopology")}

Connected providers routing through OmniRoute in real time

diff --git a/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx b/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx index 6d08ae16b5..6ce971b063 100644 --- a/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx +++ b/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx @@ -65,8 +65,8 @@ export function TierCoverageWidget() {
-

Tier coverage

-

Providers configured per fallback tier

+

{t("tierCoverageTitle")}

+

{t("tierCoverageSubtitle")}

({ online: false, loading: true }); const [a2aEnabled, setA2aEnabled] = useState(false); const [a2aToggling, setA2aToggling] = useState(false); @@ -192,19 +194,19 @@ export default function A2APage() { Discover the agent card at /.well-known/agent.json.
  • - Send JSON-RPC to POST /a2a using{" "} - message/send or{" "} - message/stream. + Send JSON-RPC to {t("rpcEndpoint")} using{" "} + {t("rpcMethodSend")} or{" "} + {t("rpcMethodStream")}.
  • - Track and cancel tasks with tasks/get and{" "} - tasks/cancel. + Track and cancel tasks with {t("rpcMethodGet")} and{" "} + {t("rpcMethodCancel")}.
  • void toggleA2a()} diff --git a/src/app/(dashboard)/dashboard/agent-skills/page.tsx b/src/app/(dashboard)/dashboard/agent-skills/page.tsx index 6eeab3f8aa..ece2554e0c 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/page.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { AGENT_SKILLS, AGENT_SKILLS_REPO_URL, @@ -10,6 +11,7 @@ import { import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; function CopyButton({ url }: { url: string }) { + const t = useTranslations("agents"); const { copied, copy } = useCopyToClipboard(); const isCopied = copied === url; @@ -21,17 +23,18 @@ function CopyButton({ url }: { url: string }) { ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" : "bg-bg-subtle text-text-muted hover:text-text-main" }`} - title="Copy raw URL to clipboard" + title={t("copyRawUrlTitle")} > {isCopied ? "check" : "content_copy"} - {isCopied ? "Copied!" : "Copy URL"} + {isCopied ? t("copied") : t("copyUrl")} ); } function SkillRow({ skill }: { skill: AgentSkill }) { + const t = useTranslations("agents"); const rawUrl = getAgentSkillRawUrl(skill.id); const blobUrl = getAgentSkillBlobUrl(skill.id); @@ -46,12 +49,12 @@ function SkillRow({ skill }: { skill: AgentSkill }) { {skill.name} {skill.isEntry && ( - Start Here + {t("startHere")} )} {skill.isNew && ( - New + {t("badgeNew")} )} {skill.endpoint && ( @@ -69,7 +72,7 @@ function SkillRow({ skill }: { skill: AgentSkill }) { target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-bg-subtle hover:text-text-main" - title="View on GitHub" + title={t("viewOnGithub")} > open_in_new @@ -109,6 +112,7 @@ function SkillSection({ } export default function AgentSkillsPage() { + const t = useTranslations("agents"); const apiSkills = AGENT_SKILLS.filter((s) => s.category === "api"); const cliSkills = AGENT_SKILLS.filter((s) => s.category === "cli"); @@ -118,12 +122,12 @@ export default function AgentSkillsPage() {
    info - How to use + {t("howToUse")}
    1. - 1. Click Copy URL on the skill you want your - agent to know about. + 1. Click {t("copyUrl")} on the skill you + want your agent to know about.
    2. 2. In your AI agent (Claude, Cursor, Cline…), say: @@ -144,20 +148,20 @@ export default function AgentSkillsPage() { className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" > open_in_new - Browse all skills on GitHub + {t("browseAllSkillsOnGithub")}
    {/* Two-column grid: API Skills | CLI Skills */}
    auto_awesome
    -

    Total Auto Requests

    +

    {t("autoRoutingTotalAutoRequests")}

    {stats.totalRequests.toLocaleString()}

    @@ -71,7 +71,7 @@ export default function AutoRoutingAnalyticsTab() { target
    -

    Avg Selection Score

    +

    {t("autoRoutingAvgSelectionScore")}

    {(stats.avgSelectionScore * 100).toFixed(1)}%

    @@ -83,7 +83,7 @@ export default function AutoRoutingAnalyticsTab() { explore
    -

    Exploration Rate

    +

    {t("autoRoutingExplorationRate")}

    {(stats.explorationRate * 100).toFixed(1)}%

    @@ -95,7 +95,7 @@ export default function AutoRoutingAnalyticsTab() { history
    -

    LKGP Hit Rate

    +

    {t("autoRoutingLkgpHitRate")}

    {(stats.lkgpHitRate * 100).toFixed(1)}%

    @@ -104,7 +104,7 @@ export default function AutoRoutingAnalyticsTab() { {/* Variant Breakdown */} -

    Requests by Variant

    +

    {t("autoRoutingRequestsByVariant")}

    {Object.entries(stats.variantBreakdown).map(([variant, count]) => { const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0; @@ -128,7 +128,7 @@ export default function AutoRoutingAnalyticsTab() { {/* Top Providers */} -

    Top Routed Providers

    +

    {t("autoRoutingTopRoutedProviders")}

    diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index 14d386e5e4..b327fac2f1 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import Badge from "@/shared/components/Badge"; import { Skeleton, Spinner } from "@/shared/components/Loading"; @@ -92,6 +93,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number; } function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) { + const t = useTranslations("analytics"); const sortedDistribution = useMemo( () => [...combo.usageSkew.modelDistribution].sort( @@ -119,18 +121,18 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    @@ -141,7 +143,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    -
    Quota health
    +
    + {t("comboHealthQuotaHealth")} +
    Lowest remaining quota across providers with short trend signals.
    @@ -189,7 +193,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    -
    Usage skew
    +
    {t("comboHealthUsageSkew")}
    Model request share and token share within this combo.
    @@ -214,12 +218,12 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    @@ -240,17 +244,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    @@ -260,7 +264,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) { {targetHealth.length > 0 ? (
    -
    Execution targets
    +
    + {t("comboHealthExecutionTargets")} +
    Step-level runtime metrics and quota visibility for structured combo targets.
    @@ -297,17 +303,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
    0 ? 1 : 0} meta={formatLatency(target.avgLatencyMs)} /> @@ -361,6 +367,7 @@ function ComboHealthSkeleton() { } export default function ComboHealthTab() { + const t = useTranslations("analytics"); const [range, setRange] = useState("24h"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -421,7 +428,7 @@ export default function ComboHealthTab() {
    -

    Combo health

    +

    {t("comboHealthTitle")}

    Monitor quota pressure, skewed model usage, and delivery performance by combo.

    @@ -436,7 +443,7 @@ export default function ComboHealthTab() {
    sync_problem
    -
    Unable to load combo health
    +
    {t("comboHealthUnableToLoad")}
    {error}
    {point.remainingPct.toFixed(point.remainingPct < 10 ? 1 : 0)}%

    -

    Remaining capacity

    +

    + {t("providerUtilizationRemainingCapacity")} +

    {formatTooltipTimestamp(point.timestamp, range)}

    diff --git a/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx index 1d0e371bbc..da9ca45094 100644 --- a/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx @@ -7,6 +7,7 @@ "use client"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; interface SearchStats { @@ -76,6 +77,7 @@ function ProviderBar({ } export default function SearchAnalyticsTab() { + const t = useTranslations("analytics"); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -122,25 +124,25 @@ export default function SearchAnalyticsTab() {
    0 ? `${stats.errors} errors` : "No errors"} /> @@ -173,7 +175,7 @@ export default function SearchAnalyticsTab() { travel_explore -

    No searches yet

    +

    {t("searchAnalyticsNoSearchesYet")}

    Use POST /v1/search to start routing web searches. diff --git a/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx index 3d21b80b41..fc4dce9d04 100644 --- a/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx +++ b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Card } from "@/shared/components"; @@ -15,6 +16,7 @@ interface DiversityReport { } export default function DiversityScoreCard() { + const t = useTranslations("analytics"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -82,7 +84,7 @@ export default function DiversityScoreCard() {

    pie_chart -

    Provider Diversity

    +

    {t("diversityScoreTitle")}

    — Provider concentration snapshot for the recent traffic window. diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 8e2a146cbb..2fc8c39429 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -1279,7 +1279,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Max Sessions Limit (T08) */}
    -

    Max Active Sessions

    +

    {t("maxActiveSessions")}

    0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.

    @@ -1302,10 +1302,10 @@ const PermissionsModal = memo(function PermissionsModal({
    -

    Custom Rate Limits

    -

    - Override global default limits. Leave empty to use defaults. +

    + {t("apiManagerCustomRateLimits")}

    +

    {t("apiManagerCustomRateLimitsDesc")}

    @@ -1454,7 +1456,7 @@ const PermissionsModal = memo(function PermissionsModal({ type="text" value={scheduleTz} onChange={(e) => setScheduleTz(e.target.value)} - placeholder="America/Sao_Paulo" + placeholder={t("apiManagerTimezonePlaceholder")} className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono" />

    {t("scheduleTimezoneHint")}

    @@ -1466,7 +1468,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Privacy Toggle */}
    -

    No-Log Payload Privacy

    +

    {t("noLogPayloadPrivacy")}

    Disable request/response payload persistence for this API key.

    @@ -1516,7 +1518,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Ban Toggle (SECURITY) */}
    -

    Banned Status

    +

    {t("bannedStatus")}

    Immediately revoke all access. Used for suspected abuse or compromised keys.

    @@ -1540,7 +1542,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Management API Access Toggle */}
    -

    Management API Access

    +

    {t("managementApiAccess")}

    Allow this key to call management routes (providers, combos, settings) via{" "} Authorization: Bearer. Use for LLM agents only. @@ -1565,7 +1567,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Expiration Date */}

    -

    Expiration Date

    +

    {t("expirationDate")}

    Key will automatically stop working after this date.

    @@ -1583,7 +1585,7 @@ const PermissionsModal = memo(function PermissionsModal({ {/* Management Access */}
    -

    Management Access

    +

    {t("managementAccess")}

    Allow this API key to manage OmniRoute configuration.

    @@ -1772,7 +1774,7 @@ const PermissionsModal = memo(function PermissionsModal({ {allConnections.length > 0 && (
    -

    Allowed Connections

    +

    {t("allowedConnections")}

    @@ -191,7 +193,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
    - - {batch.model && } - + + {batch.model && } + {relativeTime(batch.createdAt)}} />
    diff --git a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx index eca2569383..a5444f1f52 100644 --- a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import BatchDetailModal from "./BatchDetailModal"; function relativeTime(ts: number): string { @@ -131,6 +132,7 @@ export default function BatchListTab({ loading, onRefresh, }: Readonly) { + const t = useTranslations("common"); const [selectedBatch, setSelectedBatch] = useState(null); const [statusFilter, setStatusFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -199,7 +201,7 @@ export default function BatchListTab({
    setSearchQuery(e.target.value)} className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]" @@ -219,7 +221,7 @@ export default function BatchListTab({ onClick={handleRemoveCompleted} disabled={removingCompleted} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" - title="Delete all completed batches" + title={t("batchListDeleteAllCompletedTitle")} > {removingCompleted ? "hourglass_empty" : "delete_sweep"} @@ -230,7 +232,7 @@ export default function BatchListTab({ {/* Table */}
    -
    +
    @@ -338,7 +340,7 @@ export default function BatchListTab({ onClick={(e) => handleDeleteBatch(e, batch)} disabled={deletingId === batch.id} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50" - title="Delete batch and its files" + title={t("batchListDeleteBatchTitle")} > {deletingId === batch.id ? "hourglass_empty" : "delete"} diff --git a/src/app/(dashboard)/dashboard/batch/FileDetailModal.tsx b/src/app/(dashboard)/dashboard/batch/FileDetailModal.tsx index 4281178a7d..0bde086453 100644 --- a/src/app/(dashboard)/dashboard/batch/FileDetailModal.tsx +++ b/src/app/(dashboard)/dashboard/batch/FileDetailModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { Button } from "@/shared/components"; function relativeTime(ts: number): string { @@ -69,6 +70,7 @@ export default function FileDetailModal({ batches, onClose, }: Readonly) { + const t = useTranslations("common"); const [copied, setCopied] = useState(false); const relatedBatches = (batches ?? []).filter( @@ -140,7 +142,7 @@ export default function FileDetailModal({ navigator.clipboard.writeText(file.id); }} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors" - title="Copy ID" + title={t("batchFileDetailCopyId")} > content_copy @@ -149,7 +151,7 @@ export default function FileDetailModal({