chore: merge release/v3.8.0 into feat/features-batch — resolve all conflicts with release version

This commit is contained in:
diegosouzapw
2026-05-20 09:27:10 -03:00
121 changed files with 2794 additions and 1248 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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.

View File

@@ -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 `<button>` with `<div role=button>` in tunnel toggle rows to fix hydration warnings.
- **fix(migrations):** resolve version collision at migration slot 056 and add batch deletion API. ([#2294](https://github.com/diegosouzapw/OmniRoute/pull/2294) — thanks @hartmark)
- **feat(batch):** global rate-limit header cache with 60s TTL + 24h retry window. ([#2299](https://github.com/diegosouzapw/OmniRoute/pull/2299) — thanks @hartmark)
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL. ([#2286](https://github.com/diegosouzapw/OmniRoute/pull/2286), closes #2260 — thanks @mrmm)
- **feat(deepseek-web):** full DeepSeek web API executor with Keccak PoW solver. ([#2295](https://github.com/diegosouzapw/OmniRoute/pull/2295) — thanks @oyi77)
- **feat(i18n):** add Azerbaijani (az / 🇦🇿) language support — new locale in `config/i18n.json`, 42 total supported languages.
- **build(deps):** bump `actions/checkout` from 4 to 6 in CI workflows. ([#2288](https://github.com/diegosouzapw/OmniRoute/pull/2288))
#### 2026-05-17
- **fix(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
- **fix(providers):** Fix Providers empty state blocking first provider setup. (thanks @slider23)
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
- **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @oyi77)
- **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77)
- **feat(i18n):** add simple/advanced mode keys and missing provider filter keys (`allProviders`, `audioProviders`, `showFreeOnly`).
#### 2026-05-16
- **feat(deepseek-web):** full DeepSeek web API executor with PoW solver — also landed via PR #2295. (thanks @oyi77)
- **feat(batch):** global rate-limit header cache with 60s TTL — also via #2299.
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL — also via #2286.
- **feat(dashboard):** provider summary card, free test button, sidebar order, i18n fix.
- **feat(dashboard):** A2A audit page, stats bar on MCP audit, sidebar deduplication.
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
- **fix(translator):** map `developer``system` by default for non-OpenAI-family providers. ([#2281](https://github.com/diegosouzapw/OmniRoute/pull/2281))
- **fix(api/combos):** add API-key-safe `GET /v1/combos` endpoint. ([#2300](https://github.com/diegosouzapw/OmniRoute/pull/2300))
- **fix(embeddings/registry):** add DeepInfra to the embedding provider registry. ([#2298](https://github.com/diegosouzapw/OmniRoute/pull/2298))
- **fix(opencode-zen):** flag `qwen3.6-plus` and `qwen3.6-plus-free` with `targetFormat: "claude"`. ([#2292](https://github.com/diegosouzapw/OmniRoute/pull/2292))
- **fix(settings):** default `debugMode` to `true` on fresh installations.
- **fix(sse):** remove dead-code flag leak in `claudeCodeToolRemapper`. ([#2290](https://github.com/diegosouzapw/OmniRoute/pull/2290) — thanks @thepigdestroyer)
- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, `Transfer-Encoding` from upstream responses. ([#2291](https://github.com/diegosouzapw/OmniRoute/pull/2291) — thanks @thepigdestroyer)
- **fix(migrations):** resolve version collisions and add schema repair for quota thresholds.
#### 2026-05-15
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 09). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
- **feat(termux):** Android/Termux headless support — auto-detect Android platform for headless mode. ([#2273](https://github.com/diegosouzapw/OmniRoute/pull/2273) — thanks @t-way666)
- **feat(limits):** per-window quota cutoffs across all providers with usage data. ([#2267](https://github.com/diegosouzapw/OmniRoute/pull/2267) — thanks @payne0420)
- **feat(api-keys):** configurable default rate limits via `DEFAULT_RATE_LIMIT_PER_DAY` env var. ([#2266](https://github.com/diegosouzapw/OmniRoute/pull/2266) — thanks @gleber)
- **feat(authz):** `managementPolicy` accepts API keys with `manage` scope. ([#2265](https://github.com/diegosouzapw/OmniRoute/pull/2265) — thanks @gleber)
- **feat(mcp):** MCP accessibility-tree smart filter engine — collapses ≥30 repeated sibling lines, 60-80% token savings.
- **feat(auth):** CLI machine-ID HMAC-SHA256 token for zero-friction local auth without JWT/password.
- **feat(security):** route protection tiers — 5 tiers: public/read-only/protected/always/local-only.
- **feat(compression):** Caveman `SHARED_BOUNDARIES` — all 6 languages × 3 intensities embed boundary clause.
- **feat(runtime):** dynamic SQLite 5-step fallback chain — bundled → runtime-installed → lazy-install → node:sqlite → sql.js.
- **feat(cli):** standalone system tray with PowerShell fallback on Windows (`omniroute --tray`).
- **fix(providers/command-code):** send required `skills` and `stream` payload fields. ([#2271](https://github.com/diegosouzapw/OmniRoute/pull/2271) — thanks @ddarkr)
- **chore:** ignore `.playwright-mcp/` generated artifacts. ([#2269](https://github.com/diegosouzapw/OmniRoute/pull/2269) — thanks @backryun)
- **chore:** tidy up deprecated models from Windsurf provider registry. ([#2279](https://github.com/diegosouzapw/OmniRoute/pull/2279) — thanks @backryun)
- **chore(deps):** node dependency updates. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
#### 2026-05-08 a 2026-05-14
- **feat(guardrails/vision-bridge):** add `VISION_BRIDGE_BASE_URL` + `VISION_BRIDGE_API_KEY` env overrides for non-Anthropic vision-bridge routing. ([#2232](https://github.com/diegosouzapw/OmniRoute/pull/2232))
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
- **refactor(@omniroute/opencode-provider):** complete rewrite of the npm helper — tsup build (CJS + ESM + `.d.ts`), schema-correct output, `baseURL` deduplication, input validation, 13 unit tests. Versioned as `0.1.0`.
- **BREAKING:** dropped Node 20.x support. Minimum Node version is now 22.22.2 (or 24.0.0+).
- **fix(auth):** accept `x-api-key` header in `extractApiKey` so Anthropic-native clients hit the same per-key policy enforcement. ([#2225](https://github.com/diegosouzapw/OmniRoute/pull/2225))
- **fix(translator/claude-to-openai):** stop including `cache_creation_input_tokens` in `prompt_tokens`. ([#2215](https://github.com/diegosouzapw/OmniRoute/pull/2215))
- **fix(kiro):** harden OpenAI-to-Kiro translator for API compliance. ([#2251](https://github.com/diegosouzapw/OmniRoute/pull/2251) — thanks @8mbe)
- **fix(models):** sync managed model aliases with provider model visibility. ([#2250](https://github.com/diegosouzapw/OmniRoute/pull/2250) — thanks @InkshadeWoods)
- **fix(models/cleanup):** align managed model cleanup for imported models. ([#2261](https://github.com/diegosouzapw/OmniRoute/pull/2261) — thanks @InkshadeWoods)
- **fix(executor/claude-code):** store tool-name round-trip metadata in non-enumerable `_toolNameMap`. ([#2254](https://github.com/diegosouzapw/OmniRoute/pull/2254) — thanks @Rikonorus)
- **fix(streaming):** strip upstream `Content-Encoding`, `Content-Length`, `Transfer-Encoding` headers from SSE responses. ([#2253](https://github.com/diegosouzapw/OmniRoute/pull/2253) — thanks @Rikonorus)
- **fix(security):** remediate CodeQL vulnerabilities (ReDoS, cryptographic bias, stack trace exposure, weak password hashing). ([#216](https://github.com/diegosouzapw/OmniRoute/issues/216), [#215](https://github.com/diegosouzapw/OmniRoute/issues/215), [#211](https://github.com/diegosouzapw/OmniRoute/issues/211), [#208](https://github.com/diegosouzapw/OmniRoute/issues/208), [#206](https://github.com/diegosouzapw/OmniRoute/issues/206), [#210](https://github.com/diegosouzapw/OmniRoute/issues/210))
- **fix(providers/blackbox-web):** add `BLACKBOX_WEB_VALIDATED_TOKEN` env override and 403 token-error disambiguation. ([#2252](https://github.com/diegosouzapw/OmniRoute/pull/2252))
- **fix(auth):** `REQUIRE_API_KEY=false` invalid Bearer no longer 401s the whole request. ([#2257](https://github.com/diegosouzapw/OmniRoute/pull/2257))
- **feat(resilience):** add model cooldowns dashboard card with real-time list, individual/bulk re-enable, and auto-refresh.
- **feat(resilience):** `useUpstream429BreakerHints` toggle. ([#2133](https://github.com/diegosouzapw/OmniRoute/pull/2133) — thanks @eleata)
- **feat(auto):** zero-config auto-routing with `auto/` prefix — dynamic virtual combo from connected providers with 6 variant profiles. ([#2131](https://github.com/diegosouzapw/OmniRoute/pull/2131) — thanks @oyi77)
- **feat(kiro):** headless auth via kiro-cli SQLite, image support, tool overflow handling, model list sync. ([#2129](https://github.com/diegosouzapw/OmniRoute/pull/2129) — thanks @christlau)
- **feat(cursor):** surface Cursor Pro plan usage on provider-limits dashboard. ([#2128](https://github.com/diegosouzapw/OmniRoute/pull/2128) — thanks @payne0420)
- **feat(mitm):** dynamic Linux certificate path detection for multi-distro MITM cert trust. ([#2134](https://github.com/diegosouzapw/OmniRoute/pull/2134) — thanks @flyingmongoose)
- **feat(1proxy):** add dedicated settings tab with proxy rotation support. ([#2135](https://github.com/diegosouzapw/OmniRoute/pull/2135) — thanks @oyi77)
- **feat(responses):** degrade `background: true` to synchronous execution with a warning. ([#2164](https://github.com/diegosouzapw/OmniRoute/pull/2164) — thanks @Yosee11)
- **feat(api):** aggregate combo model metadata in catalog endpoint. ([#2166](https://github.com/diegosouzapw/OmniRoute/pull/2166) — thanks @faisalill)
- **feat(oauth):** complete Windsurf and Devin CLI OAuth + API-token flows. ([#2168](https://github.com/diegosouzapw/OmniRoute/pull/2168) — thanks @Zhaba1337228)
- **feat(antigravity):** support custom Google Cloud project ID. ([#2227](https://github.com/diegosouzapw/OmniRoute/pull/2227) — thanks @nickwizard)
- **feat(cli):** CLI Integration Suite — 5 new management commands, 3 API endpoints, config generators for 6 tools. ([#2240](https://github.com/diegosouzapw/OmniRoute/pull/2240) — thanks @oyi77)
- **fix(sanitizer):** preserve `reasoning_content` on assistant messages with `tool_calls`. ([#2140](https://github.com/diegosouzapw/OmniRoute/pull/2140) — thanks @DavyMassoneto)
- **fix(catalog):** ensure individual models expose `context_length` via `getTokenLimit()` fallback chain. ([#2136](https://github.com/diegosouzapw/OmniRoute/pull/2136) — thanks @herjarsa)
- **fix(docker):** remove docs directory from `.dockerignore`. ([#2137](https://github.com/diegosouzapw/OmniRoute/pull/2137), [#2120](https://github.com/diegosouzapw/OmniRoute/pull/2120) — thanks @hartmark)
- **fix(providers):** restore cloud agent provider exports and logger import. ([#2138](https://github.com/diegosouzapw/OmniRoute/pull/2138) — thanks @backryun)
- **fix(providers):** remove duplicate `CLOUD_AGENT_PROVIDERS` declaration. ([#2141](https://github.com/diegosouzapw/OmniRoute/pull/2141) — thanks @backryun)
- **fix(translator):** preserve `body.system` in openai→claude when Claude Code sends native format. ([#2130](https://github.com/diegosouzapw/OmniRoute/pull/2130))
- **fix(authz):** classify `/dashboard/onboarding` as PUBLIC to unblock setup wizard. ([#2127](https://github.com/diegosouzapw/OmniRoute/pull/2127))
- **fix(i18n):** complete Simplified Chinese translations. ([#2115](https://github.com/diegosouzapw/OmniRoute/pull/2115) — thanks @boa-z)
- **fix(sse):** classify hour quota errors as QUOTA_EXHAUSTED. ([#2119](https://github.com/diegosouzapw/OmniRoute/pull/2119) — thanks @clousky2020)
- **fix(sse):** fix CC-compatible streaming bridge. ([#2118](https://github.com/diegosouzapw/OmniRoute/pull/2118) — thanks @rdself)
- **fix(cliproxyapi):** detect Anthropic-shaped request bodies and route to `/v1/messages`. ([#2165](https://github.com/diegosouzapw/OmniRoute/pull/2165) — thanks @Brkic-Nikola)
- **fix(claudeHelper):** preserve latest assistant thinking blocks verbatim. ([#2224](https://github.com/diegosouzapw/OmniRoute/pull/2224) — thanks @NomenAK)
- **fix(deepseek):** preserve `reasoning_content` through full pipeline for DeepSeek V4 models. ([#2231](https://github.com/diegosouzapw/OmniRoute/pull/2231) — thanks @kang-heewon)
- **fix(chatcore):** stop leaking provider credentials in response headers.
- **fix(export):** exclude telemetry/usage-history tables from JSON config backups by default. ([#2125](https://github.com/diegosouzapw/OmniRoute/pull/2125))
- **build(deps):** regenerate `package-lock.json` to match `http-proxy-middleware` 4.x bump. ([#2228](https://github.com/diegosouzapw/OmniRoute/pull/2228) — thanks @NomenAK)
#### 2026-05-06 a 2026-05-07 (lançamento inicial v3.8.0)
- **feat(zed):** Zed IDE Docker support — when OmniRoute runs in Docker and Zed is on the host, the Import flow now returns a 422 with `zedDockerEnvironment: true` and the dashboard auto-expands a Manual Token Import panel (new `POST /api/providers/zed/manual-import` endpoint with Zod validation). Includes Docker detection utility (`/.dockerenv` + cgroup heuristics) and a setup guide at [`docs/providers/ZED-DOCKER.md`](docs/providers/ZED-DOCKER.md). ([#2306])
- **feat(workflow):** `/implement-features` gains pre-flight triage script (`scripts/features/feature-triage.mjs`) classifying open feature requests into 8 buckets — fresh issues (<14d) stay dormant to give the community time to react, engagement override (≥5 👍 or ≥3 unique non-bot commenters) absorbs early, already-delivered detection via merged PRs + CHANGELOG + git log closes issues with version + PR reference, stale `need_details/` (>30d) is closed politely, aged `defer/` (>90d) is re-evaluated, and externally-closed issues clean up `_ideia/` automatically. Idea files now carry a YAML frontmatter snapshot enabling incremental comment re-sync. 53 unit tests cover the new logic.
@@ -115,38 +309,6 @@
- **chore:** narrow `.claude/` gitignore to runtime files only and untrack `scheduled_tasks.lock`.
- **Docs:** 270 broken internal markdown links repaired.
### Post-release hotfixes (2026-05-18 → 2026-05-19)
- **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):** 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))
- **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))
- **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))
- **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)
- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun)
- **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)
- **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(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 09). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry — covers operator-facing workflows on top of the developer-facing manifests shipped in #2284. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
- **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.
- **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)
- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405))
- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech)
- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020)
### 🏆 v3.8.0 Hall of Fame — extended credits (post-release)
The following contributions landed after the initial v3.8.0 cut and supplement the 55+ community hall of fame below. Updated tallies:
@@ -177,9 +339,7 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
---
## [3.8.0] - 2026-05-15
### Added
### Detalhes completos — features do release (2026-05-15)
- **feat(providers):** expanded capabilities for Pollinations, MiniMax, Together, and Replicate across Video, Audio TTS, and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77)
- **feat(providers):** added 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)
@@ -223,9 +383,9 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
- **chore(deps):** node dependency updates — bump multiple runtime and dev dependencies to latest patch/minor versions. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
## [3.8.0] — 2026-05-06
### Detalhes completos — features e fixes do lançamento (2026-05-06 a 2026-05-14)
### ✨ New Features
#### ✨ New Features
- **feat(providers):** add Command Code provider (#2199 — thanks @ddarkr)
- **feat(providers):** add ModelScope provider-specific 429 handling and retry logic (#2202 — thanks @InkshadeWoods)

View File

@@ -4,6 +4,7 @@ import {
mkdirSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
@@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path";
import { resolveDataDir } from "../data-dir.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
import { backupSqliteFile } from "../sqlite.mjs";
function getBackupDir() {
return join(resolveDataDir(), "backups");
@@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) {
try {
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
Database = null;
}
let backedUp = 0;
let skipped = 0;
@@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) {
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
const destPath = join(backupPath, destName);
mkdirSync(dirname(destPath), { recursive: true });
if (file.name.endsWith(".sqlite") && Database) {
const db = new Database(sourcePath, { readonly: true });
if (file.name.endsWith(".sqlite")) {
const tmpPath = destPath.replace(/\.enc$/, "");
await db.backup(tmpPath);
db.close();
await backupSqliteFile(sourcePath, tmpPath);
if (opts.encrypt) {
encryptFile(tmpPath, destPath, passphrase);
const { unlinkSync } = await import("node:fs");
unlinkSync(tmpPath);
}
} else if (opts.encrypt) {

View File

@@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
import { printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
const STATIC_SALT = "omniroute-field-encryption-v1";
const KEY_LENGTH = 32;
@@ -78,14 +79,6 @@ function checkConfig(dataDir) {
return ok("Config", `.env found at ${envFile}`, { envFile });
}
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch (error) {
return { error };
}
}
function resolveMigrationsDir(rootDir) {
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
const candidates = [
@@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) {
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Database", "better-sqlite3 could not be loaded", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const quickCheck = db.prepare("PRAGMA quick_check").get();
const quickCheckValue = Object.values(quickCheck || {})[0];
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
await readDatabaseHealth(dbPath);
if (quickCheckValue !== "ok") {
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
}
@@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) {
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
}
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("_omniroute_migrations");
if (!table) {
if (!hasMigrationTable) {
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
}
const appliedRows = db
.prepare("SELECT version FROM _omniroute_migrations")
.all()
.map((row) => row.version);
const applied = new Set(appliedRows);
const applied = new Set(appliedMigrationVersions);
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
if (pending.length > 0) {
@@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) {
dbPath,
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}
@@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) {
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const hasProviderTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections");
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
if (!hasProviderTable) {
return secret
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const rows = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 20`
)
.all();
const encryptedValues = rows.flatMap((row) =>
["api_key", "access_token", "refresh_token", "id_token"]
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
.map((key) => row[key])
);
if (encryptedValues.length === 0) {
return secret
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
@@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) {
return fail("Storage/encryption", "Encrypted credential check failed", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}

View File

@@ -344,7 +344,10 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newKey = await new Promise((resolve) =>
rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); })
rl.question(`New API key for ${connection.name}: `, (a) => {
rl.close();
resolve(a.trim());
})
);
if (!newKey) {
console.error("No key provided.");
@@ -354,7 +357,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
// --- Dry-run ---
if (opts.dryRun) {
console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) }));
console.log(
t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) })
);
return 0;
}
@@ -363,7 +368,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve)
rl.question(
t("providers.rotate.confirmPrompt", {
name: connection.name,
id: connection.id.slice(0, 8),
}),
resolve
)
);
rl.close();
if (!/^y(es|s)?$/i.test(answer)) {
@@ -378,7 +389,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
method: "PATCH",
body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 },
body: {
apiKey: newKey,
testStatus: "unknown",
lastError: null,
rateLimitedUntil: null,
backoffLevel: 0,
},
retry: false,
acceptNotOk: true,
});
@@ -404,7 +421,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
}
}
console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) }));
console.log(
t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) })
);
// --- Post-rotation test ---
if (!opts.skipTest) {
@@ -468,8 +487,8 @@ export async function runProvidersStatusCommand(opts = {}) {
const testColor = statusColor(testStatus);
console.log(
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
);
}
@@ -543,7 +562,10 @@ export function registerProviders(program) {
.option("--dry-run", t("providers.rotate.dryRunOpt"))
.action(async (idOrName, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output });
const exitCode = await runProvidersRotateCommand(idOrName, {
...opts,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
});

View File

@@ -1,11 +1,14 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { resolveDataDir } from "../data-dir.mjs";
import { join } from "node:path";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const ENCRYPTED_PATTERN = "enc:v1:%";
const ENCRYPTED_COLUMNS = ["api_key", "access_token", "refresh_token", "id_token"];
/**
* Direct SQLite implementation for reset-encrypted-columns.
* Uses better-sqlite3 directly to avoid TypeScript source dependencies in production builds.
*/
export async function runResetEncryptedColumns(argv) {
const dataDir = resolveDataDir();
const dbPath = join(dataDir, "storage.sqlite");
@@ -38,22 +41,37 @@ export async function runResetEncryptedColumns(argv) {
return 0;
}
let db;
try {
const { countEncryptedCredentials, resetEncryptedColumns } = await import(
`${PROJECT_ROOT}/src/lib/db/recovery.ts`
// Use createRequire to load better-sqlite3 (works in both dev and production)
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3");
db = new Database(dbPath);
// Build WHERE clause for encrypted values
const whereClause = ENCRYPTED_COLUMNS.map((col) => `${col} LIKE '${ENCRYPTED_PATTERN}'`).join(
" OR "
);
const count = countEncryptedCredentials();
// Count affected rows
const countResult = db
.prepare(`SELECT COUNT(*) AS cnt FROM provider_connections WHERE ${whereClause}`)
.get();
const count = countResult?.cnt ?? 0;
if (count === 0) {
console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m");
return 0;
}
const { affected } = resetEncryptedColumns({ dryRun: false });
// Reset columns
const nullCols = ENCRYPTED_COLUMNS.map((col) => `${col} = NULL`).join(", ");
db.prepare(`UPDATE provider_connections SET ${nullCols} WHERE ${whereClause}`).run();
console.log(
`\x1b[32m✔ Reset ${affected} provider connection(s).\x1b[0m\n` +
`\x1b[32m✔ Reset ${count} provider connection(s).\x1b[0m\n` +
` Re-authenticate your providers in the dashboard or re-add API keys.\n`
);
return 0;
@@ -62,5 +80,9 @@ export async function runResetEncryptedColumns(argv) {
`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err instanceof Error ? err.message : String(err)}`
);
return 1;
} finally {
if (db) {
db.close();
}
}
}

View File

@@ -1,33 +1,42 @@
import fs from "node:fs";
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
import { ensureProviderSchema } from "./provider-store.mjs";
import { ensureSettingsSchema } from "./settings-store.mjs";
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch {
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
}
function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
);
}
return error;
}
async function openSqliteDatabase(dbPath, options = {}) {
const Database = await loadBetterSqlite();
try {
return new Database(dbPath, options);
} catch (error) {
throw createSqliteNativeError(error);
}
}
export async function openOmniRouteDb() {
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);
fs.mkdirSync(dataDir, { recursive: true });
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
let db;
try {
db = new Database(dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
throw new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
);
}
throw error;
}
const db = await openSqliteDatabase(dbPath);
db.pragma("journal_mode = WAL");
ensureSettingsSchema(db);
@@ -35,3 +44,113 @@ export async function openOmniRouteDb() {
return { db, dataDir, dbPath };
}
export async function withReadonlySqlite(dbPath, callback) {
const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
try {
return await callback(db);
} finally {
db.close();
}
}
export async function backupSqliteFile(sourcePath, destPath) {
const db = await openSqliteDatabase(sourcePath, { readonly: true });
try {
await db.backup(destPath);
} finally {
db.close();
}
}
export async function readDatabaseHealth(dbPath) {
return withReadonlySqlite(dbPath, (db) => {
const quickCheck = db.prepare("PRAGMA quick_check").get();
const quickCheckValue = Object.values(quickCheck || {})[0];
const hasMigrationTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("_omniroute_migrations");
const appliedMigrationVersions = hasMigrationTable
? db
.prepare("SELECT version FROM _omniroute_migrations")
.all()
.map((row) => row.version)
: [];
return { quickCheckValue, hasMigrationTable, appliedMigrationVersions };
});
}
export async function readEncryptedCredentialSamples(dbPath) {
return withReadonlySqlite(dbPath, (db) => {
const hasProviderTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections");
if (!hasProviderTable) {
return { hasProviderTable: false, encryptedValues: [] };
}
const rows = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 20`
)
.all();
const encryptedValues = rows.flatMap((row) =>
["api_key", "access_token", "refresh_token", "id_token"]
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
.map((key) => row[key])
);
return { hasProviderTable: true, encryptedValues };
});
}
export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) {
if (!fs.existsSync(dbPath)) {
return { exists: false, hasPassword: false };
}
return withReadonlySqlite(dbPath, (db) => {
const hasSettingsTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("key_value");
if (!hasSettingsTable) {
return { exists: true, hasPassword: false };
}
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
.get("password");
let password = row?.value;
if (typeof password === "string") {
try {
password = JSON.parse(password);
} catch {}
}
return {
exists: true,
hasPassword: typeof password === "string" && password.length > 0,
};
});
}
export async function resetManagementPassword(
password,
dbPath = resolveStoragePath(resolveDataDir())
) {
const db = await openSqliteDatabase(dbPath);
try {
db.pragma("journal_mode = WAL");
ensureSettingsSchema(db);
const hashedPassword = await hashManagementPassword(password);
updateSettings(db, { password: hashedPassword, requireLogin: true });
} finally {
db.close();
}
}

View File

@@ -67,6 +67,47 @@ function loadEnvFile() {
loadEnvFile();
// Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env)
// This ensures the key survives across upgrades and is not regenerated on each install.
// See: https://github.com/diegosouzapw/OmniRoute/issues/1622
{
const { randomBytes } = await import("node:crypto");
const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { homedir } = await import("node:os");
if (!process.env.STORAGE_ENCRYPTION_KEY) {
const dataDir = join(homedir(), ".omniroute");
const envPath = join(dataDir, ".env");
// Ensure data directory exists
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const key = randomBytes(32).toString("hex");
// Read existing .env content or start fresh
let content = "";
if (existsSync(envPath)) {
content = readFileSync(envPath, "utf-8");
}
// Append key if not already present
if (!content.includes("STORAGE_ENCRYPTION_KEY=")) {
const separator = content.trim() ? "\n" : "";
const newContent = content.trimEnd() + separator + `STORAGE_ENCRYPTION_KEY=${key}`;
writeFileSync(envPath, newContent + "\n", "utf-8");
console.log(
" \x1b[2m✨ Generated STORAGE_ENCRYPTION_KEY in ~/.omniroute/.env\x1b[0m"
);
}
// Set in process.env for immediate use
process.env.STORAGE_ENCRYPTION_KEY = key;
}
}
// Apply --lang before Commander parses (program descriptions call t() during setup)
{
const langIdx = process.argv.findIndex((a) => a === "--lang");

View File

@@ -14,16 +14,12 @@
*/
import { createInterface } from "node:readline";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import bcrypt from "bcryptjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
// Resolve data directory — same logic as the server
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
const DB_PATH = resolve(DATA_DIR, "settings.db");
const DATA_DIR = resolveDataDir();
const DB_PATH = resolveStoragePath(DATA_DIR);
const rl = createInterface({
input: process.stdin,
@@ -34,37 +30,19 @@ function ask(question) {
return new Promise((resolve) => rl.question(question, resolve));
}
function generateSecretDigest(input) {
// Use bcrypt with a salt round of 10 to match login/route.ts expectations
// and resolve CodeQL js/insufficient-password-hash warning.
return bcrypt.hashSync(input, 10);
}
console.log("\n🔑 OmniRoute — Password Reset\n");
async function main() {
// Check if database exists
if (!existsSync(DB_PATH)) {
const passwordState = await readManagementPasswordState(DB_PATH);
if (!passwordState.exists) {
console.error(`❌ Database not found at: ${DB_PATH}`);
console.error(` Make sure OmniRoute has been started at least once.`);
console.error(` Or set DATA_DIR env var to your data directory.\n`);
process.exit(1);
}
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
console.error("❌ better-sqlite3 not installed. Run: npm install");
process.exit(1);
}
const db = new Database(DB_PATH);
// Check current settings
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
if (row) {
if (passwordState.hasPassword) {
console.log(" A password is currently set.");
} else {
console.log(" No password is currently set.");
@@ -74,7 +52,6 @@ async function main() {
if (!password || password.length < 8) {
console.error("\n❌ Password must be at least 8 characters.\n");
db.close();
rl.close();
process.exit(1);
}
@@ -83,28 +60,11 @@ async function main() {
if (password !== confirm) {
console.error("\n❌ Passwords do not match.\n");
db.close();
rl.close();
process.exit(1);
}
const hashed = generateSecretDigest(password);
// Upsert the password
const stmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('password', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
stmt.run(hashed);
// Also ensure requireLogin is true
const loginStmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
loginStmt.run();
db.close();
await resetManagementPassword(password, DB_PATH);
rl.close();
console.log("\n✅ Password reset successfully!");

View File

@@ -14,7 +14,7 @@
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
image: redis:8.6.2
image: redis:8.6.2-alpine
container_name: omniroute-redis-prod
restart: unless-stopped
volumes:

View File

@@ -6,14 +6,11 @@
# base → minimal image, no CLI tools
# cli → CLIs installed inside the container (portable)
# host → runner-base + host-mounted CLI binaries (Linux-first)
# cliproxyapi → CLIProxyAPI sidecar on port 8317
#
# Usage:
# docker compose --profile base up -d
# docker compose --profile cli up -d
# docker compose --profile host up -d
# docker compose --profile cliproxyapi up -d
# docker compose --profile cli --profile cliproxyapi up -d
#
# Before first run, copy .env.example → .env and edit your secrets.
# ──────────────────────────────────────────────────────────────────────
@@ -130,30 +127,6 @@ services:
profiles:
- host
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
cliproxyapi:
container_name: cliproxyapi
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
restart: unless-stopped
ports:
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
volumes:
- cliproxyapi-data:/root/.cli-proxy-api
environment:
- PORT=${CLIPROXYAPI_PORT:-8317}
- HOST=0.0.0.0
healthcheck:
test:
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- cliproxyapi
volumes:
cliproxyapi-data:
name: cliproxyapi-data
redis-data:
name: omniroute-redis-data

View File

@@ -6,11 +6,36 @@ designed as a drop-in `ANTHROPIC_BASE_URL` replacement for the official Claude C
client, so it only accepts traffic that matches the Claude Code wire image (specific
User-Agent, `anthropic-beta` flags, Stainless SDK headers, etc.).
OmniRoute supports AgentRouter through the **Claude Code compatible** provider type
(`anthropic-compatible-cc-*`), which speaks the Anthropic Messages API with the
correct wire image. A generic `openai-compatible-chat` provider pointing at
`https://agentrouter.org` will **not** work — the upstream WAF rejects requests that
do not look like Claude Code.
## Quick start — use the native `agentrouter` provider (recommended)
For most users, **no special setup is required**. OmniRoute ships a built-in
`agentrouter` provider with the full Claude Code wire image already baked in (see
`open-sse/config/providerRegistry.ts``agentrouter`). To use it:
1. Open **Dashboard → Providers → Add Provider**.
2. Select **AgentRouter** from the list.
3. Paste your `sk-...` API key and save.
That's it — no environment variables, no custom provider type. Built-in models
include `claude-opus-4-6`, `claude-haiku-4-5-20251001`, `glm-5.1`, and
`deepseek-v3.2`.
The rest of this guide covers the **advanced path**: using the
`anthropic-compatible-cc-*` provider type. Use that when you need more control
over the wire image — for example, when connecting to other AgentRouter-style
relays that are not yet in the native provider registry, or when overriding the
base URL, chat path, or header set.
---
## Advanced: connecting via the Claude Code compatible provider type
OmniRoute also supports AgentRouter (and similar relays) through the **Claude Code
compatible** provider type (`anthropic-compatible-cc-*`), which speaks the
Anthropic Messages API with the correct wire image. A generic
`openai-compatible-chat` provider pointing at `https://agentrouter.org` will
**not** work — the upstream WAF rejects requests that do not look like Claude
Code.
---

View File

@@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr
Core capabilities:
- OpenAI-compatible API surface for CLI/tools (177 providers, 31 executors)
- OpenAI-compatible API surface for CLI/tools (177 providers, 38 executors)
- Request/response translation across provider formats
- Model combo fallback (multi-model sequence)
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`

View File

@@ -411,7 +411,7 @@ open-sse/
├── types.d.ts
├── config/ Provider registries, header profiles, identity, …
├── handlers/ Request handlers (chat, embeddings, audio, image, …)
├── executors/ 31 provider-specific HTTP executors
├── executors/ 38 provider-specific HTTP executors
├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro)
├── transformer/ Responses API ↔ Chat Completions stream transformer
├── services/ 80+ service modules (combos, fallback, quotas, identity, …)
@@ -441,7 +441,7 @@ open-sse/
### 4.2 `open-sse/executors/`
31 provider executors, each extending `BaseExecutor` (`base.ts`):
38 provider executors, each extending `BaseExecutor` (`base.ts`):
`antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`,
`cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`,

View File

@@ -64,23 +64,17 @@ docker compose --profile cli up -d
# Host profile (Linux-first; mounts host CLI binaries read-only)
docker compose --profile host up -d
# Combine CLI + CLIProxyAPI sidecar
docker compose --profile cli --profile cliproxyapi up -d
```
## Available Profiles
OmniRoute ships four Compose profiles. Pick the one that matches your environment.
OmniRoute ships three Compose profiles. Pick the one that matches your environment.
| Profile | Service | When to use | Command |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` |
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
| Profile | Service | When to use | Command |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
## Redis Sidecar
@@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) |
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
## Docker Compose with Caddy (HTTPS Auto-TLS)

View File

@@ -27,12 +27,12 @@ re-authenticating one account does not affect any other account's refresh token.
The isolation applies to all three import methods:
| Import method | Isolation status |
|---|---|
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
| **Google / GitHub social login** | Isolated from v3.8.0 |
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
| Import method | Isolation status |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
| **Google / GitHub social login** | Isolated from v3.8.0 |
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
---

View File

@@ -1,6 +1,6 @@
# ARCHITECTURE (Português (Brasil))
🌐 **Languages:** 🇺🇸 [English](../../../../architecture/ARCHITECTURE.md) · 🇸🇦 [ar](../../../ar/docs/architecture/ARCHITECTURE.md) · 🇧🇬 [bg](../../../bg/docs/architecture/ARCHITECTURE.md) · 🇧🇩 [bn](../../../bn/docs/architecture/ARCHITECTURE.md) · 🇨🇿 [cs](../../../cs/docs/architecture/ARCHITECTURE.md) · 🇩🇰 [da](../../../da/docs/architecture/ARCHITECTURE.md) · 🇩🇪 [de](../../../de/docs/architecture/ARCHITECTURE.md) · 🇪🇸 [es](../../../es/docs/architecture/ARCHITECTURE.md) · 🇮🇷 [fa](../../../fa/docs/architecture/ARCHITECTURE.md) · 🇫🇮 [fi](../../../fi/docs/architecture/ARCHITECTURE.md) · 🇫🇷 [fr](../../../fr/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [gu](../../../gu/docs/architecture/ARCHITECTURE.md) · 🇮🇱 [he](../../../he/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [hi](../../../hi/docs/architecture/ARCHITECTURE.md) · 🇭🇺 [hu](../../../hu/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [id](../../../id/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [in](../../../in/docs/architecture/ARCHITECTURE.md) · 🇮🇹 [it](../../../it/docs/architecture/ARCHITECTURE.md) · 🇯🇵 [ja](../../../ja/docs/architecture/ARCHITECTURE.md) · 🇰🇷 [ko](../../../ko/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [mr](../../../mr/docs/architecture/ARCHITECTURE.md) · 🇲🇾 [ms](../../../ms/docs/architecture/ARCHITECTURE.md) · 🇳🇱 [nl](../../../nl/docs/architecture/ARCHITECTURE.md) · 🇳🇴 [no](../../../no/docs/architecture/ARCHITECTURE.md) · 🇵🇭 [phi](../../../phi/docs/architecture/ARCHITECTURE.md) · 🇵🇱 [pl](../../../pl/docs/architecture/ARCHITECTURE.md) · 🇵🇹 [pt](../../../pt/docs/architecture/ARCHITECTURE.md) · 🇷🇴 [ro](../../../ro/docs/architecture/ARCHITECTURE.md) · 🇷🇺 [ru](../../../ru/docs/architecture/ARCHITECTURE.md) · 🇸🇰 [sk](../../../sk/docs/architecture/ARCHITECTURE.md) · 🇸🇪 [sv](../../../sv/docs/architecture/ARCHITECTURE.md) · 🇰🇪 [sw](../../../sw/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [ta](../../../ta/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [te](../../../te/docs/architecture/ARCHITECTURE.md) · 🇹🇭 [th](../../../th/docs/architecture/ARCHITECTURE.md) · 🇹🇷 [tr](../../../tr/docs/architecture/ARCHITECTURE.md) · 🇺🇦 [uk-UA](../../../uk-UA/docs/architecture/ARCHITECTURE.md) · 🇵🇰 [ur](../../../ur/docs/architecture/ARCHITECTURE.md) · 🇻🇳 [vi](../../../vi/docs/architecture/ARCHITECTURE.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/architecture/ARCHITECTURE.md)
🌐 **Languages:** 🇺🇸 [English](../../../../architecture/ARCHITECTURE.md) · 🇸🇦 [ar](../../../ar/docs/architecture/ARCHITECTURE.md) · 🇦🇿 [az](../../../az/docs/architecture/ARCHITECTURE.md) · 🇧🇬 [bg](../../../bg/docs/architecture/ARCHITECTURE.md) · 🇧🇩 [bn](../../../bn/docs/architecture/ARCHITECTURE.md) · 🇨🇿 [cs](../../../cs/docs/architecture/ARCHITECTURE.md) · 🇩🇰 [da](../../../da/docs/architecture/ARCHITECTURE.md) · 🇩🇪 [de](../../../de/docs/architecture/ARCHITECTURE.md) · 🇪🇸 [es](../../../es/docs/architecture/ARCHITECTURE.md) · 🇮🇷 [fa](../../../fa/docs/architecture/ARCHITECTURE.md) · 🇫🇮 [fi](../../../fi/docs/architecture/ARCHITECTURE.md) · 🇫🇷 [fr](../../../fr/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [gu](../../../gu/docs/architecture/ARCHITECTURE.md) · 🇮🇱 [he](../../../he/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [hi](../../../hi/docs/architecture/ARCHITECTURE.md) · 🇭🇺 [hu](../../../hu/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [id](../../../id/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [in](../../../in/docs/architecture/ARCHITECTURE.md) · 🇮🇹 [it](../../../it/docs/architecture/ARCHITECTURE.md) · 🇯🇵 [ja](../../../ja/docs/architecture/ARCHITECTURE.md) · 🇰🇷 [ko](../../../ko/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [mr](../../../mr/docs/architecture/ARCHITECTURE.md) · 🇲🇾 [ms](../../../ms/docs/architecture/ARCHITECTURE.md) · 🇳🇱 [nl](../../../nl/docs/architecture/ARCHITECTURE.md) · 🇳🇴 [no](../../../no/docs/architecture/ARCHITECTURE.md) · 🇵🇭 [phi](../../../phi/docs/architecture/ARCHITECTURE.md) · 🇵🇱 [pl](../../../pl/docs/architecture/ARCHITECTURE.md) · 🇵🇹 [pt](../../../pt/docs/architecture/ARCHITECTURE.md) · 🇷🇴 [ro](../../../ro/docs/architecture/ARCHITECTURE.md) · 🇷🇺 [ru](../../../ru/docs/architecture/ARCHITECTURE.md) · 🇸🇰 [sk](../../../sk/docs/architecture/ARCHITECTURE.md) · 🇸🇪 [sv](../../../sv/docs/architecture/ARCHITECTURE.md) · 🇰🇪 [sw](../../../sw/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [ta](../../../ta/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [te](../../../te/docs/architecture/ARCHITECTURE.md) · 🇹🇭 [th](../../../th/docs/architecture/ARCHITECTURE.md) · 🇹🇷 [tr](../../../tr/docs/architecture/ARCHITECTURE.md) · 🇺🇦 [uk-UA](../../../uk-UA/docs/architecture/ARCHITECTURE.md) · 🇵🇰 [ur](../../../ur/docs/architecture/ARCHITECTURE.md) · 🇻🇳 [vi](../../../vi/docs/architecture/ARCHITECTURE.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/architecture/ARCHITECTURE.md)
---
@@ -20,17 +20,17 @@ _Última atualização: 2026-05-13_
## Resumo Executivo
OmniRoute é um gateway de roteamento de IA local e um painel construído sobre Next.js.
OmniRoute é um gateway de roteamento de IA local e um painel construído sobre Next.js.
Ele fornece um único endpoint compatível com OpenAI (`/v1/*`) e roteia o tráfego entre vários provedores upstream com tradução, fallback, atualização de token e rastreamento de uso.
Principais capacidades:
Capacidades principais:
- Superfície de API compatível com OpenAI para CLI/ferramentas (177 provedores, 31 executores)
- Superfície de API compatível com OpenAI para CLI/ferramentas (177 provedores, 38 executores)
- Tradução de solicitação/resposta entre formatos de provedores
- Fallback de combinação de modelos (sequência de múltiplos modelos)
- Etapas de combinação estruturadas (`provedor + modelo + conexão`) com ordenação em tempo de execução por `compositeTiers`
- Fallback em nível de conta (múltiplas contas por provedor)
- Pré-verificação de cota e seleção de conta P2C ciente da cota no caminho principal do chat
- Passos de combinação estruturados (`provedor + modelo + conexão`) com ordenação em tempo de execução por `compositeTiers`
- Fallback em nível de conta (multi-conta por provedor)
- Pré-verificação de cota e seleção de conta P2C ciente da cota no caminho principal de chat
- Gerenciamento de conexão de provedor OAuth + chave de API (14 módulos OAuth)
- Geração de embeddings via `/v1/embeddings` (6 provedores, 9 modelos)
- Geração de imagens via `/v1/images/generations` (10+ provedores, 20+ modelos)
@@ -45,11 +45,11 @@ Principais capacidades:
- Sanitização de resposta para compatibilidade estrita com o SDK da OpenAI
- Normalização de papéis (desenvolvedor→sistema, sistema→usuário) para compatibilidade entre provedores
- Conversão de saída estruturada (json_schema → Gemini responseSchema)
- Persistência local para provedores, chaves, aliases, combinações, configurações, preços (26 módulos de DB)
- Persistência local para provedores, chaves, aliases, combos, configurações, preços (26 módulos de DB)
- Rastreamento de uso/custo e registro de solicitações
- Sincronização em nuvem opcional para sincronização de múltiplos dispositivos/estados
- Sincronização em nuvem opcional para sincronização multi-dispositivo/estado
- Lista de permissão/bloqueio de IP para controle de acesso à API
- Gerenciamento de orçamento de pensamento (passagem/automático/customizado/adaptativo)
- Gerenciamento de orçamento de pensamento (pass-through/auto/custom/adaptive)
- Injeção de prompt global
- Rastreamento de sessão e identificação
- Limitação de taxa aprimorada por conta com perfis específicos de provedores
@@ -59,12 +59,12 @@ Principais capacidades:
- Camada de domínio: regras de custo, política de fallback, política de bloqueio
- Context Relay: resumos de transferência de sessão para continuidade de rotação de conta
- Persistência de estado de domínio (cache de gravação SQLite para fallbacks, orçamentos, bloqueios, disjuntores)
- Motor de políticas para avaliação centralizada de solicitações (bloqueio → orçamento → fallback)
- Telemetria de solicitações com agregação de latência p50/p95/p99
- Telemetria de alvo de combinação e saúde histórica do alvo de combinação via `combo_execution_key` / `combo_step_id`
- Motor de política para avaliação centralizada de solicitações (bloqueio → orçamento → fallback)
- Telemetria de solicitação com agregação de latência p50/p95/p99
- Telemetria de alvo de combo e saúde histórica do alvo de combo via `combo_execution_key` / `combo_step_id`
- ID de correlação (X-Request-Id) para rastreamento de ponta a ponta
- Registro de auditoria de conformidade com opção de exclusão por chave de API
- Framework de avaliação para garantia de qualidade de LLM
- Estrutura de avaliação para garantia de qualidade de LLM
- Painel de saúde com status de disjuntor de provedor em tempo real
- Servidor MCP (37 ferramentas) com 3 transportes (stdio/SSE/Streamable HTTP)
- Servidor A2A (JSON-RPC 2.0 + SSE) com habilidades e ciclo de vida de tarefas
@@ -72,19 +72,19 @@ Principais capacidades:
- Sistema de habilidades (registro, executor, sandbox, habilidades integradas)
- Proxy MITM com gerenciamento de certificados e manipulação de DNS
- Middleware de proteção contra injeção de prompt
- Pipeline de compressão de prompt com Caveman, RTK, pipelines empilhados, combinações de compressão, pacotes de idiomas e análises
- Pipeline de compressão de prompt com Caveman, RTK, pipelines empilhados, combos de compressão, pacotes de idioma e análises
- Registro de ACP (Agent Communication Protocol)
- Provedores OAuth modulares (14 módulos individuais sob `src/lib/oauth/providers/`)
- Scripts de desinstalação/desinstalação completa
- Ação de reparo de ambiente OAuth
- Ponte WebSocket para clientes WS compatíveis com OpenAI (`/v1/ws`)
- Gerenciamento de tokens de sincronização (emissão/revogação, download de pacote de configuração versionado por ETag)
- GLM Thinking (`glmt`) preset de provedor de primeira classe
- Contagem híbrida de tokens (contagem de tokens do lado do provedor `/messages/count_tokens` com fallback de estimativa)
- Gerenciamento de token de sincronização (emissão/revogação, download de pacote de configuração versionado por ETag)
- Pensamento GLM (`glmt`) como preset de provedor de primeira classe
- Contagem híbrida de tokens (lado do provedor `/messages/count_tokens` com fallback de estimativa)
- Auto-semeadura de alias de modelo (30+ normalizações de dialeto cross-proxy na inicialização)
- Busca segura de saída com proteção SSRF, bloqueio de URL privada e retry configurável
- Repetições de chat cientes de cooldown com `requestRetry` e `maxRetryIntervalSec` configuráveis
- Validação do ambiente em tempo de execução com Zod na inicialização
- Validação do ambiente de execução com Zod na inicialização
- Auditoria de conformidade v2 com paginação, eventos CRUD de provedores e registro de validação bloqueada por SSRF
Modelo de execução principal:
@@ -102,7 +102,7 @@ os demais estão vinculados a seus guias específicos de domínio.
> Fonte: [diagrams/request-pipeline.mmd](../diagrams/request-pipeline.mmd)
![Modelo de resiliência em 3 camadas](../diagrams/exported/resilience-3layers.svg)
![Modelo de resiliência de 3 camadas](../diagrams/exported/resilience-3layers.svg)
> Fonte: [diagrams/resilience-3layers.mmd](../diagrams/resilience-3layers.mmd) — também vinculado a
> [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) e à referência de resiliência `CLAUDE.md`.
@@ -116,13 +116,13 @@ os demais estão vinculados a seus guias específicos de domínio.
- Autenticação de provedor e atualização de token
- Tradução de requisições e streaming SSE
- Persistência de estado local + uso
- Orquestração de sincronização em nuvem opcional
- Orquestração opcional de sincronização em nuvem
### Fora do Escopo
- Implementação de serviço em nuvem por trás de `NEXT_PUBLIC_CLOUD_URL`
- SLA do provedor/plano de controle fora do processo local
- Binaries CLI externas em si (Claude CLI, Codex CLI, etc.)
- Binaries CLI externos em si (Claude CLI, Codex CLI, etc.)
## Superfície do Painel (Atual)
@@ -146,8 +146,8 @@ Páginas principais em `src/app/(dashboard)/dashboard/`:
- `/dashboard/cache` — estatísticas de cache de leitura e raciocínio, controles de expulsão
- `/dashboard/playground` — playground de chat interativo contra qualquer combo/modelo configurado
- `/dashboard/changelog` — visualizador de changelog no aplicativo (renderiza `CHANGELOG.md`)
- `/dashboard/system` — diagnósticos de tempo de execução, informações de versão, superfície de validação do ambiente
- `/dashboard/onboarding` — assistente de configuração para primeira execução em novas instalações
- `/dashboard/system` — diagnósticos de tempo de execução, informações de versão, superfície de validação de ambiente
- `/dashboard/onboarding` — assistente de configuração para primeira execução para novas instalações
- `/dashboard/media` — playground de imagem/vídeo/música
- `/dashboard/search-tools` — teste de provedor de busca e histórico
- `/dashboard/health` — tempo de atividade, disjuntores, limites de taxa, sessões monitoradas por cota
@@ -155,7 +155,7 @@ Páginas principais em `src/app/(dashboard)/dashboard/`:
- `/dashboard/settings` — abas de configurações do sistema (geral, roteamento, padrões de combo, etc.)
- `/dashboard/context/caveman` — regras de compressão Caveman, pacotes de idioma, visualização e modo de saída
- `/dashboard/context/rtk` — filtros de saída de comando RTK, visualização e configurações de segurança em tempo de execução
- `/dashboard/context/combos` — pipelines de compressão nomeadas atribuídas a combos de roteamento
- `/dashboard/context/combos` — pipelines de compressão nomeados atribuídos a combos de roteamento
- `/dashboard/translator` — inspeção de tradutor e visualização de conversão de formato de requisição
- `/dashboard/audit` — navegador de log de auditoria de conformidade com paginação e metadados estruturados
- `/dashboard/usage` — navegador de uso por requisição vinculado a `usage_history`
@@ -212,7 +212,7 @@ flowchart LR
## Componentes Centrais de Execução
## 1) API e Camada de Roteamento (Rotas do App Next.js)
## 1) Camada de API e Roteamento (Rotas do App Next.js)
Principais diretórios:
@@ -247,7 +247,7 @@ Domínios de gerenciamento:
- Chaves/aliases/combos/preços: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing`
- Uso: `src/app/api/usage/*`
- Síncrono/nuvem: `src/app/api/sync/*`, `src/app/api/cloud/*`
- Ferramentas de CLI: `src/app/api/cli-tools/*`
- Ferramentas auxiliares de CLI: `src/app/api/cli-tools/*`
- Filtro de IP: `src/app/api/settings/ip-filter` (GET/PUT)
- Orçamento de pensamento: `src/app/api/settings/thinking-budget` (GET/PUT)
- Prompt do sistema: `src/app/api/settings/system-prompt` (GET/PUT)
@@ -256,7 +256,7 @@ Domínios de gerenciamento:
- Sessões: `src/app/api/sessions` (GET)
- Limites de taxa: `src/app/api/rate-limits` (GET)
- Resiliência: `src/app/api/resilience` (GET/PATCH) — fila de requisições, cooldown de conexão, quebra de provedor, configuração de espera por cooldown
- Redefinição de resiliência: `src/app/api/resilience/reset` (POST) — redefinir quebras de provedores
- Reset de resiliência: `src/app/api/resilience/reset` (POST) — resetar quebras de provedores
- Estatísticas de cache: `src/app/api/cache/stats` (GET/DELETE)
- Telemetria: `src/app/api/telemetry/summary` (GET)
- Orçamento: `src/app/api/usage/budget` (GET/POST)
@@ -265,8 +265,8 @@ Domínios de gerenciamento:
- Avaliações: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET)
- Políticas: `src/app/api/policies` (GET/POST)
- Tokens de síncrono: `src/app/api/sync/tokens` (GET/POST), `src/app/api/sync/tokens/[id]` (GET/DELETE)
- Pacote de configuração: `src/app/api/sync/bundle` (GET, snapshot versionado por ETag de configurações/provedores/combos/chaves)
- WebSocket: `src/app/api/v1/ws/route.ts`manipulador de upgrade para clientes WS compatíveis com OpenAI
- Pacote de configuração: `src/app/api/sync/bundle` (GET, snapshot versionado em ETag de configurações/provedores/combos/chaves)
- WebSocket: `src/app/api/v1/ws/route.ts`Manipulador de upgrade para clientes WS compatíveis com OpenAI
## 2) SSE + Núcleo de Tradução
@@ -304,13 +304,13 @@ Serviços (lógica de negócios):
- Transferência de contexto: `open-sse/services/contextHandoff.ts` — geração e injeção de resumo de transferência para estratégia de retransmissão de contexto
- Compressão: `open-sse/services/compression/*` — compressão proativa antes da tradução do provedor;
inclui regras de Caveman, filtros RTK, pipelines empilhados, combos de compressão, estatísticas e validação
- Recuperador de cota Codex: `open-sse/services/codexQuotaFetcher.ts` — recupera a cota Codex para decisões de transferência de contexto
- Retry ciente de cooldown: `src/sse/services/cooldownAwareRetry.ts` — retries de cooldown por modelo com `requestRetry` / `maxRetryIntervalSec` configuráveis
- Fetch seguro de saída: `src/shared/network/safeOutboundFetch.ts`fetch protegido de provedor/modelo com proteção SSRF, bloqueio de URL privada, retry e timeout
- Guarda de URL de saída: `src/shared/network/outboundUrlGuard.ts` — valida URLs de provedores contra intervalos CIDR de privado/localhost
- Recuperador de cota do Codex: `open-sse/services/codexQuotaFetcher.ts` — recupera a cota do Codex para decisões de transferência de contexto
- Retentativa ciente de cooldown: `src/sse/services/cooldownAwareRetry.ts` — retentativas de cooldown por modelo com `requestRetry` / `maxRetryIntervalSec` configuráveis
- Busca segura de saída: `src/shared/network/safeOutboundFetch.ts`busca protegida de provedor/modelo com proteção SSRF, bloqueio de URL privada, retentativa e timeout
- Guarda de URL de saída: `src/shared/network/outboundUrlGuard.ts` — valida URLs de provedores contra faixas CIDR privadas/localhost
- Padrões de requisição do provedor: `open-sse/services/providerRequestDefaults.ts` — padrões de `maxTokens`, `temperature`, `thinkingBudgetTokens` a nível de provedor
- Constantes do provedor GLM: `open-sse/config/glmProvider.ts` — modelos GLM compartilhados, URLs de cota, timeout/padrões GLMT
- Upstream Antigravity: `open-sse/config/antigravityUpstream.ts` — constantes de URL base e caminho de descoberta
- Upstream de antigravidade: `open-sse/config/antigravityUpstream.ts` — constantes de URL base e caminho de descoberta
- Constantes do cliente Codex: `open-sse/config/codexClient.ts` — valores de user-agent e versão do cliente versionados
- Semente de alias de modelo: `src/lib/modelAliasSeed.ts` — semeia 30+ aliases de dialetos cross-proxy na inicialização
@@ -323,15 +323,15 @@ Módulos da camada de domínio:
- Motor de políticas: `src/domain/policyEngine.ts` — avaliação centralizada de bloqueio → orçamento → fallback
- Catálogo de códigos de erro: `src/lib/domain/errorCodes.ts`
- ID da requisição: `src/lib/domain/requestId.ts`
- Timeout de fetch: `src/lib/domain/fetchTimeout.ts`
- Timeout de busca: `src/lib/domain/fetchTimeout.ts`
- Telemetria de requisição: `src/lib/domain/requestTelemetry.ts`
- Conformidade/auditoria: `src/lib/domain/compliance/index.ts`
- Executor de eval: `src/lib/domain/evalRunner.ts`
- Executor de avaliação: `src/lib/domain/evalRunner.ts`
- Persistência do estado do domínio: `src/lib/db/domainState.ts` — CRUD SQLite para cadeias de fallback, orçamentos, histórico de custos, estado de bloqueio, disjuntores
Módulos do provedor OAuth (14 arquivos individuais sob `src/lib/oauth/providers/`):
Módulos do provedor OAuth (14 arquivos individuais em `src/lib/oauth/providers/`):
- Índice de registro: `src/lib/oauth/providers/index.ts`
- Índice do registro: `src/lib/oauth/providers/index.ts`
- Provedores individuais: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts`, `windsurf.ts`, `gitlab-duo.ts`
- Wrapper fino: `src/lib/oauth/providers.ts` — re-exportações de módulos individuais
@@ -349,25 +349,25 @@ O Motor de Combinação Automática pontua e escolhe dinamicamente os alvos de r
Principais capacidades:
- **14 estratégias de roteamento** (prioridade, ponderada, preenchimento primeiro, round-robin, P2C, aleatória,
menos utilizada, otimizada por custo, estritamente aleatória, **automática**, lkgp, otimizada por contexto,
re de contexto, além de um caminho de fallback) — automática é a adição principal na v3.8.0.
- **14 estratégias de roteamento** (prioridade, ponderada, preenchimento primeiro, round-robin, P2C, aleatório,
menos utilizado, otimizado por custo, estritamente aleatório, **auto**, lkgp, otimizado por contexto,
retransmissão de contexto, além de um caminho de fallback) — auto é a adição principal na v3.8.0.
- **Pontuação de 9 fatores**: custo, latência p95, taxa de sucesso, margem de cota, proximidade de bloqueio,
estado do disjuntor, falhas recentes, disponibilidade do modelo e afinidade de tags.
- **Fábrica virtual** materializa combinações efêmeras quando nenhuma combinação nomeada correspondente
existe, buscando candidatos de conexões de provedores ativos e saudáveis.
existe, buscando candidatos de conexões de provedores ativos saudáveis.
- **Prefixos automáticos**: `auto/coding`, `auto/cheap`, `auto/fast`, `auto/offline`,
`auto/smart`, `auto/lkgp` — cada um respaldado por um perfil de peso ajustado.
- **4 pacotes de modo**: coding, fast, cheap, smart — enviados como configurações de peso pré-definidas
`auto/smart`, `auto/lkgp` — cada um apoiado por um perfil de peso ajustado.
- **4 pacotes de modo**: coding, fast, cheap, smart — enviados como configurações de peso predefinidas
chamáveis a partir do painel.
Para detalhes algorítmicos completos (fórmulas de fatores, ajuste de peso), consulte
Para detalhes algorítmicos completos (fórmulas de fatores, ajuste de peso), veja
[`docs/routing/AUTO-COMBO.md`](../routing/AUTO-COMBO.md).
### B. Agentes de Nuvem
Os Agentes de Nuvem envolvem plataformas de código-agente hospedadas de terceiros (Codex Cloud, Devin,
Jules) por trás de um ciclo de vida de tarefa uniforme baseado em DB. Todos os pontos finais de criação/inspeção
Jules) por trás de um ciclo de vida de tarefa uniforme baseado em DB. Todos os pontos de criação/inspeção
de tarefas requerem autenticação de gerenciamento.
- Raiz do módulo: `src/lib/cloudAgent/` (`baseAgent.ts`, `registry.ts`, `api.ts`,
@@ -378,23 +378,23 @@ de tarefas requerem autenticação de gerenciamento.
- Painel: `/dashboard/cloud-agents`
- Armazenamento: tabela `cloud_agent_tasks`
Para detalhes de provisionamento por agente e especificidades do OAuth, consulte
Para detalhes de provisionamento por agente e especificidades do OAuth, veja
[`docs/frameworks/CLOUD_AGENT.md`](../frameworks/CLOUD_AGENT.md).
### C. Guardrails
O módulo de guardrails é uma camada de middleware recarregável que inspeciona solicitações
e respostas em busca de PII, injeção de prompt e conteúdo visual inseguro. Violações
interrompem a solicitação com HTTP **503** mais um código de erro estruturado, permitindo
e respostas em busca de PII, injeção de prompt e conteúdo de visão inseguro. Violações
interrompem a solicitação com HTTP **503** além de um código de erro estruturado, permitindo
que chamadores subsequentes tentem novamente ou ramifiquem.
- Raiz do módulo: `src/lib/guardrails/` (`base.ts`, `registry.ts`, `piiMasker.ts`,
`promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`)
- Recarregamento a quente: o registro observa mudanças de configuração e reconstrói a cadeia no local
- Recarregamento a quente: o registro observa alterações de configuração e reconstrói a cadeia no local
- Pontos de conexão: entrada do manipulador de chat, manipulador de geração de imagem, sanitizador de resposta
- Contrato HTTP: violações aparecem como `503` com `error.code = "GUARDRAIL_VIOLATION"`
Para autoria de regras e ajuste de limiares, consulte
Para autoria de regras e ajuste de limiares, veja
[`docs/security/GUARDRAILS.md`](../security/GUARDRAILS.md).
### D. Camada de Domínio
@@ -425,24 +425,24 @@ O pipeline de autorização classifica cada solicitação recebida e aplica a
cadeia de políticas apropriada antes do despacho.
- Entrada do pipeline: `src/server/authz/pipeline.ts`
- Classificador de solicitações: `src/server/authz/classify.ts` — distingue rotas de compatibilidade pública
- Classificador de solicitações: `src/server/authz/classify.ts` — distingue rotas de compatibilidade públicas
de rotas de gerenciamento
- Inventário de rotas públicas: `src/shared/constants/publicApiRoutes.ts`
- Políticas: `src/server/authz/policies/` — predicados compostáveis
(`requireApiKey`, `requireManagement`, `requireFreshAuth`, etc.)
- Utilitários de cabeçalho: `src/server/authz/headers.ts`
- Auxiliar de asserção: `src/server/authz/assertAuth.ts`
- Helper de asserção: `src/server/authz/assertAuth.ts`
- Contexto da solicitação: `src/server/authz/context.ts`
Rotas públicas vs rotas de gerenciamento são uma fronteira rígida: APIs de agente/cooldown e
mutações de provedores requerem autenticação de gerenciamento (HTTP 401 se ausente).
Para as regras completas de classificação de rotas, consulte
Para as regras completas de classificação de rotas, veja
[`docs/architecture/AUTHZ_GUIDE.md`](./AUTHZ_GUIDE.md).
### F. FSM de Workflow e Roteador Consciente de Tarefas
Um roteador impulsionado por máquina de estados finitos, posicionado acima da seleção de combinações para direcionar
Um roteador acionado por máquina de estados finitos, posicionado acima da seleção de combinações para direcionar
o tráfego com base na fase de workflow detectada (planejamento, execução,
revisão) e afinidade de tarefas em segundo plano.
@@ -451,43 +451,43 @@ revisão) e afinidade de tarefas em segundo plano.
- Detector de tarefas em segundo plano: `open-sse/services/backgroundTaskDetector.ts`
- Classificador de intenção: `open-sse/services/intentClassifier.ts`
As transições da FSM alimentam a pontuação do Motor de Combinação Automática, tendendo a modelos mais baratos
para tarefas de background/automação e a modelos mais fortes para turnos interativos de planejamento/revisão.
As transições da FSM alimentam a pontuação do Auto Combo, tendendo a modelos mais baratos
para tarefas de automação/em segundo plano e a modelos mais fortes para planejamento/revisão interativa.
### G. Resiliência Específica do Provedor
Vários provedores enviam módulos dedicados de resiliência e furtividade que se aproveitam das
camadas globais de disjuntor / cooldown de conexão / bloqueio de modelo:
- Motor Antigravidade 429: `open-sse/services/antigravity429Engine.ts` (rotaciona
- Motor Antigravity 429: `open-sse/services/antigravity429Engine.ts` (rotaciona
identidade, limpa cabeçalhos de resposta, controla créditos/rastreamento de versão via
`antigravityCredits.ts`, `antigravityHeaderScrub.ts`, `antigravityHeaders.ts`,
`antigravityIdentity.ts`, `antigravityObfuscation.ts`, `antigravityVersion.ts`)
- Política de cota ModelScope: `open-sse/services/modelscopePolicy.ts`
- CCH de Código Claude (Handshake de Canal de Compatibilidade): `open-sse/services/claudeCodeCCH.ts`,
- Claude Code CCH (Handshake de Canal de Compatibilidade): `open-sse/services/claudeCodeCCH.ts`,
além de `claudeCodeCompatible.ts`, `claudeCodeConstraints.ts`, `claudeCodeExtraRemap.ts`,
`claudeCodeToolRemapper.ts`
- Modelagem de impressão digital de Código Claude: `open-sse/services/claudeCodeFingerprint.ts`
- Ofuscação de Código Claude: `open-sse/services/claudeCodeObfuscation.ts`
- Cliente TLS do ChatGPT: `open-sse/services/chatgptTlsClient.ts` (estilo curl-impersonate
para sessões do ChatGPT-Web)
- Modelagem de impressão digital do Claude Code: `open-sse/services/claudeCodeFingerprint.ts`
- Ofuscação do Claude Code: `open-sse/services/claudeCodeObfuscation.ts`
- Cliente TLS do ChatGPT: `open-sse/services/chatgptTlsClient.ts` (estilo de
impersonação curl para sessões do ChatGPT-Web)
- Cache de imagem do ChatGPT: `open-sse/services/chatgptImageCache.ts`
Para o guia completo de furtividade e orientações operacionais, consulte
Para o guia completo de furtividade e orientações operacionais, veja
[`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md).
### H. Webhooks, Cache de Raciocínio, Cache de Leitura
- **Webhooks** — despacho de saída para eventos de provedor/conta/tarefa.
- Despachante: `src/lib/webhookDispatcher.ts`
- Dispatcher: `src/lib/webhookDispatcher.ts`
- Armazenamento: tabela SQLite `webhooks` (via `src/lib/db/webhooks.ts`)
- Painel: `/dashboard/webhooks` (assinaturas, segredos, histórico de tentativas)
- Para taxonomia de eventos e semântica de tentativas, consulte [`docs/frameworks/WEBHOOKS.md`](../frameworks/WEBHOOKS.md).
- **Cache de Raciocínio** — blocos de raciocínio reproduzíveis para provedores que emitem
- Para taxonomia de eventos e semântica de tentativas, veja [`docs/frameworks/WEBHOOKS.md`](../frameworks/WEBHOOKS.md).
- **Cache de Raciocínio** — blocos de raciocínio replays para provedores que emitem
tokens de pensamento (Claude, GLMT, etc.) para que turnos consecutivos possam pular o re-pensamento.
- Camada de DB: `src/lib/db/reasoningCache.ts`
- Camada de serviço: `open-sse/services/reasoningCache.ts`
- Para semântica de reprodução, consulte [`docs/routing/REASONING_REPLAY.md`](../routing/REASONING_REPLAY.md).
- Para semântica de replay, veja [`docs/routing/REASONING_REPLAY.md`](../routing/REASONING_REPLAY.md).
- **Cache de Leitura** — cache de resposta de curta duração indexado por assinatura e usado para
colapsar tentativas idênticas de SDKs upstream quebrados.
- Camada de DB: `src/lib/db/readCache.ts`
@@ -513,7 +513,7 @@ Banco de dados de estado de domínio (SQLite):
- `src/lib/db/domainState.ts` — operações CRUD para estado de domínio
- Tabelas (criadas em `src/lib/db/core.ts`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers`
- Padrão de cache write-through: Maps em memória são autoritativos em tempo de execução; mutações são escritas de forma síncrona no SQLite; estado é restaurado do DB na inicialização a frio
- Padrão de cache write-through: Maps em memória são autoritativos em tempo de execução; mutações são escritas de forma síncrona no SQLite; o estado é restaurado do DB na inicialização a frio
## 4) Superfícies de Autenticação + Segurança
@@ -610,7 +610,7 @@ flowchart TD
Q -- Não --> R[Retornar todas indisponíveis]
```
As decisões de fallback são impulsionadas por `open-sse/services/accountFallback.ts` usando códigos de status e heurísticas de mensagens de erro. O roteamento de combo adiciona uma proteção extra: 400s específicos do provedor, como bloqueio de conteúdo upstream e falhas de validação de função, são tratados como falhas locais do modelo para que os alvos de combo posteriores ainda possam ser executados.
As decisões de fallback são impulsionadas por `open-sse/services/accountFallback.ts` usando códigos de status e heurísticas de mensagens de erro. O roteamento de combo adiciona uma proteção extra: 400s específicos do provedor, como bloqueio de conteúdo upstream e falhas de validação de função, são tratados como falhas locais do modelo, para que os alvos de combo posteriores ainda possam ser executados.
## Ciclo de Vida de Onboarding OAuth e Atualização de Token
@@ -630,7 +630,7 @@ sequenceDiagram
OAuth-->>UI: dados do fluxo
UI->>OAuth: POST trocar ou consultar
OAuth->>ProvAuth: troca de token/consulta
OAuth->>ProvAuth: troca/consulta de token
ProvAuth-->>OAuth: tokens de acesso/atualização
OAuth->>DB: createProviderConnection(dados oauth)
OAuth-->>UI: sucesso + id da conexão
@@ -656,7 +656,7 @@ sequenceDiagram
participant Claude as ~/.claude/settings.json
UI->>Sync: POST ação=habilitar
Sync->>DB: definir cloudEnabled=true
Sync->>DB: set cloudEnabled=true
Sync->>DB: garantir que a chave da API exista
Sync->>Cloud: POST /sync/{machineId} (provedores/aliases/combos/chaves)
Cloud-->>Sync: resultado da sincronização
@@ -670,7 +670,7 @@ sequenceDiagram
Sync-->>UI: sincronizado
UI->>Sync: POST ação=desabilitar
Sync->>DB: definir cloudEnabled=false
Sync->>DB: set cloudEnabled=false
Sync->>Cloud: DELETE /sync/{machineId}
Sync->>Claude: mudar ANTHROPIC_BASE_URL de volta para local (se necessário)
Sync-->>UI: desabilitado
@@ -781,8 +781,8 @@ erDiagram
Arquivos de armazenamento físico:
- banco de dados de runtime principal: `${DATA_DIR}/storage.sqlite`
- linhas de log de requisições: `${DATA_DIR}/log.txt` (artefato de compatibilidade/debug)
- banco de dados de runtime primário: `${DATA_DIR}/storage.sqlite`
- linhas de log de requisições: `${DATA_DIR}/log.txt` (artefato de compat/debug)
- arquivos de payload de chamadas estruturadas: `${DATA_DIR}/call_logs/`
- sessões de depuração de tradutor/requisição opcionais: `<repo>/logs/...`
@@ -795,7 +795,7 @@ flowchart LR
Browser[Navegador do Dashboard]
end
subgraph ContainerOrProcess[Runtime OmniRoute]
subgraph ContainerOrProcess[Runtime do OmniRoute]
Next[Servidor Next.js\nPORT=20128]
Core[Núcleo SSE + Executores]
MainDB[(storage.sqlite)]
@@ -823,7 +823,7 @@ flowchart LR
- `src/app/api/v1/*`, `src/app/api/v1beta/*`: APIs de compatibilidade
- `src/app/api/v1/providers/[provider]/*`: rotas dedicadas por provedor (chat, embeddings, imagens)
- `src/app/api/providers*`: CRUD de provedor, validação, teste
- `src/app/api/providers*`: CRUD de provedores, validação, teste
- `src/app/api/provider-nodes*`: gerenciamento de nós compatíveis personalizados
- `src/app/api/provider-models`: gerenciamento de modelos personalizados (CRUD)
- `src/app/api/models/route.ts`: API de catálogo de modelos (aliases + modelos personalizados)
@@ -831,7 +831,7 @@ flowchart LR
- `src/app/api/keys*`: ciclo de vida da chave API local
- `src/app/api/models/alias`: gerenciamento de alias
- `src/app/api/combos*`: gerenciamento de combos de fallback
- `src/app/api/pricing`: substituições de preços para cálculo de custo
- `src/app/api/pricing`: substituições de preços para cálculo de custos
- `src/app/api/settings/proxy`: configuração de proxy (GET/PUT/DELETE)
- `src/app/api/settings/proxy/test`: teste de conectividade de proxy de saída (POST)
- `src/app/api/usage/*`: APIs de uso e logs
@@ -842,7 +842,7 @@ flowchart LR
- `src/app/api/settings/system-prompt`: prompt do sistema global (GET/PUT)
- `src/app/api/settings/compression`: configurações de compressão global (GET/PUT)
- `src/app/api/compression/*`: visualização de compressão, metadados de regras e pacotes de idioma
- `src/app/api/context/caveman/config`: alias de configurações Caveman (GET/PUT)
- `src/app/api/context/caveman/config`: alias de configurações do Caveman (GET/PUT)
- `src/app/api/context/rtk/*`: configuração RTK, catálogo de filtros, endpoint de teste e recuperação de saída bruta
- `src/app/api/context/combos*`: CRUD de combos de compressão e atribuições de combos de roteamento
- `src/app/api/context/analytics`: alias de análises de compressão
@@ -871,7 +871,7 @@ flowchart LR
### Persistência
- `src/lib/db/*`: configuração/persistência de estado persistente e persistência de domínio no SQLite
- `src/lib/db/*`: configuração/persistência de estado e domínio persistente no SQLite
- `src/lib/localDb.ts`: re-exportação de compatibilidade para módulos de DB
- `src/lib/usageDb.ts`: fachada de histórico de uso/logs de chamadas sobre tabelas SQLite
@@ -883,13 +883,13 @@ Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Configuração dinâmica de URL/cabeçalho por provedor |
| `AntigravityExecutor` | Google Antigravity | IDs de projeto/sessão personalizados, análise de Retry-After, ofuscação de 429 |
| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, imposição de consulta de api-version |
| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, aplicação de consulta de api-version |
| `BlackboxWebExecutor` | Blackbox AI (modo web) | Reversão de sessão web com emulação de impressão digital TLS |
| `ChatGPTWebExecutor` | ChatGPT web | Gerenciamento de cliente TLS + cookie de sessão (`chatgptTlsClient.ts`) |
| `ClaudeIdentityExecutor` | Claude.ai (caminho CCH) | Pipelines de restrição + remapeamento de ferramentas, modelagem de impressão digital |
| `CliProxyApiExecutor` | Provedores compatíveis com CLIProxyAPI | Manipulação personalizada de autenticação e protocolo |
| `CloudflareAiExecutor` | Cloudflare Workers AI | Injeção de ID de conta, rastreamento de uso baseado em Neurons |
| `CodexExecutor` | OpenAI Codex | Injeta instruções do sistema, força esforço de raciocínio |
| `CodexExecutor` | OpenAI Codex | Injeções de instruções do sistema, força de esforço de raciocínio |
| `CommandCodeExecutor` | Código de Comando | Rotação de cabeçalho por sessão + OAuth |
| `CursorExecutor` | Cursor IDE | Protocolo ConnectRPC, codificação Protobuf, assinatura de requisições via checksum |
| `DevinCliExecutor` | Devin CLI | Conexão do ciclo de vida da tarefa Devin via módulo de agente em nuvem |
@@ -900,14 +900,14 @@ Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open
| `GrokWebExecutor` | xAI Grok web | Reversão de sessão web, seleção de modo (pensar/padrão) |
| `KieExecutor` | KIE | Emissão de token personalizada com âncoras de sessão rotativas |
| `KiroExecutor` | AWS CodeWhisperer/Kiro | Formato binário do AWS EventStream → conversão para SSE |
| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de mensagem de imagem |
| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de imagem-mensagem |
| `NlpCloudExecutor` | NLP Cloud | Formato de corpo de requisição específico do provedor |
| `OpenCodeExecutor` | OpenCode | Configuração de provedor compatível com AI SDK |
| `PerplexityWebExecutor` | Perplexity web | Reversão de sessão web para continuidade de chat |
| `PetalsExecutor` | Inferência distribuída Petals | Roteamento de enxame descentralizado |
| `PollinationsExecutor` | Pollinations AI | Nenhuma chave de API necessária, requisições limitadas por taxa |
| `PuterExecutor` | Puter | Integração de provedor baseada em navegador |
| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito de múltiplos modelos |
| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito multi-modelo |
| `VertexExecutor` | Google Vertex AI | Autenticação de conta de serviço, endpoints baseados em região |
| `WindsurfExecutor` | Windsurf (Codeium) | Atualização de token de sessão + OAuth do Codeium |
@@ -918,69 +918,69 @@ Todos os outros provedores (incluindo nós compatíveis personalizados) usam o `
> **Nota:** A matriz abaixo é uma amostra representativa dos 177 provedores registrados no
> OmniRoute v3.8.0. Para a lista canônica e continuamente atualizada, consulte
> [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (gerada automaticamente) ou a fonte
> de verdade em `src/shared/constants/providers.ts` (validada pelo Zod no carregamento).
> de verdade em `src/shared/constants/providers.ts` (validada pelo Zod na carga).
| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso |
| ----------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | ----------------------- |
| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin |
| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total |
| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa |
| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Instantâneas de cota |
| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ |
| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso |
| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ |
| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ |
| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ |
| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ |
| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Puter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ |
| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação |
| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ |
| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Windsurf | openai | OAuth (Codeium) | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa |
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| ChatGPT-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota |
| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ |
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ |
| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso |
| ----------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | -------------------- |
| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin |
| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total |
| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa |
| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Capturas de cota |
| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ |
| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso |
| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ |
| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ |
| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ |
| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ |
| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
| Puter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ |
| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação |
| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ |
| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Windsurf | openai | OAuth (Codeium) | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa |
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| ChatGPT-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota |
| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ |
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ |
## Cobertura de Tradução de Formato
@@ -1005,36 +1005,36 @@ As traduções usam **OpenAI como o formato central** — todas as conversões p
Formato de Origem → OpenAI (central) → Formato de Destino
```
As traduções são selecionadas dinamicamente com base na forma da carga útil de origem e no formato de destino do provedor.
As traduções são selecionadas dinamicamente com base na forma do payload de origem e no formato de destino do provedor.
Camadas de processamento adicionais no pipeline de tradução:
- **Sanitização de resposta** — Remove campos não padrão das respostas no formato OpenAI (tanto streaming quanto não streaming) para garantir conformidade estrita com o SDK
- **Normalização de função** — Converte `developer``system` para destinos que não são OpenAI; mescla `system``user` para modelos que rejeitam a função de sistema (GLM, ERNIE)
- **Normalização de função** — Converte `developer``system` para alvos que não são OpenAI; mescla `system``user` para modelos que rejeitam a função de sistema (GLM, ERNIE)
- **Extração de tag de pensamento** — Analisa blocos ``do conteúdo para o campo`reasoning_content`
- **Saída estruturada** — Converte `response_format.json_schema` do OpenAI para `responseMimeType` + `responseSchema` do Gemini
## Endpoints da API Suportados
## Endpoints de API Suportados
| Endpoint | Formato | Manipulador |
| -------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
| `POST /v1/messages` | Claude Messages | Mesmo manipulador (detecção automática) |
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
| `GET /v1/embeddings` | Listagem de Modelos | Rota da API |
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
| `GET /v1/images/generations` | Listagem de Modelos | Rota da API |
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicado por provedor com validação de modelo |
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicado por provedor com validação de modelo |
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicado por provedor com validação de modelo |
| `POST /v1/messages/count_tokens` | Contagem de Tokens Claude | Rota da API |
| `GET /v1/models` | Lista de Modelos OpenAI | Rota da API (chat + embedding + image + modelos personalizados) |
| `GET /api/models/catalog` | Catálogo | Todos os modelos agrupados por provedor + tipo |
| `POST /v1beta/models/*:streamGenerateContent` | Nativo do Gemini | Rota da API |
| `GET/PUT/DELETE /api/settings/proxy` | Configuração de Proxy | Configuração de proxy de rede |
| `POST /api/settings/proxy/test` | Conectividade de Proxy | Endpoint de teste de saúde/conectividade do proxy |
| `GET/POST/DELETE /api/provider-models` | Modelos de Provedor | Metadados do modelo do provedor que suportam modelos disponíveis personalizados e gerenciados |
| Endpoint | Formato | Manipulador |
| -------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
| `POST /v1/messages` | Claude Messages | Mesmo manipulador (detecção automática) |
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
| `GET /v1/embeddings` | Listagem de modelos | Rota da API |
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
| `GET /v1/images/generations` | Listagem de modelos | Rota da API |
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicado por provedor com validação de modelo |
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicado por provedor com validação de modelo |
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicado por provedor com validação de modelo |
| `POST /v1/messages/count_tokens` | Contagem de Tokens Claude | Rota da API |
| `GET /v1/models` | Lista de Modelos OpenAI | Rota da API (chat + embedding + imagem + modelos personalizados) |
| `GET /api/models/catalog` | Catálogo | Todos os modelos agrupados por provedor + tipo |
| `POST /v1beta/models/*:streamGenerateContent` | Nativo do Gemini | Rota da API |
| `GET/PUT/DELETE /api/settings/proxy` | Configuração de Proxy | Configuração de proxy de rede |
| `POST /api/settings/proxy/test` | Conectividade de Proxy | Endpoint de teste de saúde/conectividade do proxy |
| `GET/POST/DELETE /api/provider-models` | Modelos de Provedor | Metadados do modelo do provedor que sustentam modelos disponíveis personalizados e gerenciados |
## Manipulador de Bypass
@@ -1042,9 +1042,9 @@ O manipulador de bypass (`open-sse/utils/bypassHandler.ts`) intercepta solicita
## Registro de Solicitações e Artefatos
O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/requestLogger.ts`) é mantido apenas para compatibilidade com versões anteriores. O contrato de tempo de execução atual utiliza:
O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/requestLogger.ts`) é mantido apenas para compatibilidade com versões anteriores. O contrato de tempo de execução atual usa:
- `APP_LOG_TO_FILE=true` para logs de aplicação e auditoria escritos em `<repo>/logs/`
- `APP_LOG_TO_FILE=true` para logs de aplicação e auditoria gravados em `<repo>/logs/`
- Registros de log de chamadas com suporte a SQLite em `call_logs`
- Artefatos em `${DATA_DIR}/call_logs/YYYY-MM-DD/...` quando o pipeline de log de chamadas está habilitado
@@ -1061,18 +1061,18 @@ O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/reque
- pré-verificação e atualização com nova tentativa para provedores atualizáveis
- nova tentativa 401/403 após tentativa de atualização no caminho principal
## 3) Segurança de Stream
## 3) Segurança do Stream
- controlador de stream ciente de desconexões
- stream de tradução com descarte de fim de stream e tratamento de `[DONE]`
- fallback de estimativa de uso quando os metadados de uso do provedor estão ausentes
## 4) Degradação de Sincronização em Nuvem
## 4) Degradação da Sincronização na Nuvem
- erros de sincronização são exibidos, mas o tempo de execução local continua
- o agendador possui lógica capaz de nova tentativa, mas a execução periódica atualmente chama a sincronização de tentativa única por padrão
## 5) Integridade de Dados
## 5) Integridade dos Dados
- migrações de esquema SQLite e ganchos de autoatualização na inicialização
- caminho de compatibilidade de migração legado JSON → SQLite
@@ -1100,15 +1100,15 @@ A captura detalhada do payload da solicitação armazena até quatro estágios d
- solicitação bruta recebida do cliente
- solicitação traduzida realmente enviada para upstream
- resposta do provedor reconstruída como JSON; respostas transmitidas são compactadas para o resumo final mais metadados do stream
- resposta final do cliente retornada pelo OmniRoute; respostas transmitidas são armazenadas na mesma forma de resumo compactado
- resposta final do cliente retornada pelo OmniRoute; respostas transmitidas são armazenadas na mesma forma de resumo compacto
## Limites Sensíveis à Segurança
- O segredo do JWT (`JWT_SECRET`) protege a verificação/assinatura do cookie de sessão do painel
- A senha inicial de bootstrap (`INITIAL_PASSWORD`) deve ser configurada explicitamente para o provisionamento na primeira execução
- O segredo HMAC da chave da API (`API_KEY_SECRET`) protege o formato da chave da API local gerada
- Segredos do provedor (chaves/tokens da API) são persistidos no banco de dados local e devem ser protegidos a nível de sistema de arquivos
- Os endpoints de sincronização em nuvem dependem da autenticação da chave da API + semântica do id da máquina
- Segredos do provedor (chaves/token da API) são persistidos no banco de dados local e devem ser protegidos a nível de sistema de arquivos
- Os endpoints de sincronização na nuvem dependem da autenticação da chave da API + semântica do ID da máquina
## Matriz de Ambiente e Tempo de Execução
@@ -1117,7 +1117,7 @@ Variáveis de ambiente ativamente usadas pelo código:
- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD`
- Armazenamento: `DATA_DIR`
- Comportamento compatível do node: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE`
- Substituição opcional da base de armazenamento (Linux/macOS quando `DATA_DIR` não definido): `XDG_CONFIG_HOME`
- Substituição opcional da base de armazenamento (Linux/macOS quando `DATA_DIR` não estiver definido): `XDG_CONFIG_HOME`
- Hashing de segurança: `API_KEY_SECRET`, `MACHINE_ID_SALT`
- Registro: `APP_LOG_TO_FILE`, `APP_LOG_RETENTION_DAYS`, `CALL_LOG_RETENTION_DAYS`
- URL de sincronização/nuvem: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL`
@@ -1128,15 +1128,15 @@ Variáveis de ambiente ativamente usadas pelo código:
## Notas Arquitetônicas Conhecidas
1. `usageDb` e `localDb` compartilham a mesma política de diretório base (`DATA_DIR` -> `XDG_CONFIG_HOME/omniroute` -> `~/.omniroute`) com migração de arquivos legados.
2. `/api/v1/route.ts` delega para o mesmo construtor de catálogo unificado usado por `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) para evitar desvios semânticos.
3. O logger de requisições escreve cabeçalhos/corpo completos quando habilitado; trate o diretório de logs como sensível.
4. O comportamento em nuvem depende do correto `NEXT_PUBLIC_BASE_URL` e da acessibilidade do endpoint em nuvem.
5. O diretório `open-sse/` é publicado como o pacote **npm workspace** `@omniroute/open-sse`. O código-fonte o importa via `@omniroute/open-sse/...` (resolvido pelo Next.js `transpilePackages`). Os caminhos de arquivos neste documento ainda usam o nome do diretório `open-sse/` para consistência.
6. Gráficos no painel usam **Recharts** (baseado em SVG) para visualizações analíticas interativas e acessíveis (gráficos de barras de uso de modelo, tabelas de quebra de provedor com taxas de sucesso).
2. `/api/v1/route.ts` delega ao mesmo construtor de catálogo unificado usado por `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) para evitar desvios semânticos.
3. O registrador de requisições escreve cabeçalhos/corpo completos quando habilitado; trate o diretório de logs como sensível.
4. O comportamento na nuvem depende do correto `NEXT_PUBLIC_BASE_URL` e da acessibilidade do endpoint da nuvem.
5. O diretório `open-sse/` é publicado como o pacote de **workspace npm** `@omniroute/open-sse`. O código-fonte o importa via `@omniroute/open-sse/...` (resolvido pelo Next.js `transpilePackages`). Os caminhos de arquivo neste documento ainda usam o nome do diretório `open-sse/` para consistência.
6. Gráficos no painel usam **Recharts** (baseado em SVG) para visualizações analíticas acessíveis e interativas (gráficos de barras de uso de modelo, tabelas de quebra de provedor com taxas de sucesso).
7. Testes E2E usam **Playwright** (`tests/e2e/`), executados via `npm run test:e2e`. Testes unitários usam **Node.js test runner** (`tests/unit/`), executados via `npm run test:unit`. O código-fonte sob `src/` é **TypeScript** (`.ts`/`.tsx`); o workspace `open-sse/` permanece em JavaScript (`.js`).
8. A página de configurações é organizada em 7 abas: Geral, Aparência, IA, Segurança, Roteamento, Resiliência, Avançado. A página de Resiliência configura apenas a fila de requisições, o tempo de espera de conexão, o quebra-provedor e o comportamento de espera; o estado de tempo de execução do quebra ao vivo é mostrado na página de Saúde.
9. A estratégia **Context Relay** (`context-relay`) é dividida em duas camadas: `combo.ts` decide se uma transferência deve ser gerada, `chat.ts` injeta a transferência após a resolução da conta. Os dados da transferência vivem na tabela SQLite `context_handoffs`. Essa divisão é intencional porque apenas `chat.ts` sabe se a conta real mudou.
10. A **imposição de proxy** agora é abrangente: `tokenHealthCheck.ts` resolve o proxy por conexão, `/api/providers/validate` usa `runWithProxyContext`, e `proxyFetch.ts` usa `undici.fetch()` para manter a compatibilidade do despachante no Node 22.
8. A página de configurações é organizada em 7 abas: Geral, Aparência, IA, Segurança, Roteamento, Resiliência, Avançado. A página de Resiliência configura apenas a fila de requisições, o tempo de espera de conexão, o quebra-provedor e o comportamento de espera pelo tempo de espera; o estado de tempo de execução do quebra ao vivo é mostrado na página de Saúde.
9. A estratégia de **Context Relay** (`context-relay`) é dividida em duas camadas: `combo.ts` decide se uma transferência deve ser gerada, `chat.ts` injeta a transferência após a resolução da conta. Os dados da transferência vivem na tabela SQLite `context_handoffs`. Essa divisão é intencional porque apenas `chat.ts` sabe se a conta real mudou.
10. A **aplicação de proxy** agora é abrangente: `tokenHealthCheck.ts` resolve o proxy por conexão, `/api/providers/validate` usa `runWithProxyContext`, e `proxyFetch.ts` usa `undici.fetch()` para manter a compatibilidade do despachante no Node 22.
11. **Detecção de política de tempo de execução do Node.js**: `/api/settings/require-login` retorna os campos `nodeVersion` e `nodeCompatible`. A página de login renderiza um banner de aviso quando o tempo de execução está fora das linhas seguras suportadas do Node.js.
## Lista de Verificação de Verificação Operacional

View File

@@ -0,0 +1,346 @@
# E2E Dashboard Shakedown — v3.8.0
**Branch alvo:** `release/v3.8.0`
**Objetivo:** validar manualmente, em modo dev (Turbopack), que toda página renderiza sem erro de runtime ou de backend antes de fechar a versão 3.8.0. Para cada erro encontrado, o operador **corrige na própria página** e segue para a próxima — esse documento é o roteiro vivo da sessão.
> Este é um plano de **smoke test manual operacional**, não uma suíte automatizada. Pareceu didático demais? É proposital: o objetivo é que outro mantenedor consiga retomar do meio se a sessão for interrompida.
---
## 0. Pré-requisitos (rodar uma vez)
### 0.1 Estado do repositório
```bash
git fetch origin
git checkout release/v3.8.0
git pull origin release/v3.8.0 --ff-only
git status # working tree limpo
```
### 0.2 Conflito conhecido — diretório `app/` na raiz
O `npm pack` e `npm run build` geram `app/` na raiz (gitignored, mirror de `src/app/`). Se ele existir, **o Next.js dev prefere a raiz e quebra todas as rotas** (Turbopack devolve `PageNotFoundError: Cannot find module for page: route not found /(dashboard)/...`).
```bash
[ -d app ] && mv app /tmp/omniroute-pack-artifact-$(date +%s)
ls -d app 2>/dev/null && echo "STILL THERE — abortar" || echo "ok"
```
### 0.3 Cache do Turbopack
```bash
rm -rf .next/dev
```
### 0.4 Dev server
Em um terminal dedicado:
```bash
npm run dev 2>&1 | tee /tmp/omniroute-dev.log
```
Esperar `Ready` e `Local: http://localhost:20128`. Mantenha o terminal visível durante toda a sessão — é a fonte primária de erros de backend.
### 0.5 Browser
- Chrome com **DevTools aberto** (F12), aba **Console** ativa, **filtro `error|warning`**, e a aba **Network** com "Preserve log" marcado.
- Limpar o console entre páginas (`Ctrl+L`) para isolar o ruído.
- Login com a conta admin antes de começar (algumas páginas só carregam autenticado).
### 0.6 Side-channel — busca por erros no backend
Em outro terminal:
```bash
tail -F /tmp/omniroute-dev.log | grep --line-buffered -iE "error|warn|cannot|undefined|TypeError|PageNotFoundError"
```
Mantenha aberto. Se algo aparecer enquanto você está em uma página, anote na coluna **Erros** da linha correspondente.
---
## 1. O que conta como "passou"
Uma página passa quando **todas** estas condições são atendidas:
1. HTTP final é `200` (não `4xx`/`5xx`). Redirects (`307`/`302`) só são aceitáveis se intencionais (ex.: `/dashboard``/home`).
2. Nenhum **error overlay** do Turbopack/React aparece na tela.
3. Nenhum `console.error` no DevTools (warnings são toleráveis, mas anote os novos).
4. Nenhuma stack trace nova no `/tmp/omniroute-dev.log`. Erros pré-existentes recorrentes (refresh de token de provider sem credencial, p.ex.) podem ser ignorados — mas confirme que são os mesmos de antes.
5. Conteúdo principal da página renderiza (não apenas o layout/sidebar vazio).
6. Pelo menos uma interação básica funciona (clique em uma aba, filtro, ou link interno) sem erro.
Se qualquer um falhar → **status `❌`**, descreva o sintoma na coluna **Erros**, corrija, recarregue, marque `✅` quando passar.
---
## 2. Categorias de erro mais comuns e padrão de correção
| Sintoma | Lugar onde aparece | Causa típica | Onde corrigir |
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `PageNotFoundError: route not found /(group)/...` | Tela vermelha do Turbopack | `app/` na raiz, ou `.next/dev/` stale | Voltar à §0.2/0.3 |
| `Cannot find module 'X'` em runtime | Tela ou log | Import inexistente, alias quebrado | Corrigir o import; checar `tsconfig.json` |
| `Hydration failed because the server rendered HTML didn't match` | Console | Date/Math.random no render server, ou `useEffect` fora de `"use client"` | Mover lógica para `useEffect`, ou marcar a sub-árvore como `dynamic="force-dynamic"` |
| `500` em rota de API chamada pela página | Network + log | Zod parse fail, DB error, validador de input | Ver o handler em `src/app/api/.../route.ts` e o módulo em `src/lib/db/` |
| `Error: Text content does not match server-rendered HTML` | Console | i18n key faltando, ou string diferente entre SSR/CSR | `npm run i18n:run -- --files=<arquivo>` ou adicionar a chave |
| Skeleton infinito | Tela | `useEffect` busca dados de uma API que retorna 401/500 | Conferir auth/proxy/middleware; testar a rota com `curl -H "cookie:..."` |
| Sidebar/layout não renderiza | Tela | `(dashboard)/layout.tsx` falhando | Olhar o `DashboardLayout` em `src/shared/components/` |
| Botão/tab dispara erro ao clicar | Console | Provider/context ausente, mock removido | Verificar providers no `(dashboard)/layout.tsx` |
**Regra de ouro:** se a correção exigir mais de ~20 linhas ou cruza módulos do `open-sse/`, anote como `bloqueador` e siga para a próxima página — não trave a release por um refactor.
---
## 3. Checklist de páginas (ordem sugerida)
Marque conforme avança. A ordem segue a sidebar (top → bottom) com as páginas órfãs no final. URLs assumem `http://localhost:20128`.
### 3.1 Auth & público (rodar **deslogado** primeiro, depois logar)
| Status | URL | O que validar | Erros |
| ------ | ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----- |
| ☐ | `/` | Redireciona para `/login` ou `/home` conforme sessão | |
| ☐ | `/login` | Form aparece, validação de campo vazio funciona | |
| ☐ | `/forgot-password` | Form aparece | |
| ☐ | `/landing` | Renderiza sem CSS quebrado | |
| ☐ | `/docs` | Index dos docs carrega | |
| ☐ | `/docs/api-explorer` | OpenAPI explorer carrega | |
| ☐ | `/docs/quickstart` (ex. slug) | Markdown renderiza | |
| ☐ | `/status` | Status page renderiza | |
| ☐ | `/terms` | Texto carrega | |
| ☐ | `/privacy` | Texto carrega | |
| ☐ | `/maintenance` | Página estática | |
| ☐ | `/offline` | Página estática | |
| ☐ | `/forbidden`, `/400`, `/401`, `/403`, `/408`, `/429`, `/500`, `/502`, `/503` | Cada uma renderiza sem erro recursivo | |
> Após confirmar o `/login`, autentique e siga.
### 3.2 Sidebar — Home
| Status | URL | Validação | Erros |
| ------ | ------------ | --------------------------------------------------- | ----- |
| ☐ | `/dashboard` | Redireciona para `/home` (HTTP 307) | |
| ☐ | `/home` | Cards de overview renderizam, sem skeleton infinito | |
### 3.3 Sidebar — OmniProxy
| Status | URL | Validação | Erros |
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ----- |
| ☐ | `/dashboard/endpoint` | Lista de endpoints + tabs | |
| ☐ | `/dashboard/api-manager` | Lista de API keys, botão "Create" abre modal | |
| ☐ | `/dashboard/providers` | Tabela de providers carrega; filtro de status funciona | |
| ☐ | `/dashboard/providers/new` | Form de novo provider; campos condicionais respondem | |
| ☐ | `/dashboard/providers/anthropic` (qualquer `[id]` válido) | Detalhe do provider; abas "Connections", "Models", "Validate" | |
| ☐ | `/dashboard/combos` | Lista de combos, drag/drop, modo cost-optimized | |
| ☐ | `/dashboard/quota` | Quotas globais por provider/modelo | |
#### 3.3.1 Compressão e Contexto
| Status | URL | Validação | Erros |
| ------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----- |
| ☐ | `/dashboard/context/caveman` | Regras Caveman + preview ao vivo | |
| ☐ | `/dashboard/context/rtk` | DSL editor (Monaco) — **atenção:** se Monaco falhar com `vs/nls.messages-loader`, é regressão do fix do `MonacoEditor.tsx` | |
| ☐ | `/dashboard/context/combos` | Pipeline RTK→Caveman | |
#### 3.3.2 Ferramentas
| Status | URL | Validação | Erros |
| ------ | ------------------------- | -------------------------------------------------------- | ----- |
| ☐ | `/dashboard/cli-tools` | Lista de tools, botão "Generate config" responde sem 500 | |
| ☐ | `/dashboard/agents` | Lista de agents | |
| ☐ | `/dashboard/cloud-agents` | 3 cloud agents listados (codex-cloud, devin, jules) | |
#### 3.3.3 Integrações
| Status | URL | Validação | Erros |
| ------ | -------------------------- | --------------------------------- | ----- |
| ☐ | `/dashboard/api-endpoints` | OpenAPI auto-doc renderiza | |
| ☐ | `/dashboard/webhooks` | Form de webhook + lista de events | |
#### 3.3.4 Proxy
| Status | URL | Validação | Erros |
| ------ | ------------------------------ | ----------------------------------- | ----- |
| ☐ | `/dashboard/system/proxy` | Config de proxy global/per-provider | |
| ☐ | `/dashboard/system/mitm-proxy` | Cert install button, status | |
| ☐ | `/dashboard/system/1proxy` | UI da feature 1proxy | |
### 3.4 Sidebar — Analytics
| Status | URL | Validação | Erros |
| ------ | ----------------------------------- | ----------------------------------------- | ----- |
| ☐ | `/dashboard/analytics` | Dashboard de uso geral, gráficos carregam | |
| ☐ | `/dashboard/analytics/combo-health` | Tabela + sparklines por combo | |
| ☐ | `/dashboard/analytics/utilization` | Heatmap | |
| ☐ | `/dashboard/costs` | Custo total + breakdown | |
| ☐ | `/dashboard/cache` | Métricas de cache, hit-rate | |
| ☐ | `/dashboard/analytics/compression` | Métricas de RTK/Caveman | |
| ☐ | `/dashboard/analytics/search` | Métricas de search providers | |
| ☐ | `/dashboard/analytics/evals` | Suítes de eval + run history | |
### 3.5 Sidebar — Monitoring
| Status | URL | Validação | Erros |
| ------ | -------------------------- | ---------------------------------------------------------- | ----- |
| ☐ | `/dashboard/logs` | Hub de logs | |
| ☐ | `/dashboard/logs/proxy` | Tail de requisições (live) — confirmar reconexão se houver | |
| ☐ | `/dashboard/logs/console` | Console capturado | |
| ☐ | `/dashboard/logs/activity` | Atividade do usuário | |
| ☐ | `/dashboard/health` | Status dos providers (circuit breakers, cooldowns) | |
| ☐ | `/dashboard/runtime` | Métricas runtime + memória/CPU | |
#### 3.5.1 Costs / Parameters
| Status | URL | Validação | Erros |
| ------ | ------------------------------ | ---------------------------------------- | ----- |
| ☐ | `/dashboard/costs/pricing` | Tabela pricing por modelo, edição inline | |
| ☐ | `/dashboard/costs/budget` | Limites mensais + alertas | |
| ☐ | `/dashboard/costs/quota-share` | Preview de quota sharing | |
#### 3.5.2 Audit
| Status | URL | Validação | Erros |
| ------ | ---------------------- | ------------------------------- | ----- |
| ☐ | `/dashboard/audit` | Lista de eventos audit, filtros | |
| ☐ | `/dashboard/audit/mcp` | Audit do MCP server | |
| ☐ | `/dashboard/audit/a2a` | Audit do A2A | |
### 3.6 Sidebar — DevTools
| Status | URL | Validação | Erros |
| ------ | ------------------------- | ------------------------------------- | ----- |
| ☐ | `/dashboard/translator` | OpenAI ↔ Claude ↔ Gemini side-by-side | |
| ☐ | `/dashboard/playground` | Chat playground, streaming funciona | |
| ☐ | `/dashboard/search-tools` | Lista de search providers | |
### 3.7 Sidebar — Agentic Features
| Status | URL | Validação | Erros |
| ------ | ------------------------- | ------------------------------------ | ----- |
| ☐ | `/dashboard/mcp` | MCP server config, 37 tools listadas | |
| ☐ | `/dashboard/memory` | Memory store, FTS5 search | |
| ☐ | `/dashboard/skills` | 10 skills publicadas | |
| ☐ | `/dashboard/agent-skills` | Skill assignment per agent | |
| ☐ | `/dashboard/a2a` | A2A registry + 5 skills | |
### 3.8 Sidebar — Other Features
| Status | URL | Validação | Erros |
| ------ | ------------------------------- | --------------------------------- | ----- |
| ☐ | `/dashboard/leaderboard` | Ranking + filtros | |
| ☐ | `/dashboard/profile` | Perfil do user, edição básica | |
| ☐ | `/dashboard/tokens` | Tokens & API keys do user | |
| ☐ | `/dashboard/gamification/admin` | Admin-only — só logado como admin | |
| ☐ | `/dashboard/cache/media` | Cache de mídia (imagens/áudio) | |
| ☐ | `/dashboard/batch` | Batch jobs | |
| ☐ | `/dashboard/batch/files` | Files API | |
### 3.9 Sidebar — Configuration
| Status | URL | Validação | Erros |
| ------ | -------------------------------- | ------------------------------ | ----- |
| ☐ | `/dashboard/settings` | Hub redireciona/exibe sub-tabs | |
| ☐ | `/dashboard/settings/general` | Form salva sem 500 | |
| ☐ | `/dashboard/settings/appearance` | Trocar tema aplica | |
| ☐ | `/dashboard/settings/ai` | Config de AI models | |
| ☐ | `/dashboard/settings/routing` | Estratégias de combo | |
| ☐ | `/dashboard/settings/resilience` | Circuit breaker / cooldown | |
| ☐ | `/dashboard/settings/advanced` | Toggles avançados | |
| ☐ | `/dashboard/settings/security` | Auth, sessões, 2FA | |
| ☐ | `/dashboard/settings/pricing` | Settings de pricing (legado) | |
### 3.10 Sidebar — Help
| Status | URL | Validação | Erros |
| ------ | --------------------------- | ---------------------------------- | ----- |
| ☐ | `/docs` (já testado em 3.1) | — | |
| ☐ | `/dashboard/changelog` | Renderiza markdown do CHANGELOG.md | |
### 3.11 Páginas órfãs (existem como rota mas não estão na sidebar)
Testar para garantir que não estão quebradas (alguém pode ter link antigo bookmarkado).
| Status | URL | Validação | Erros |
| ------ | ------------------------ | -------------------------------------------------------------- | ------------------------ |
| ☐ | `/dashboard/auto-combo` | Página de Auto-Combo (9-factor scoring) | |
| ☐ | `/dashboard/compression` | (legado — pode ter sido absorvido por `analytics/compression`) | |
| ☐ | `/dashboard/limits` | Rate limits | |
| ☐ | `/dashboard/onboarding` | Wizard de primeiro setup | |
| ☐ | `/dashboard/usage` | Stats de uso (legado) | |
| ☐ | `/auth/callback` | OAuth callback — só funciona via flow real | (não testar manualmente) |
| ☐ | `/callback` | Mesmo do anterior | (não testar manualmente) |
---
## 4. Procedimento por página
Para cada linha do checklist:
1. **Limpar console do DevTools** (`Ctrl+L`).
2. **Navegar** clicando na sidebar (preferível a digitar URL — testa também a navegação).
3. **Esperar carregar** (até o spinner sumir e o conteúdo principal aparecer; timeout subjetivo: 10s).
4. **Olhar o DevTools Console**: qualquer `error` vermelho conta.
5. **Olhar o terminal do `tail -F /tmp/omniroute-dev.log`**: stack trace nova = falha.
6. **Interagir** com o elemento óbvio da página (1 clique em filtro/aba/CTA). Se o clique disparar erro, falha.
7. **Marcar `✅`** se ok, **`❌` + nota** se falhar.
8. **Se falhar**:
a. Categorizar pela tabela §2.
b. Corrigir.
c. Salvar; aguardar Turbopack recompilar (~2-5s; olhar o terminal do dev server).
d. Recarregar a página; refazer §16.
e. Quando passar, atualizar a coluna **Erros** desta linha com "Fix: <resumo do que mudou>" e marcar `✅`.
9. **Próxima linha.**
---
## 5. Commits durante a sessão
Não acumular commits enormes. **Um fix por página**, com escopo claro:
```bash
# exemplo
git add src/app/\(dashboard\)/dashboard/<página>/page.tsx
git commit -m "fix(<area>): <descrição curta>
E2E shakedown v3.8.0: <página> quebrava com <sintoma>.
<o que mudou e por quê>"
```
Não usar `Co-Authored-By` (hard rule #16). Não rodar `--no-verify`.
Ao final da sessão, **push único** com todos os fixes:
```bash
git push origin release/v3.8.0
```
---
## 6. Encerramento da sessão
Quando todas as linhas tiverem `✅`:
1. Rodar a suíte rápida de sanidade:
```bash
npm run lint
npm run typecheck:core
npm run test:unit
```
2. Anexar este arquivo (preenchido) ao PR de release ou ao tag `v3.8.0` como evidência.
3. Atualizar `CHANGELOG.md` com a linha:
> E2E dashboard shakedown completed — see `docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md`.
4. Subir para `main` e disparar o release.
---
## 7. Tabela "página → ajuste aplicado" (preencher na sessão)
| Página | Sintoma | Causa-raiz | Correção | Commit |
| ------------------------------- | ----------------------------------- | --------------------- | --------------------------- | -------- |
| _exemplo: /dashboard/cli-tools_ | _500 no POST /api/cli-tools/config_ | _Zod schema faltando_ | _Adicionado `.safeParse()`_ | _abc123_ |
| | | | | |
| | | | | |
Mantenha a tabela crescendo conforme corrige. Esse é o trail de auditoria do shakedown.

View File

@@ -114,9 +114,9 @@ On success it returns:
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |
| Symptom | Cause | Fix |
| ------------------------------------ | ---------------------------- | -------------------------------- |
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |

View File

@@ -59,14 +59,14 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
## Web Cookie Providers (7)
| ID | Alias | Name | Tags | Website | Notes |
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
| ID | Alias | Name | Tags | Website | Notes |
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Pro: $8/mo, 50+ models. Free tier: limited models. Requires Cookie header + convex-session-id from DevTools. **Skeleton — endpoint URL not yet confirmed (TODO post-devtools-capture).** |
## API Key Providers (paid / paid-with-free-credits) (122)

View File

@@ -87,17 +87,17 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque
> Source: [diagrams/auto-combo-9factor.mmd](../diagrams/auto-combo-9factor.mmd)
| Factor | Default Weight | Description |
| :----------------- | :------------- | :---------------------------------------------------------------------- |
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
| Factor | Default Weight | Description |
| :----------------- | :------------- | :------------------------------------------------------------------------------------------------- |
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.17 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
**Sum:** `0.22 + 0.17 + 0.17 + 0.13 + 0.08 + 0.08 + 0.05 + 0.05 + 0.05 = 1.0` (validated by `validateWeights()`).
@@ -210,16 +210,16 @@ Including the bare `auto` (default) plus the 6 `AutoVariant` values declared in
The 9-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
membership as one signal via the `tierPriority` weight. Default weights (from `DEFAULT_WEIGHTS`):
| Factor | Default weight | Notes |
| ------------------------ | -------------- | --------------------------------- |
| Tier priority | 0.05 | Tier 1 premium → higher score |
| Latency (p50 inverse) | 0.35 | Fastest wins |
| Factor | Default weight | Notes |
| ------------------------ | -------------- | -------------------------------------------------------------- |
| Tier priority | 0.05 | Tier 1 premium → higher score |
| Latency (p50 inverse) | 0.35 | Fastest wins |
| Cost ($/1M inverse) | 0.20 | Cheapest **blended** price wins (60% input + 40% output ratio) |
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
| Quota remaining | 0.10 | Near-exhausted deprioritized |
| Context window match | 0.08 | Penalizes short windows |
| Task fitness | 0.10 | Coding → coding-specialist models |
| Stability | 0.00 | Disabled by default |
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
| Quota remaining | 0.10 | Near-exhausted deprioritized |
| Context window match | 0.08 | Penalizes short windows |
| Task fitness | 0.10 | Coding → coding-specialist models |
| Stability | 0.00 | Disabled by default |
Tier alone does **not** force Tier 1 first — if Tier 1 latency is bad or
cost-vs-quality is suboptimal, Tier 2 wins. To force tier ordering, use combo

View File

@@ -139,6 +139,7 @@ parsed body from the upstream provider). When provided, it is sanitized by
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
Sanitization rules applied to `upstreamDetails`:
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
are removed.

View File

@@ -50,6 +50,8 @@ const eslintConfig = [
"bin/**",
// Dependencies
"node_modules/**",
".worktrees/**",
".omnivscodeagent/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
"_references/**",

View File

@@ -1,12 +1,11 @@
export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 Thinking" },
{ id: "gemini-3-pro-preview", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
{ id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
{ id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image" },
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" },
{
id: "gemini-2.5-computer-use-preview-10-2025",
name: "Gemini 2.5 Computer Use Preview (10/2025)",

View File

@@ -37,6 +37,12 @@ interface ImageModelAliasEntry {
}
const IMAGE_MODEL_ALIASES: Record<string, ImageModelAliasEntry> = {
"gemini-3.1-flash-image-preview": {
provider: "antigravity",
model: "gemini-3.1-flash-image",
name: "Gemini 3.1 Flash Image",
listInCatalog: false,
},
"flux-kontext": {
provider: "black-forest-labs",
model: "flux-kontext-pro",
@@ -223,11 +229,11 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
antigravity: {
id: "antigravity",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent",
authType: "oauth",
authHeader: "bearer",
format: "gemini-image", // Special format: uses Gemini generateContent API
models: [],
models: [{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }],
supportedSizes: ["1024x1024"],
},

View File

@@ -1,7 +1,11 @@
import { BaseExecutor } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { getRotatingApiKey, getValidApiKey } from "../services/apiKeyRotator.ts";
import {
getRotatingApiKey,
getValidApiKey,
resolveKeyForRequest,
} from "../services/apiKeyRotator.ts";
import type { KeyHealth } from "../services/apiKeyRotator.ts";
import {
buildClaudeCodeCompatibleHeaders,
@@ -254,14 +258,22 @@ export class DefaultExecutor extends BaseExecutor {
// T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders
const extraKeys =
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
const health = credentials.providerSpecificData?.apiKeyHealth as
| Record<string, KeyHealth>
| undefined;
const effectiveKey =
extraKeys.length > 0 && credentials.connectionId && credentials.apiKey
? getValidApiKey(credentials.connectionId, credentials.apiKey, extraKeys, health) ||
credentials.apiKey
: credentials.apiKey;
const selectedKeyId = (credentials.providerSpecificData as Record<string, unknown> | undefined)
?.selectedKeyId as string | undefined;
let effectiveKey = credentials.apiKey;
if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) {
const resolved = resolveKeyForRequest(
credentials.connectionId,
credentials.apiKey,
extraKeys,
selectedKeyId ?? null
);
effectiveKey = resolved?.key ?? credentials.apiKey;
if (resolved && credentials.providerSpecificData) {
(credentials.providerSpecificData as Record<string, unknown>).selectedKeyId =
resolved.keyId;
}
}
switch (this.provider) {
case "gemini":

View File

@@ -58,8 +58,7 @@ export interface T3ChatCredentials {
// ── Helpers ──────────────────────────────────────────────────────────────
function validateCredentials(creds: unknown): creds is T3ChatCredentials {
const raw =
typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
const raw = typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
return (
typeof raw.cookies === "string" &&
raw.cookies.length > 0 &&
@@ -373,10 +372,7 @@ export class T3ChatWebExecutor extends BaseExecutor {
// TODO(post-devtools-capture): Map the actual t3.chat non-streaming response
// shape to OpenAI format once the real field names are confirmed.
const content =
(json as any)?.content ??
(json as any)?.text ??
(json as any)?.message?.content ??
"";
(json as any)?.content ?? (json as any)?.text ?? (json as any)?.message?.content ?? "";
const openaiResponse = {
id: `chatcmpl-t3-${Date.now()}`,
object: "chat.completion",

View File

@@ -17,6 +17,14 @@ import { randomUUID } from "crypto";
*/
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { antigravityUserAgent } from "../services/antigravityHeaders.ts";
import {
deriveAntigravityMachineId,
getAntigravityEnvelopeUserAgent,
getAntigravityVscodeSessionId,
} from "../services/antigravityIdentity.ts";
import { getCachedAntigravityVersion } from "../services/antigravityVersion.ts";
import { kieExecutor } from "../executors/kie.ts";
import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
@@ -38,7 +46,7 @@ import {
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts";
interface KieImageOptions {
model: string;
@@ -81,6 +89,32 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([
"qwen-image",
]);
const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/;
function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string {
if (typeof value === "string") {
const trimmedValue = value.trim();
if (IMAGE_ASPECT_RATIO_PATTERN.test(trimmedValue)) return trimmedValue;
}
return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null);
}
function parseJsonOrNull(value: string): unknown | null {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function sanitizeImageProviderError(errorText: string): unknown {
const parsed = parseJsonOrNull(errorText);
if (parsed !== null) {
return sanitizeUpstreamDetails(parsed) || sanitizeErrorMessage(errorText);
}
return sanitizeErrorMessage(errorText);
}
const BFL_MODEL_ENDPOINTS = {
"flux-2-max": "/v1/flux-2-max",
"flux-2-pro": "/v1/flux-2-pro",
@@ -602,42 +636,79 @@ async function handleKieImageGeneration({
*/
async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }) {
const startTime = Date.now();
const url = `${providerConfig.baseUrl}/${model}:generateContent`;
const url = providerConfig.baseUrl;
const provider = "antigravity";
const credentialRecord = credentials || {};
const token = credentialRecord.accessToken || credentialRecord.apiKey;
const providerSpecificData = credentialRecord.providerSpecificData;
const providerSpecificProjectId =
providerSpecificData && typeof providerSpecificData === "object"
? (providerSpecificData as Record<string, unknown>).projectId
: null;
const credentialProjectId =
typeof credentialRecord.projectId === "string" ? credentialRecord.projectId.trim() : "";
const providerProjectId =
typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : "";
const projectId = credentialProjectId || providerProjectId || null;
const candidateCount =
typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1;
const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
// Summarized request for call log
const logRequestBody = {
model: body.model,
prompt:
typeof body.prompt === "string"
? body.prompt.slice(0, 200)
: String(body.prompt ?? "").slice(0, 200),
prompt: promptText.slice(0, 200),
size: body.size || "default",
n: body.n || 1,
n: candidateCount,
};
const geminiBody = {
contents: [
{
parts: [{ text: body.prompt }],
if (!projectId || typeof projectId !== "string") {
return saveImageErrorResult({
provider,
model,
status: 400,
startTime,
error:
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.",
requestBody: logRequestBody,
});
}
const antigravityBody = {
project: projectId,
requestId: `image_gen/${Date.now()}/${randomUUID()}/0`,
request: {
contents: [
{
role: "user",
parts: [{ text: promptText }],
},
],
generationConfig: {
candidateCount,
imageConfig: {
aspectRatio: normalizeImageAspectRatio(body.aspect_ratio, body.size),
},
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
model,
userAgent: getAntigravityEnvelopeUserAgent(credentialRecord),
requestType: "image_gen",
};
const token = credentials.accessToken || credentials.apiKey;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": antigravityUserAgent(),
"x-client-name": "antigravity",
"x-client-version": getCachedAntigravityVersion(),
"x-machine-id": deriveAntigravityMachineId(credentialRecord),
"x-vscode-sessionid": getAntigravityVscodeSessionId(),
"x-goog-user-project": projectId,
};
if (log) {
const promptPreview =
typeof body.prompt === "string"
? body.prompt.slice(0, 60)
: String(body.prompt ?? "").slice(0, 60);
const promptPreview = promptText.slice(0, 60);
log.info(
"IMAGE",
`antigravity/${model} (gemini) | prompt: "${promptPreview}..." | format: gemini-image`
@@ -645,16 +716,32 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
}
try {
const response = await fetch(url, {
let response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(geminiBody),
body: JSON.stringify(antigravityBody),
});
if (response.status === HTTP_STATUS.FORBIDDEN && headers["x-goog-user-project"]) {
const retryHeaders = { ...headers };
delete retryHeaders["x-goog-user-project"];
if (log) {
log.info("IMAGE", "antigravity image 403 with x-goog-user-project; retrying without it");
}
response = await fetch(url, {
method: "POST",
headers: retryHeaders,
body: JSON.stringify(antigravityBody),
});
}
if (!response.ok) {
const errorText = await response.text();
const safeError = sanitizeImageProviderError(errorText);
const safeErrorLog =
typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {});
if (log) {
log.error("IMAGE", `antigravity error ${response.status}: ${errorText.slice(0, 200)}`);
log.error("IMAGE", `antigravity error ${response.status}: ${safeErrorLog.slice(0, 200)}`);
}
saveCallLog({
@@ -664,25 +751,26 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
model: `antigravity/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
error: safeErrorLog.slice(0, 500),
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: response.status, error: errorText };
return { success: false, status: response.status, error: safeError };
}
const data = await response.json();
const responseBody = data.response || data;
// Extract image data from Gemini response
// Extract image data from Antigravity's wrapped Gemini response.
const images = [];
const candidates = data.candidates || [];
const candidates = responseBody.candidates || [];
for (const candidate of candidates) {
const parts = candidate.content?.parts || [];
for (const part of parts) {
if (part.inlineData) {
images.push({
b64_json: part.inlineData.data,
revised_prompt: parts.find((p) => p.text)?.text || body.prompt,
revised_prompt: parts.find((p) => p.text)?.text || promptText,
});
}
}
@@ -726,7 +814,7 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -1306,7 +1394,7 @@ async function handleFalAIImageGeneration({
model,
status: 502,
startTime,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
});
}
}
@@ -1496,7 +1584,7 @@ async function handleStabilityAIImageGeneration({
model,
status: 502,
startTime,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
});
}
}
@@ -1615,7 +1703,7 @@ async function handleBlackForestLabsImageGeneration({
model,
status: 502,
startTime,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
});
}
}
@@ -1690,7 +1778,7 @@ async function handleRecraftImageGeneration({
model,
status: 502,
startTime,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
});
}
}
@@ -1778,7 +1866,7 @@ async function handleTopazImageGeneration({
model,
status: 502,
startTime,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
});
}
}
@@ -2358,7 +2446,7 @@ async function fetchImageEndpoint(url, headers, body, provider, log) {
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -2456,7 +2544,7 @@ async function handleHyperbolicImageGeneration({
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -2714,7 +2802,7 @@ async function handleNanoBananaImageGeneration({
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -2910,7 +2998,7 @@ async function handleSDWebUIImageGeneration({ model, provider, providerConfig, b
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -3016,7 +3104,7 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -3128,7 +3216,7 @@ async function handleHaiperImageGeneration({
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -3260,7 +3348,7 @@ async function handleLeonardoImageGeneration({
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}
@@ -3350,7 +3438,7 @@ async function handleIdeogramImageGeneration({
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
};
}
}

View File

@@ -378,7 +378,11 @@ export async function buildAndSignClaudeCodeRequest(
if (Array.isArray(b.messages)) {
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
const adjacent = fixToolAdjacency(fixed);
b.messages = stripTrailingAssistantOrphanToolUse(adjacent);
// fixToolAdjacency can leave orphan tool_result blocks behind when it
// strips a tool_use whose tool_result wasn't in the next message.
// Re-pair to drop those orphans (discussion #2410).
const cleaned = fixToolPairs(adjacent);
b.messages = stripTrailingAssistantOrphanToolUse(cleaned);
}
}
@@ -1158,7 +1162,9 @@ function readNestedString(
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
current = (current as Record<string, unknown>)[key];
if (key === "__proto__" || key === "constructor" || key === "prototype") return null;
if (!Object.prototype.hasOwnProperty.call(current, key)) return null;
current = Reflect.get(current as object, key);
}
return toNonEmptyString(current);
}

View File

@@ -2658,10 +2658,7 @@ async function handleRoundRobinCombo({
// same-provider targets are skipped immediately.
if (provider && isProviderExhaustedReason(fallbackResult)) {
exhaustedProviders.add(provider);
log.info(
"COMBO-RR",
`Provider ${provider} quota exhausted — marking for skip (#1731)`
);
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
}
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(

View File

@@ -267,6 +267,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
let candidate = [...system, ...nonSystem.slice(-keep)];
candidate = fixToolPairs(candidate);
candidate = fixToolAdjacency(candidate);
// Re-run pair fix: fixToolAdjacency may have stripped tool_use blocks, leaving
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
candidate = fixToolPairs(candidate);
candidate = stripTrailingAssistantOrphanToolUse(candidate);
const tokens = estimateTokens(JSON.stringify(candidate));
if (tokens <= targetTokens) break;
@@ -276,6 +279,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
let result = [...system, ...nonSystem.slice(-keep)];
result = fixToolPairs(result);
result = fixToolAdjacency(result);
// Re-run pair fix to drop any tool_result whose matching tool_use was removed by
// fixToolAdjacency (discussion #2410 — orphan tool_result -> upstream 400).
result = fixToolPairs(result);
result = stripTrailingAssistantOrphanToolUse(result);
// Add summary of dropped messages

View File

@@ -420,7 +420,12 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
}
// Preserve historical behavior: OpenAI stays default when model exists there
if (providers.includes("openai")) {
if (
providers.includes("openai") ||
/^gpt-/i.test(modelId) ||
/^o1/i.test(modelId) ||
/^o3/i.test(modelId)
) {
return {
provider: "openai",
model: modelId,

View File

@@ -0,0 +1,26 @@
import { execSync } from "child_process";
try {
console.log("Fetching workflow runs...");
const output = execSync("gh run list --limit 100 --json status,conclusion,databaseId", {
encoding: "utf8",
});
const runs = JSON.parse(output);
console.log(`Found ${runs.length} runs.`);
let count = 0;
for (const run of runs) {
if (run.conclusion !== "success") {
console.log(`Deleting run ID ${run.databaseId} with conclusion '${run.conclusion}'...`);
try {
execSync(`gh run delete ${run.databaseId}`);
count++;
} catch (err) {
console.error(`Failed to delete run ID ${run.databaseId}:`, err.message);
}
}
}
console.log(`Deleted ${count} runs successfully.`);
} catch (error) {
console.error("Error executing script:", error);
}

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename);
const ROOT: string = join(__dirname, "..", "..");
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
function runPackDryRun(): any {
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const npmExecPath = process.env.npm_execpath;
const command = npmExecPath ? process.execPath : npmCommand;
const args = [
...(npmExecPath ? [npmExecPath] : []),
"pack",
"--dry-run",
"--json",
"--ignore-scripts",
];
const output = execFileSync(command, args, {
return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
});
}
function ensureAppStagingReady(): void {
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
requiredPath.startsWith("app/")
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
if (missingAppRequiredPaths.length === 0) return;
console.log("📦 app/ staging is missing required runtime files; running npm run build:cli...");
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
@@ -66,6 +74,7 @@ function formatBytes(bytes: number): string {
}
try {
ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {

View File

@@ -24,16 +24,23 @@ const require = createRequire(import.meta.url);
const CRYPTO_SECRETS = {
JWT_SECRET: () => randomBytes(64).toString("hex"),
API_KEY_SECRET: () => randomBytes(32).toString("hex"),
STORAGE_ENCRYPTION_KEY: () => randomBytes(32).toString("hex"),
// STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall.
// Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to
// ~/.omniroute/.env to survive across upgrades. This prevents credential loss
// when upgrading OmniRoute (issue #1622).
MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
};
/**
* Keys that MUST NOT be regenerated when existing encrypted data exists in the DB.
* Generating a new key would make all previously-encrypted credentials unrecoverable.
*
* Note: STORAGE_ENCRYPTION_KEY is no longer auto-generated in postinstall.
* It's generated at server startup in bin/omniroute.mjs and persisted to
* ~/.omniroute/.env to survive across upgrades.
* @see https://github.com/diegosouzapw/OmniRoute/issues/1622
*/
const ENCRYPTION_BOUND_KEYS = new Set(["STORAGE_ENCRYPTION_KEY"]);
const ENCRYPTION_BOUND_KEYS = new Set([]);
// ── Resolve DATA_DIR (mirrors bootstrap-env.mjs / dataPaths.ts) ─────────────
function resolveDataDir(env = process.env) {

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
/**
* Audit every dashboard page for:
* 1. Hardcoded user-visible strings in JSX (any language)
* 2. t("...") calls that reference keys NOT present in en.json
*
* Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout.
*
* Scope: src/app/(dashboard)/**\/*.tsx
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname, relative, basename } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..", "..");
const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json");
const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)");
const OUT_PATH = join(__dirname, "_audit.json");
const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8"));
/**
* Deep-convert a parsed JSON object into a Map tree.
* Using Map sidesteps prototype-pollution concerns when keys are
* derived from source-code scraping (CWE-915).
*/
function toMapTree(value) {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(toMapTree);
const m = new Map();
for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v));
return m;
}
const enJson = toMapTree(enJsonRaw);
/** Walk a directory recursively and return all .tsx files */
function walkTsx(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walkTsx(full));
else if (entry.endsWith(".tsx")) out.push(full);
}
return out;
}
/** Walk a dotted path through the Map tree, returning the leaf or undefined */
function walkPath(parts) {
let cursor = enJson;
for (const p of parts) {
if (!(cursor instanceof Map) || !cursor.has(p)) return undefined;
cursor = cursor.get(p);
}
return cursor;
}
/** Check if a dotted key exists, supporting dotted namespaces too */
function keyExists(namespace, key) {
const nsParts = namespace ? namespace.split(".") : [];
const keyParts = key.split(".");
return walkPath([...nsParts, ...keyParts]) !== undefined;
}
/** Extract useTranslations("ns") and getTranslations("ns") calls */
function extractNamespaces(source) {
const namespaces = [];
const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
for (const m of source.matchAll(reNs)) {
namespaces.push(m[1]);
}
// Without arg: bare, key must be fully qualified
if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null);
return namespaces;
}
/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */
function extractTCalls(source) {
const out = new Set();
// t("..."), t('...') — match only quoted string literals (skip template literals)
const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g;
for (const m of source.matchAll(re)) out.add(m[1]);
// translateOrFallback("key", fallback) — also t("key") style
for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) {
out.add(m[1]);
}
return [...out];
}
/**
* Find candidate hardcoded user-visible strings in JSX.
*
* Patterns we detect:
* A. JSX text: >Some text< (must contain a letter, length >= 2)
* B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..."
* C. Toast/error literals: toast.error("...") toast.success("...")
*
* We DELIBERATELY skip:
* - strings inside {t(...)} or {tOrFallback(...)}
* - strings inside import statements
* - strings inside CSS class names (className/style)
* - URLs (start with /, http, mailto:, #)
* - All-uppercase/snake constants (CONSTANT_VAR)
* - pure-symbol strings (no letters)
* - lonely-word JSX text that is a console.log / pino call argument
*/
// TS / JS noise that the naive regex picks up between two `>` chars
const TS_TYPE_NOISE = new Set([
"Promise",
"Record",
"Map",
"Set",
"Array",
"ReadonlyArray",
"Partial",
"Pick",
"Omit",
"Required",
"Readonly",
"Awaited",
"ReturnType",
"Parameters",
"void | Promise",
"Promise | undefined",
]);
function findHardcodedStrings(source) {
const findings = [];
const lines = source.split("\n");
// (A) JSX text between > and < — fragile but good enough for an audit pass
let lineIdx = 0;
for (const line of lines) {
lineIdx++;
// Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines)
if (
/^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line)
)
continue;
// Skip lines that are *clearly* TypeScript signatures
if (
/:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*</.test(
line
)
)
continue;
// Neutralise arrow functions and TS generics that confuse the regex
const safe = line
.replace(/=>/g, "__ARROW__")
.replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags
.replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like <T> or <T, U>
let m;
const re = />([^<>{}\n]+)</g;
while ((m = re.exec(safe)) !== null) {
const raw = m[1].trim();
if (!raw) continue;
if (!/[A-Za-zÀ-ÿ一-鿿Ѐ-ӿ֐-׿؀-ۿ]/.test(raw)) continue;
if (raw.length < 2) continue;
if (raw.startsWith("{") || raw.endsWith("}")) continue;
if (/^[A-Z0-9_]+$/.test(raw)) continue; // CONSTANT
if (/^(https?:\/\/|\/|mailto:|#)/.test(raw)) continue; // URL/path
if (/^[a-z][a-z_0-9]*$/.test(raw) && raw.length < 22) continue; // icon/id slug
if (TS_TYPE_NOISE.has(raw)) continue;
// operator/expression debris like "0 && foo.size", "x !== y"
if (/(&&|\|\||==|!=|<=|>=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue;
// pure number or simple variable.member with no spaces
if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue;
findings.push({ kind: "jsx-text", line: lineIdx, value: raw });
}
}
// (B) JSX attributes that are user-visible
const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g;
lineIdx = 0;
for (const line of lines) {
lineIdx++;
if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue;
let m;
const re = new RegExp(attrRe.source, "g");
while ((m = re.exec(line)) !== null) {
const value = m[2].trim();
if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue;
if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key
findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value });
}
}
// (C) toast.* and Error() arguments that look like user copy (length > 5, has a space)
lineIdx = 0;
const callRe =
/\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g;
for (const line of lines) {
lineIdx++;
let m;
const re = new RegExp(callRe.source, "g");
while ((m = re.exec(line)) !== null) {
findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() });
}
}
return findings;
}
/** Find t() calls whose key does NOT exist in en.json */
function findMissingKeys(namespaces, tKeys) {
const missing = [];
for (const key of tKeys) {
// A key may itself contain dots — handle namespace dotted into key
let found = false;
for (const ns of namespaces) {
if (keyExists(ns, key)) {
found = true;
break;
}
}
// Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations)
if (!found && key.includes(".")) {
const [maybeNs, ...rest] = key.split(".");
if (keyExists(maybeNs, rest.join("."))) found = true;
}
if (!found) missing.push(key);
}
return missing;
}
const files = walkTsx(DASHBOARD_ROOT);
const report = [];
for (const file of files) {
const rel = relative(REPO_ROOT, file);
const src = readFileSync(file, "utf8");
const namespaces = extractNamespaces(src);
const tCalls = extractTCalls(src);
const hardcoded = findHardcodedStrings(src);
const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls);
// Only include files that have ANY user-visible content
if (!namespaces.length && !tCalls.length && !hardcoded.length) continue;
report.push({
file: rel,
namespaces,
tCallCount: tCalls.length,
missingKeys: missing,
hardcodedCount: hardcoded.length,
hardcoded,
});
}
writeFileSync(OUT_PATH, JSON.stringify(report, null, 2));
// Human summary
let totalMissing = 0;
let totalHardcoded = 0;
console.log("# i18n audit — dashboard pages\n");
console.log(`Scanned ${report.length} files with user-visible content.\n`);
const onlyIssues = report
.map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length }))
.filter((r) => r.total > 0)
.sort((a, b) => b.total - a.total);
for (const r of onlyIssues) {
totalMissing += r.missingKeys.length;
totalHardcoded += r.hardcoded.length;
console.log(`\n## ${r.file}`);
console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`);
if (r.missingKeys.length) {
console.log(` ✗ missing keys (${r.missingKeys.length}):`);
for (const k of r.missingKeys) console.log(` - ${k}`);
}
if (r.hardcoded.length) {
console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`);
for (const h of r.hardcoded.slice(0, 30)) {
console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`);
}
if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`);
}
}
console.log(`\n# Summary`);
console.log(`Files with issues: ${onlyIssues.length}`);
console.log(`Missing keys total: ${totalMissing}`);
console.log(`Hardcoded strings: ${totalHardcoded}`);
console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`);

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* For every missing key in _audit.json, locate its English value by
* 1) finding the t("key") call site in the file
* 2) grabbing the line(s) immediately around it in the HEAD version of the file
*
* This rebuilds a complete _pending-keys.json after subagents have already
* rewritten .tsx files but en.json edits were lost.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
const audit = JSON.parse(readFileSync("scripts/i18n/_audit.json", "utf8"));
function loadHead(file) {
try {
return execSync(`git show "HEAD:${file}"`, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
} catch {
return null;
}
}
const SKIP = new Set([
"templatePayloads.vision.system",
"templatePayloads.vision.userPrompt",
"templatePayloads.vision.imageUrl",
"templatePayloads.schemaCoercion.userPrompt",
"templatePayloads.schemaCoercion.toolDescription",
"templatePayloads.schemaCoercion.cityDescription",
]);
/** Find the line in NEW source where t("key") is used, then derive English value from HEAD */
function valueForKey(file, key) {
const cur = readFileSync(file, "utf8").split("\n");
const head = loadHead(file)?.split("\n") ?? [];
// String search avoids ReDoS — key is our own audit data but better safe
const needle1 = `t("${key}")`;
const needle2 = `t('${key}')`;
for (let i = 0; i < cur.length; i++) {
const line = cur[i];
if (!line.includes(needle1) && !line.includes(needle2)) continue;
// The original line in HEAD should be similar around the same area
// Find the most-recently corresponding HEAD line: scan backwards and forwards from i
const probe = [i, i - 1, i + 1, i - 2, i + 2, i - 3, i + 3];
for (const j of probe) {
if (j < 0 || j >= head.length) continue;
const line = head[j];
// Try to extract the English literal: between > <, or attribute value
const jsxMatch = line.match(/>([^<>{}\n]{2,})</);
if (jsxMatch && !jsxMatch[1].includes("{") && !jsxMatch[1].includes("=>")) {
const v = jsxMatch[1].trim();
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
}
const attrMatch = line.match(
/\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/
);
if (attrMatch) {
const v = attrMatch[1].trim();
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
}
}
}
return null;
}
const inferredNamespaces = new Map();
function ensureNs(ns) {
if (!inferredNamespaces.has(ns)) inferredNamespaces.set(ns, {});
return inferredNamespaces.get(ns);
}
for (const entry of audit) {
if (!entry.missingKeys.length) continue;
const ns = entry.namespaces[0];
if (!ns) continue; // exampleTemplates etc. — skip
const target = ensureNs(ns);
for (const key of entry.missingKeys) {
if (SKIP.has(key)) continue;
if (target[key]) continue;
const value = valueForKey(entry.file, key);
if (value) {
target[key] = value;
} else {
// Could not infer — leave a TODO marker so we notice
target[key] = `__TODO__${key}`;
}
}
}
const out = Object.fromEntries(inferredNamespaces);
writeFileSync("scripts/i18n/_pending-keys.json", JSON.stringify(out, null, 2) + "\n");
let total = 0;
let todo = 0;
for (const [ns, keys] of Object.entries(out)) {
const cnt = Object.keys(keys).length;
total += cnt;
for (const v of Object.values(keys)) if (String(v).startsWith("__TODO__")) todo++;
console.log(`${ns}: ${cnt} keys`);
}
console.log(`\nTotal: ${total} keys (${todo} need manual resolution)`);

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Extract NEW i18n keys created by subagents from git diff.
*
* For each modified .tsx file, walk the unified diff and pair "-" lines
* (containing literal English text) with their following "+" lines
* (now using `t("key")`). When we find a stable pairing, record the key
* and its original English value.
*
* The output is a per-namespace map ready to merge into en.json.
*/
import { execSync } from "node:child_process";
const diff = execSync('git diff --unified=0 "src/app/(dashboard)"', {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
const blocks = diff.split(/^diff --git /m).slice(1);
const allPairs = []; // { file, removed, added }
for (const block of blocks) {
const headLine = block.split("\n")[0];
const fileMatch = headLine.match(/b\/(.+?)$/);
const file = fileMatch ? fileMatch[1] : "?";
const lines = block.split("\n");
// Walk in groups: consecutive "-" lines, then consecutive "+" lines.
// Pair them positionally (removed[i] ↔ added[i]).
let removed = [];
let added = [];
function flush() {
const n = Math.min(removed.length, added.length);
for (let i = 0; i < n; i++) allPairs.push({ file, removed: removed[i], added: added[i] });
// If removed.length > added.length, pair remaining removed with last added (multi-string in one new line)
if (removed.length > added.length && added.length > 0) {
for (let i = added.length; i < removed.length; i++) {
allPairs.push({ file, removed: removed[i], added: added[added.length - 1] });
}
}
removed = [];
added = [];
}
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++")) continue;
if (line.startsWith("-")) {
if (added.length) flush();
removed.push(line.slice(1));
} else if (line.startsWith("+")) {
added.push(line.slice(1));
} else {
flush();
}
}
flush();
}
/** Patterns inside JSX or attribute strings */
const T_CALL_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g;
const JSX_TEXT_RE = />([^<>{}\n]+)</g;
const ATTR_RE = /\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/g;
const newKeys = new Map(); // key -> { value, file }
function recordKey(key, value, file) {
const trimmed = value.trim();
if (!trimmed) return;
// Ignore if value contains JSX expression syntax — those were dynamic
if (/^\{|\}$/.test(trimmed)) return;
if (!newKeys.has(key)) {
newKeys.set(key, { value: trimmed, file });
}
}
for (const { file, removed, added } of allPairs) {
// Extract every t("xxx") key from the "added" line
const tKeys = [...added.matchAll(T_CALL_RE)].map((m) => m[1]);
if (!tKeys.length) continue;
// Extract candidate strings from the "removed" line: JSX text + attr values
const candidates = [];
for (const m of removed.matchAll(JSX_TEXT_RE)) candidates.push(m[1]);
for (const m of removed.matchAll(ATTR_RE)) candidates.push(m[1]);
// Direct strings without surrounding markup (rare)
const stripped = removed
.replace(/<[^<>]+>/g, "")
.replace(/\bt\([^)]*\)/g, "")
.trim();
if (stripped && /[A-Za-z]/.test(stripped)) candidates.push(stripped);
// For each tKey in added, try to align with the candidate at the same index.
// The agents typically replaced strings in the same left-to-right order.
for (let i = 0; i < tKeys.length; i++) {
const candidate = candidates[i] ?? candidates[candidates.length - 1];
if (!candidate) continue;
recordKey(tKeys[i], candidate, file);
}
}
// Group by file's primary namespace via useTranslations() call in file
import { readFileSync } from "node:fs";
function inferNamespaceForFile(file) {
try {
const src = readFileSync(file, "utf8");
const m = src.match(/useTranslations\s*\(\s*["']([^"']+)["']\s*\)/);
return m?.[1] ?? "common";
} catch {
return "common";
}
}
const byNamespace = new Map();
for (const [key, { value, file }] of newKeys) {
const ns = inferNamespaceForFile(file);
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
byNamespace.get(ns).push({ key, value });
}
for (const [ns, items] of byNamespace) {
console.log(`\nNEW_KEYS_FOR_NAMESPACE: ${ns}`);
console.log("{");
for (const { key, value } of items) {
// Escape value for JSON
console.log(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`);
}
console.log("}");
}
console.log(`\nTotal extracted: ${newKeys.size} keys across ${byNamespace.size} namespaces`);

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* Merge keys from scripts/i18n/_pending-keys.json into src/i18n/messages/en.json
*
* Format of _pending-keys.json:
* { "namespace": { "key": "value", ... }, ... }
*
* Keys are appended to the END of each namespace block. Existing keys with
* the same name are preserved (we never overwrite).
*/
import { readFileSync, writeFileSync } from "node:fs";
const SRC = "src/i18n/messages/en.json";
const PENDING = "scripts/i18n/_pending-keys.json";
const enJson = JSON.parse(readFileSync(SRC, "utf8"));
const pending = JSON.parse(readFileSync(PENDING, "utf8"));
let added = 0;
let skipped = 0;
for (const [ns, keys] of Object.entries(pending)) {
if (!Object.prototype.hasOwnProperty.call(enJson, ns)) {
console.warn(`! namespace missing in en.json: ${ns} — skipping`);
continue;
}
const target = enJson[ns];
for (const [k, v] of Object.entries(keys)) {
if (Object.prototype.hasOwnProperty.call(target, k)) {
skipped++;
continue;
}
target[k] = v;
added++;
}
}
writeFileSync(SRC, JSON.stringify(enJson, null, 2) + "\n");
console.log(`✓ merged ${added} new keys (${skipped} skipped — already present)`);

View File

@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
/**
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
* The banner is dismissable and persists only for the current session.
*/
export default function BootstrapBanner() {
const t = useTranslations("common");
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
@@ -46,7 +48,7 @@ export default function BootstrapBanner() {
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-600/60 hover:text-amber-700 dark:text-amber-400/60 dark:hover:text-amber-300 transition-colors ml-1"
aria-label="Dismiss"
aria-label={t("bootstrapBannerDismiss")}
>
</button>

View File

@@ -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<Set<string>>(new Set());
// T07: Check for unhealthy API keys and show notification (once per session)
const notifiedUnhealthyKeys = useRef<Set<string>>(new Set());
useEffect(() => {
const checkApiKeyHealth = () => {
const newInvalidKeys = new Set<string>();
const invalidConnections: string[] = [];
let firstInvalidProviderId: string | null = null;
const newUnhealthyKeys = new Set<string>();
const unhealthyProviderIds = new Set<string>();
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) {
<span className="material-symbols-outlined text-[18px]">check_circle</span>
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
</p>
<p className="text-xs text-text-muted mt-1">Reloading page automatically...</p>
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
</div>
)}
</div>
@@ -788,7 +812,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-base font-semibold">Provider Topology</h2>
<h2 className="text-base font-semibold">{t("providerTopology")}</h2>
<p className="text-xs text-text-muted">
Connected providers routing through OmniRoute in real time
</p>

View File

@@ -65,8 +65,8 @@ export function TierCoverageWidget() {
<div className="rounded-xl border border-white/[0.06] bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-sm">Tier coverage</h3>
<p className="text-xs text-text-muted mt-0.5">Providers configured per fallback tier</p>
<h3 className="font-semibold text-sm">{t("tierCoverageTitle")}</h3>
<p className="text-xs text-text-muted mt-0.5">{t("tierCoverageSubtitle")}</p>
</div>
<Link
href="/dashboard/providers"

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import A2ADashboardPage from "../endpoint/components/A2ADashboard";
@@ -118,6 +119,7 @@ function DisabledPanel() {
}
export default function A2APage() {
const t = useTranslations("a2aDashboard");
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ 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 <code className="text-xs">/.well-known/agent.json</code>.
</li>
<li>
Send JSON-RPC to <code className="text-xs">POST /a2a</code> using{" "}
<code className="text-xs">message/send</code> or{" "}
<code className="text-xs">message/stream</code>.
Send JSON-RPC to <code className="text-xs">{t("rpcEndpoint")}</code> using{" "}
<code className="text-xs">{t("rpcMethodSend")}</code> or{" "}
<code className="text-xs">{t("rpcMethodStream")}</code>.
</li>
<li>
Track and cancel tasks with <code className="text-xs">tasks/get</code> and{" "}
<code className="text-xs">tasks/cancel</code>.
Track and cancel tasks with <code className="text-xs">{t("rpcMethodGet")}</code> and{" "}
<code className="text-xs">{t("rpcMethodCancel")}</code>.
</li>
</ol>
</div>
<div className="shrink-0">
<ServiceToggle
label="A2A"
label={t("serviceLabel")}
status={a2aStatus}
enabled={a2aEnabled}
onToggle={() => void toggleA2a()}

View File

@@ -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")}
>
<span className="material-symbols-outlined text-[14px]">
{isCopied ? "check" : "content_copy"}
</span>
{isCopied ? "Copied!" : "Copy URL"}
{isCopied ? t("copied") : t("copyUrl")}
</button>
);
}
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 }) {
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
{skill.isEntry && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
Start Here
{t("startHere")}
</span>
)}
{skill.isNew && (
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
New
{t("badgeNew")}
</span>
)}
{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")}
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
@@ -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() {
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">info</span>
<span className="text-sm font-semibold text-text-main">How to use</span>
<span className="text-sm font-semibold text-text-main">{t("howToUse")}</span>
</div>
<ol className="space-y-1 text-xs text-text-muted">
<li>
1. Click <strong className="text-text-main">Copy URL</strong> on the skill you want your
agent to know about.
1. Click <strong className="text-text-main">{t("copyUrl")}</strong> on the skill you
want your agent to know about.
</li>
<li>
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"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Browse all skills on GitHub
{t("browseAllSkillsOnGithub")}
</a>
</div>
{/* Two-column grid: API Skills | CLI Skills */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkillSection
title="API Skills"
title={t("apiSkills")}
subtitle={`${apiSkills.length} skills — control OmniRoute via REST / HTTP`}
icon="api"
skills={apiSkills}
/>
<SkillSection
title="CLI Skills"
title={t("cliSkills")}
subtitle={`${cliSkills.length} skills — control OmniRoute via the omniroute terminal binary`}
icon="terminal"
skills={cliSkills}

View File

@@ -59,7 +59,7 @@ export default function AutoRoutingAnalyticsTab() {
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
</div>
<div>
<p className="text-sm text-text-muted">Total Auto Requests</p>
<p className="text-sm text-text-muted">{t("autoRoutingTotalAutoRequests")}</p>
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
</div>
</div>
@@ -71,7 +71,7 @@ export default function AutoRoutingAnalyticsTab() {
<span className="material-symbols-outlined text-[20px]">target</span>
</div>
<div>
<p className="text-sm text-text-muted">Avg Selection Score</p>
<p className="text-sm text-text-muted">{t("autoRoutingAvgSelectionScore")}</p>
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
</div>
</div>
@@ -83,7 +83,7 @@ export default function AutoRoutingAnalyticsTab() {
<span className="material-symbols-outlined text-[20px]">explore</span>
</div>
<div>
<p className="text-sm text-text-muted">Exploration Rate</p>
<p className="text-sm text-text-muted">{t("autoRoutingExplorationRate")}</p>
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
</div>
</div>
@@ -95,7 +95,7 @@ export default function AutoRoutingAnalyticsTab() {
<span className="material-symbols-outlined text-[20px]">history</span>
</div>
<div>
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
<p className="text-sm text-text-muted">{t("autoRoutingLkgpHitRate")}</p>
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
</div>
</div>
@@ -104,7 +104,7 @@ export default function AutoRoutingAnalyticsTab() {
{/* Variant Breakdown */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingRequestsByVariant")}</h3>
<div className="space-y-3">
{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 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingTopRoutedProviders")}</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>

View File

@@ -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 }) {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:min-w-[420px]">
<MetricBlock
icon="battery_status_good"
label="Worst quota left"
label={t("comboHealthWorstQuotaLeft")}
value={formatPercent(combo.quotaHealth.worstRemainingPct)}
/>
<MetricBlock
icon="balance"
label="Usage skew"
label={t("comboHealthUsageSkew")}
value={combo.usageSkew.giniCoefficient.toFixed(2)}
subValue="Gini coefficient"
/>
<MetricBlock
icon="bolt"
label="Success rate"
label={t("comboHealthSuccessRate")}
value={formatPercent(combo.performance.successRate * 100, 1)}
subValue={`${combo.performance.totalRequests.toLocaleString()} requests`}
/>
@@ -141,7 +143,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
<div className="grid gap-6 px-6 py-5 xl:grid-cols-[1.1fr_1fr_0.95fr]">
<section className="flex flex-col gap-4">
<div>
<div className="text-sm font-semibold text-text-main">Quota health</div>
<div className="text-sm font-semibold text-text-main">
{t("comboHealthQuotaHealth")}
</div>
<div className="mt-1 text-xs text-text-muted">
Lowest remaining quota across providers with short trend signals.
</div>
@@ -189,7 +193,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
<section className="flex flex-col gap-4">
<div>
<div className="text-sm font-semibold text-text-main">Usage skew</div>
<div className="text-sm font-semibold text-text-main">{t("comboHealthUsageSkew")}</div>
<div className="mt-1 text-xs text-text-muted">
Model request share and token share within this combo.
</div>
@@ -214,12 +218,12 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
<div className="mt-3 grid gap-2">
<DistributionBar
label="Requests"
label={t("comboHealthRequests")}
value={entry.requestShare}
meta={formatShare(entry.requestShare)}
/>
<DistributionBar
label="Tokens"
label={t("comboHealthTokens")}
value={entry.tokenShare}
meta={formatShare(entry.tokenShare)}
/>
@@ -240,17 +244,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<MetricBlock
icon="timer"
label="Avg latency"
label={t("comboHealthAvgLatency")}
value={formatLatency(combo.performance.avgLatencyMs)}
/>
<MetricBlock
icon="task_alt"
label="Success rate"
label={t("comboHealthSuccessRate")}
value={formatPercent(combo.performance.successRate * 100, 1)}
/>
<MetricBlock
icon="stacked_line_chart"
label="Total requests"
label={t("comboHealthTotalRequests")}
value={combo.performance.totalRequests.toLocaleString()}
/>
</div>
@@ -260,7 +264,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
{targetHealth.length > 0 ? (
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
<div>
<div className="text-sm font-semibold text-text-main">Execution targets</div>
<div className="text-sm font-semibold text-text-main">
{t("comboHealthExecutionTargets")}
</div>
<div className="mt-1 text-xs text-text-muted">
Step-level runtime metrics and quota visibility for structured combo targets.
</div>
@@ -297,17 +303,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<DistributionBar
label="Success"
label={t("comboHealthSuccess")}
value={Math.max(target.successRate, 0) / 100}
meta={formatPercent(target.successRate, 0)}
/>
<DistributionBar
label="Latency"
label={t("comboHealthLatency")}
value={target.avgLatencyMs > 0 ? 1 : 0}
meta={formatLatency(target.avgLatencyMs)}
/>
<DistributionBar
label="Quota"
label={t("comboHealthQuota")}
value={Math.max(target.quotaRemainingPct || 0, 0) / 100}
meta={formatPercentOrDash(target.quotaRemainingPct)}
/>
@@ -361,6 +367,7 @@ function ComboHealthSkeleton() {
}
export default function ComboHealthTab() {
const t = useTranslations("analytics");
const [range, setRange] = useState<UtilizationTimeRange>("24h");
const [data, setData] = useState<ComboHealthResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -421,7 +428,7 @@ export default function ComboHealthTab() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 rounded-xl border border-black/5 bg-surface p-5 shadow-sm dark:border-white/5 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">Combo health</h2>
<h2 className="text-lg font-semibold text-text-main">{t("comboHealthTitle")}</h2>
<p className="mt-1 text-sm text-text-muted">
Monitor quota pressure, skewed model usage, and delivery performance by combo.
</p>
@@ -436,7 +443,7 @@ export default function ComboHealthTab() {
<div className="flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-[40px] text-error">sync_problem</span>
<div className="flex flex-col gap-1">
<div className="font-medium text-text-main">Unable to load combo health</div>
<div className="font-medium text-text-main">{t("comboHealthUnableToLoad")}</div>
<div className="text-sm text-text-muted">{error}</div>
</div>
<button
@@ -477,7 +484,7 @@ export default function ComboHealthTab() {
flowing.
</div>
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
<p className="text-xs font-medium text-text-main">Getting started</p>
<p className="text-xs font-medium text-text-main">{t("comboHealthGettingStarted")}</p>
<ul className="mt-2 text-left text-xs text-text-muted">
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">

View File

@@ -8,6 +8,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface CompressionAnalyticsSummary {
totalRequests: number;
@@ -116,6 +117,7 @@ function ProviderBar({
}
export default function CompressionAnalyticsTab() {
const t = useTranslations("analytics");
const [stats, setStats] = useState<CompressionAnalyticsSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -192,25 +194,33 @@ export default function CompressionAnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
<StatCard
icon="compress"
label="Total Requests"
label={t("compressionAnalyticsTotalRequests")}
value={stats.totalRequests.toLocaleString()}
/>
<StatCard
icon="token"
label="Tokens Saved"
label={t("compressionAnalyticsTokensSaved")}
value={stats.totalTokensSaved.toLocaleString()}
/>
<StatCard icon="percent" label="Avg Savings" value={`${stats.avgSavingsPct}%`} />
<StatCard icon="timer" label="Avg Duration" value={`${stats.avgDurationMs}ms`} />
<StatCard
icon="percent"
label={t("compressionAnalyticsAvgSavings")}
value={`${stats.avgSavingsPct}%`}
/>
<StatCard
icon="timer"
label={t("compressionAnalyticsAvgDuration")}
value={`${stats.avgDurationMs}ms`}
/>
<StatCard
icon="receipt_long"
label="Receipts"
label={t("compressionAnalyticsReceipts")}
value={stats.realUsage.requestsWithReceipts.toLocaleString()}
sub={`${stats.realUsage.totalTokens.toLocaleString()} real tokens`}
/>
<StatCard
icon="verified"
label="Fallbacks"
label={t("compressionAnalyticsFallbacks")}
value={stats.validationFallbacks.toLocaleString()}
sub="validation restores"
/>
@@ -224,25 +234,25 @@ export default function CompressionAnalyticsTab() {
</h3>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-sm">
<div>
<div className="text-text-muted">Prompt tokens</div>
<div className="text-text-muted">{t("compressionAnalyticsPromptTokens")}</div>
<div className="text-lg font-semibold text-text">
{stats.realUsage.promptTokens.toLocaleString()}
</div>
</div>
<div>
<div className="text-text-muted">Completion tokens</div>
<div className="text-text-muted">{t("compressionAnalyticsCompletionTokens")}</div>
<div className="text-lg font-semibold text-text">
{stats.realUsage.completionTokens.toLocaleString()}
</div>
</div>
<div>
<div className="text-text-muted">Total tokens</div>
<div className="text-text-muted">{t("compressionAnalyticsTotalTokens")}</div>
<div className="text-lg font-semibold text-text">
{stats.realUsage.totalTokens.toLocaleString()}
</div>
</div>
<div>
<div className="text-text-muted">Cache tokens</div>
<div className="text-text-muted">{t("compressionAnalyticsCacheTokens")}</div>
<div className="text-lg font-semibold text-text">
{(
(stats.realUsage.cacheReadTokens ?? 0) + (stats.realUsage.cacheWriteTokens ?? 0)
@@ -345,7 +355,7 @@ export default function CompressionAnalyticsTab() {
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
compress
</span>
<p className="font-medium text-text">No compression data yet</p>
<p className="font-medium text-text">{t("compressionAnalyticsNoDataYet")}</p>
<p className="text-sm mt-1">
Use <code className="bg-bg-muted px-1 rounded">POST /v1/chat/completions</code> with
compression configuration to start tracking compression analytics.

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CartesianGrid,
@@ -89,6 +90,7 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
}
export default function ProviderUtilizationTab() {
const t = useTranslations("analytics");
const [range, setRange] = useState<UtilizationTimeRange>("24h");
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
const [data, setData] = useState<ProviderUtilizationResponse | null>(null);
@@ -191,7 +193,7 @@ export default function ProviderUtilizationTab() {
return (
<div className="flex flex-col gap-6">
<Card
title="Provider utilization"
title={t("providerUtilizationTitle")}
subtitle={RANGE_LABELS[range]}
icon="monitoring"
action={
@@ -236,7 +238,9 @@ export default function ProviderUtilizationTab() {
<div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-[32px] text-error">error</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Failed to load utilization data</p>
<p className="text-sm font-medium text-text-main">
{t("providerUtilizationFailedToLoad")}
</p>
<p className="text-sm text-text-muted">{error}</p>
</div>
<button
@@ -266,13 +270,15 @@ export default function ProviderUtilizationTab() {
timeline
</span>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-text-main">No utilization data available</p>
<p className="text-sm font-medium text-text-main">{t("providerUtilizationNoData")}</p>
<p className="max-w-md text-sm text-text-muted">
Provider quota snapshots will appear here after utilization data is collected.
</p>
</div>
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
<p className="text-xs font-medium text-text-main">Getting started</p>
<p className="text-xs font-medium text-text-main">
{t("providerUtilizationGettingStarted")}
</p>
<ul className="mt-2 text-left text-xs text-text-muted">
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">
@@ -369,7 +375,9 @@ export default function ProviderUtilizationTab() {
</div>
<div>
<p className="text-sm font-semibold text-text-main">{point.provider}</p>
<p className="text-xs text-text-muted">Latest quota snapshot</p>
<p className="text-xs text-text-muted">
{t("providerUtilizationLatestSnapshot")}
</p>
</div>
</div>
<span
@@ -390,7 +398,9 @@ export default function ProviderUtilizationTab() {
<p className="text-3xl font-bold text-text-main">
{point.remainingPct.toFixed(point.remainingPct < 10 ? 1 : 0)}%
</p>
<p className="mt-1 text-xs text-text-muted">Remaining capacity</p>
<p className="mt-1 text-xs text-text-muted">
{t("providerUtilizationRemainingCapacity")}
</p>
</div>
<div className="text-right text-xs text-text-muted">
<p>{formatTooltipTimestamp(point.timestamp, range)}</p>

View File

@@ -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<SearchStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -122,25 +124,25 @@ export default function SearchAnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon="manage_search"
label="Total Searches"
label={t("searchAnalyticsTotalSearches")}
value={stats.total.toLocaleString()}
sub={`${stats.today} today`}
/>
<StatCard
icon="cached"
label="Cache Hit Rate"
label={t("searchAnalyticsCacheHitRate")}
value={`${stats.cacheHitRate}%`}
sub={`${stats.cached} cached requests`}
/>
<StatCard
icon="attach_money"
label="Total Cost"
label={t("searchAnalyticsTotalCost")}
value={`$${stats.totalCostUsd.toFixed(4)}`}
sub="search API costs"
/>
<StatCard
icon="timer"
label="Avg Response"
label={t("searchAnalyticsAvgResponse")}
value={`${stats.avgDurationMs}ms`}
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
/>
@@ -173,7 +175,7 @@ export default function SearchAnalyticsTab() {
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
travel_explore
</span>
<p className="font-medium text-text">No searches yet</p>
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
<p className="text-sm mt-1">
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
web searches.

View File

@@ -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<DiversityReport | null>(null);
const [loading, setLoading] = useState(true);
@@ -82,7 +84,7 @@ export default function DiversityScoreCard() {
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
<h3 className="font-semibold text-text-main">{t("diversityScoreTitle")}</h3>
<span className="text-xs text-text-muted hidden sm:inline">
Provider concentration snapshot for the recent traffic window.
</span>

View File

@@ -1279,7 +1279,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Max Sessions Limit (T08) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Max Active Sessions</p>
<p className="text-sm font-medium text-text-main">{t("maxActiveSessions")}</p>
<p className="text-xs text-text-muted">
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
</p>
@@ -1302,10 +1302,10 @@ const PermissionsModal = memo(function PermissionsModal({
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Custom Rate Limits</p>
<p className="text-xs text-text-muted">
Override global default limits. Leave empty to use defaults.
<p className="text-sm font-medium text-text-main">
{t("apiManagerCustomRateLimits")}
</p>
<p className="text-xs text-text-muted">{t("apiManagerCustomRateLimitsDesc")}</p>
</div>
<button
type="button"
@@ -1332,9 +1332,11 @@ const PermissionsModal = memo(function PermissionsModal({
return next;
});
}}
placeholder="Requests"
placeholder={t("apiManagerRateLimitRequestsPlaceholder")}
/>
<span className="text-sm text-text-muted shrink-0">req /</span>
<span className="text-sm text-text-muted shrink-0">
{t("apiManagerRateLimitReqPer")}
</span>
<Input
type="number"
min={1}
@@ -1347,14 +1349,14 @@ const PermissionsModal = memo(function PermissionsModal({
return next;
});
}}
placeholder="Seconds"
placeholder={t("apiManagerRateLimitSecondsPlaceholder")}
/>
<span className="text-sm text-text-muted shrink-0">sec</span>
<button
type="button"
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
title="Remove limit"
title={t("apiManagerRemoveLimitTitle")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
@@ -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"
/>
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
@@ -1466,7 +1468,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Privacy Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">No-Log Payload Privacy</p>
<p className="text-sm font-medium text-text-main">{t("noLogPayloadPrivacy")}</p>
<p className="text-xs text-text-muted">
Disable request/response payload persistence for this API key.
</p>
@@ -1516,7 +1518,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Ban Toggle (SECURITY) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
<div className="flex flex-col gap-1">
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
<p className="text-sm font-bold text-red-700 dark:text-red-400">{t("bannedStatus")}</p>
<p className="text-xs text-red-600 dark:text-red-300">
Immediately revoke all access. Used for suspected abuse or compromised keys.
</p>
@@ -1540,7 +1542,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Management API Access Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Management API Access</p>
<p className="text-sm font-medium text-text-main">{t("managementApiAccess")}</p>
<p className="text-xs text-text-muted">
Allow this key to call management routes (providers, combos, settings) via{" "}
<code className="font-mono">Authorization: Bearer</code>. Use for LLM agents only.
@@ -1565,7 +1567,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Expiration Date */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Expiration Date</p>
<p className="text-sm font-medium text-text-main">{t("expirationDate")}</p>
<p className="text-xs text-text-muted">
Key will automatically stop working after this date.
</p>
@@ -1583,7 +1585,7 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Management Access */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Management Access</p>
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
<p className="text-xs text-text-muted">
Allow this API key to manage OmniRoute configuration.
</p>
@@ -1772,7 +1774,7 @@ const PermissionsModal = memo(function PermissionsModal({
{allConnections.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
<p className="text-sm font-medium text-text-main">{t("allowedConnections")}</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
@@ -135,6 +136,7 @@ function formatTs(ts: number | null | undefined): string {
}
export default function BatchDetailModal({ batch, files, onClose }: BatchDetailModalProps) {
const t = useTranslations("common");
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -182,7 +184,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
navigator.clipboard.writeText(batch.id);
}}
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
title="Copy ID"
title={t("batchDetailCopyId")}
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
</button>
@@ -191,7 +193,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
</div>
<button
onClick={onClose}
aria-label="Close"
aria-label={t("batchDetailClose")}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
@@ -208,11 +210,11 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
</span>
<StatusBadge batch={batch} />
</div>
<Field label="Endpoint" value={batch.endpoint} />
{batch.model && <Field label="Model" value={batch.model} />}
<Field label="Window" value={batch.completionWindow} />
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
<Field
label="Created"
label={t("batchDetailCreated")}
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
/>
</div>

View File

@@ -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<BatchListTabProps>) {
const t = useTranslations("common");
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
const [statusFilter, setStatusFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
@@ -199,7 +201,7 @@ export default function BatchListTab({
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
<input
type="text"
placeholder="Search by ID, endpoint, model…"
placeholder={t("batchListSearchPlaceholder")}
value={searchQuery}
onChange={(e) => 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")}
>
<span className="material-symbols-outlined text-[16px]">
{removingCompleted ? "hourglass_empty" : "delete_sweep"}
@@ -230,7 +232,7 @@ export default function BatchListTab({
{/* Table */}
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Batches">
<table className="w-full text-sm" role="table" aria-label={t("batchListBatchesTable")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
@@ -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")}
>
<span className="material-symbols-outlined text-[13px]">
{deletingId === batch.id ? "hourglass_empty" : "delete"}

View File

@@ -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<FileDetailModalProps>) {
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")}
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
</button>
@@ -149,7 +151,7 @@ export default function FileDetailModal({
</div>
<button
onClick={onClose}
aria-label="Close"
aria-label={t("batchFileDetailClose")}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
@@ -266,7 +268,7 @@ export default function FileDetailModal({
<span className="material-symbols-outlined text-[40px] mb-2 opacity-20">
find_in_page
</span>
<p className="text-sm">Failed to load file contents</p>
<p className="text-sm">{t("batchFileDetailFailedToLoad")}</p>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import FileDetailModal from "./FileDetailModal";
function relativeTime(ts: number): string {
@@ -84,6 +85,7 @@ export default function FilesListTab({
onRefresh,
batches,
}: Readonly<FilesListTabProps>) {
const t = useTranslations("common");
const [searchQuery, setSearchQuery] = useState("");
const [purposeFilter, setPurposeFilter] = useState("all");
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
@@ -129,7 +131,7 @@ export default function FilesListTab({
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
<input
type="text"
placeholder="Search by ID or filename…"
placeholder={t("batchFilesListSearchPlaceholder")}
value={searchQuery}
onChange={(e) => 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)]"
@@ -149,7 +151,7 @@ export default function FilesListTab({
{/* Table */}
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Files">
<table className="w-full text-sm" role="table" aria-label={t("batchFilesListFilesTable")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">

View File

@@ -1,12 +1,14 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import BatchListTab from "./BatchListTab";
import { FileRecord } from "@/lib/db/files";
import { BatchRecord } from "@/lib/db/batches";
import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils";
export default function BatchPage() {
const t = useTranslations("common");
const [batches, setBatches] = useState<BatchRecord[]>([]);
const [files, setFiles] = useState<FileRecord[]>([]);
const [batchesTotal, setBatchesTotal] = useState(0);
@@ -174,7 +176,7 @@ export default function BatchPage() {
onRefresh={() => fetchData(false)}
/>
{loadingMore && batchesCount > 0 && (
<div className="text-center text-sm">Loading more</div>
<div className="text-center text-sm">{t("batchPageLoadingMore")}</div>
)}
<div ref={bottomRefBatches} className="h-10" />
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
interface CachePerformanceProps {
@@ -68,6 +69,7 @@ export default function CachePerformance({
onRetry,
stats,
}: CachePerformanceProps) {
const t = useTranslations("cache");
// Parse hitRate string (e.g. "85.0%") to number for the bar
const hitRateNum = hitRate ? parseFloat(hitRate) : 0;
@@ -87,7 +89,7 @@ export default function CachePerformance({
<button
onClick={onRetry}
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
aria-label="Retry"
aria-label={t("cachePerformanceRetry")}
>
Retry
</button>
@@ -117,7 +119,9 @@ export default function CachePerformance({
{!loading && !error && stats !== null && (
<>
{/* Hit rate bar */}
{hitRate !== undefined && <HitRateBar hitRate={hitRateNum} label="Hit Rate" />}
{hitRate !== undefined && (
<HitRateBar hitRate={hitRateNum} label={t("cachePerformanceHitRate")} />
)}
{/* Hit / Miss / Total breakdown */}
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
@@ -141,13 +145,17 @@ export default function CachePerformance({
{avgLatencyMs !== undefined && (
<div>
<div className="text-lg font-semibold tabular-nums">{avgLatencyMs}</div>
<div className="text-xs text-text-muted mt-0.5">Avg Latency (ms)</div>
<div className="text-xs text-text-muted mt-0.5">
{t("cachePerformanceAvgLatency")}
</div>
</div>
)}
{p95LatencyMs !== undefined && (
<div>
<div className="text-lg font-semibold tabular-nums">{p95LatencyMs}</div>
<div className="text-xs text-text-muted mt-0.5">P95 Latency (ms)</div>
<div className="text-xs text-text-muted mt-0.5">
{t("cachePerformanceP95Latency")}
</div>
</div>
)}
</div>

View File

@@ -62,7 +62,7 @@ export default function IdempotencyLayer({
<button
onClick={onRetry}
className="self-start text-sm px-3 py-1 rounded bg-surface/50 hover:bg-surface/80 transition-colors"
aria-label="Retry"
aria-label={t("retry")}
>
Retry
</button>

View File

@@ -336,7 +336,7 @@ export default function ReasoningCacheTab() {
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
<th className="px-4 py-3">Model</th>
<th className="px-4 py-3">{t("reasoningEntries")}</th>
<th className="px-4 py-3">Avg Chars</th>
<th className="px-4 py-3">{t("reasoningAvgChars")}</th>
<th className="px-4 py-3">{t("reasoningChars")}</th>
</tr>
</thead>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import ReactMarkdown, { type Components } from "react-markdown";
import { Button } from "@/shared/components";
import {
@@ -80,6 +81,7 @@ const markdownComponents: Components = {
};
export default function ChangelogViewer() {
const t = useTranslations("common");
const [markdown, setMarkdown] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -109,7 +111,7 @@ export default function ChangelogViewer() {
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
sync
</span>
<p className="text-sm text-text-muted">Loading changelog from GitHub...</p>
<p className="text-sm text-text-muted">{t("changelogViewerLoading")}</p>
</div>
);
}

View File

@@ -649,8 +649,8 @@ openai_base_url = "${getEffectiveBaseUrl()}"
onChange={(e) => setWireApi(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="chat">Chat Completions (/chat/completions)</option>
<option value="responses">Responses API (/responses)</option>
<option value="chat">{t("wireApiChatCompletions")}</option>
<option value="responses">{t("wireApiResponses")}</option>
</select>
</div>

View File

@@ -193,7 +193,7 @@ export default function CopilotToolCard({
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium">GitHub Copilot Config Generator</p>
<p className="font-medium">{t("copilotConfigGenerator")}</p>
<p className="mt-1 text-xs opacity-80">
Generates the{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
@@ -226,7 +226,7 @@ export default function CopilotToolCard({
>
1
</div>
<span className="font-medium text-sm">API Key</span>
<span className="font-medium text-sm">{t("copilotApiKey")}</span>
</div>
<select
value={selectedApiKeyId}
@@ -278,7 +278,7 @@ export default function CopilotToolCard({
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder="Filter models..."
placeholder={t("copilotFilterModelsPlaceholder")}
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
@@ -327,7 +327,9 @@ export default function CopilotToolCard({
</summary>
<div className="mt-3 grid grid-cols-2 gap-3 pl-6">
<div>
<label className="text-xs text-text-muted block mb-1">Max Input Tokens</label>
<label className="text-xs text-text-muted block mb-1">
{t("copilotMaxInputTokens")}
</label>
<input
type="number"
value={maxInputTokens}
@@ -336,7 +338,9 @@ export default function CopilotToolCard({
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">Max Output Tokens</label>
<label className="text-xs text-text-muted block mb-1">
{t("copilotMaxOutputTokens")}
</label>
<input
type="number"
value={maxOutputTokens}
@@ -351,7 +355,7 @@ export default function CopilotToolCard({
onChange={(e) => setToolCalling(e.target.checked)}
className="rounded border-border accent-[#1F6FEB]"
/>
<span className="text-sm">Tool Calling</span>
<span className="text-sm">{t("copilotToolCalling")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -401,7 +405,7 @@ export default function CopilotToolCard({
{/* Usage instructions */}
<div className="mt-3 p-3 bg-bg-secondary rounded-lg border border-border">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">Paste into: </span>
<span className="font-medium text-text-main">{t("copilotPasteInto")} </span>
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
~/.config/Code/User/chatLanguageModels.json
</code>

View File

@@ -328,14 +328,14 @@ export default function CloudAgentsPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Repository name"
label={t("repositoryName")}
placeholder="omniroute"
value={newTask.repoName}
onChange={(e) => setNewTask({ ...newTask, repoName: e.target.value })}
required
/>
<Input
label="Repository URL"
label={t("repositoryUrl")}
placeholder="https://github.com/owner/repo"
value={newTask.repoUrl}
onChange={(e) => setNewTask({ ...newTask, repoUrl: e.target.value })}
@@ -344,7 +344,7 @@ export default function CloudAgentsPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Branch"
label={t("branch")}
placeholder="main"
value={newTask.branch}
onChange={(e) => setNewTask({ ...newTask, branch: e.target.value })}

View File

@@ -287,7 +287,9 @@ export default function IntelligentComboPanel({
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
<p className="text-[11px] uppercase tracking-wide text-text-muted">Mode Pack</p>
<p className="text-[11px] uppercase tracking-wide text-text-muted">
{t("modePack")}
</p>
<p className="mt-1 text-sm font-semibold text-text-main">
{normalizedConfig.modePack}
</p>

View File

@@ -1738,7 +1738,7 @@ function ComboCard({
onChange={(e) => handleCompressionOverrideChange(e.target.value)}
disabled={isSavingCompression}
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
title="Compression Override"
title={t("compressionOverride")}
>
<option value="">Default</option>
<option value="off">Off</option>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
interface BadgeUnlockEvent {
badgeId: string;
@@ -20,6 +21,7 @@ const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
const t = useTranslations("common");
const [toasts, setToasts] = useState<BadgeUnlockEvent[]>([]);
const timeoutIds = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
@@ -92,7 +94,7 @@ export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
>
<span className="text-2xl">🏆</span>
<div>
<div className="font-semibold text-white">Badge Unlocked!</div>
<div className="font-semibold text-white">{t("badgeToastUnlocked")}</div>
<div className="text-sm text-text-muted">{toast.badgeName}</div>
</div>
</div>

View File

@@ -339,11 +339,8 @@ export default function QuotaSharePageClient() {
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2">
<span className="material-symbols-outlined text-[18px] shrink-0">science</span>
<div>
<strong>Beta UI preview.</strong> A configuração é salva em <code>localStorage</code>{" "}
(não persiste no servidor ainda). A aplicação dos caps por request ainda não está
conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota;
a aplicação real virá em uma próxima iteração com persistência no banco e interceptação na
chamada upstream.
<strong>{t("betaPreviewLabel")}</strong> {t("betaConfigSavedPrefix")}{" "}
<code>localStorage</code> {t("betaConfigSavedSuffix")}
</div>
</div>
@@ -598,7 +595,9 @@ function PoolCard({
<div className="mt-3 flex items-center justify-between gap-2 flex-wrap text-[11px]">
<div className="flex items-center gap-1">
<span className="text-text-muted font-semibold uppercase tracking-wide">Policy:</span>
<span className="text-text-muted font-semibold uppercase tracking-wide">
{t("policyLabel")}
</span>
{(["hard", "soft", "burst"] as PoolPolicy[]).map((p) => (
<button
key={p}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { useDisplayBaseUrl } from "@/shared/hooks";
@@ -44,6 +45,7 @@ const METHOD_COLORS: Record<string, string> = {
/* ─── Main Component ─────────────────────────────────── */
export default function ApiEndpointsTab() {
const t = useTranslations("endpoint");
const baseUrl = useDisplayBaseUrl();
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
@@ -224,7 +226,9 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[20px] text-red-500">error</span>
</div>
<div>
<h3 className="text-sm font-semibold text-text-main">API catalog unavailable</h3>
<h3 className="text-sm font-semibold text-text-main">
{t("apiEndpointsCatalogUnavailable")}
</h3>
<p className="text-xs text-text-muted mt-1">
{catalogError || "The OpenAPI specification could not be loaded."}
</p>
@@ -254,7 +258,7 @@ export default function ApiEndpointsTab() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search endpoints..."
placeholder={t("apiEndpointsSearchPlaceholder")}
className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
/>
@@ -334,7 +338,7 @@ export default function ApiEndpointsTab() {
{ep.security && (
<span
className="material-symbols-outlined text-[12px] text-amber-500"
title="Requires auth"
title={t("apiEndpointsRequiresAuth")}
>
lock
</span>
@@ -482,7 +486,7 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[32px] text-text-muted">
search_off
</span>
<p className="text-sm text-text-muted mt-2">No endpoints match your filter</p>
<p className="text-sm text-text-muted mt-2">{t("apiEndpointsNoMatch")}</p>
</Card>
)}

View File

@@ -1281,7 +1281,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
</span>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1 flex-wrap">
<span className="text-sm font-medium">Local Server</span>
<span className="text-sm font-medium">{t("localServer")}</span>
{resolvedMachineId && (
<span className="text-xs text-text-muted">· {resolvedMachineId.slice(0, 8)}</span>
)}
@@ -1335,7 +1335,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
cloud
</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Cloud OmniRoute</span>
<span className="text-sm font-medium">{t("cloudOmniroute")}</span>
</div>
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
@@ -2387,7 +2387,7 @@ function EndpointCard({
<button
onClick={() => void copy(fullUrl, copyId)}
className="shrink-0 flex items-center justify-center size-6 rounded hover:bg-sidebar transition-colors"
title="Copy URL"
title={t("copyUrl")}
>
<span className="material-symbols-outlined text-[12px] text-text-muted">
{copied === copyId ? "check" : "content_copy"}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
@@ -111,6 +112,7 @@ function EngineRow({
}
export default function TokenSaverCard() {
const t = useTranslations("endpoint");
const notify = useNotificationStore();
const [config, setConfig] = useState<CompressionConfig | null>(null);
const [loading, setLoading] = useState(true);
@@ -182,14 +184,14 @@ export default function TokenSaverCard() {
</span>
)}
</h2>
<p className="text-sm text-text-muted mt-1">Spend less tokens on every request.</p>
<p className="text-sm text-text-muted mt-1">{t("tokenSaverSubtitle")}</p>
</div>
<Toggle size="md" checked={masterEnabled} onChange={(v) => save({ enabled: v })} />
</div>
<div className="divide-y divide-border mt-4">
<EngineRow
title="Tool output"
title={t("tokenSaverToolOutput")}
badge="RTK"
href="/dashboard/context/rtk"
description="git/grep/ls/tree/logs cleaner → 60-90% fewer input tokens"
@@ -206,7 +208,7 @@ export default function TokenSaverCard() {
}
/>
<EngineRow
title="LLM output"
title={t("tokenSaverLlmOutput")}
badge="Caveman"
href="/dashboard/context/caveman"
description="Terse-style system prompt → ~65% fewer output tokens (up to 87%)"
@@ -223,7 +225,7 @@ export default function TokenSaverCard() {
}
/>
<EngineRow
title="Input compression"
title={t("tokenSaverInputCompression")}
badge="Caveman"
href="/dashboard/context/caveman"
description="Rewrite chat history → ~50% fewer input tokens"

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
interface Anomaly {
@@ -10,6 +11,7 @@ interface Anomaly {
}
export default function GamificationAdminPage() {
const t = useTranslations("common");
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
const [loading, setLoading] = useState(true);
@@ -33,24 +35,24 @@ export default function GamificationAdminPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Gamification Admin</h1>
<p className="text-sm text-text-muted mt-1">Monitor anomalies and system health</p>
<h1 className="text-2xl font-bold">{t("gamificationAdmin")}</h1>
<p className="text-sm text-text-muted mt-1">{t("monitorAnomaliesAndHealth")}</p>
</div>
<Card>
<h2 className="text-lg font-semibold mb-3">Flagged Anomalies</h2>
<h2 className="text-lg font-semibold mb-3">{t("flaggedAnomalies")}</h2>
{loading ? (
<div className="text-text-muted">Loading...</div>
) : anomalies.length === 0 ? (
<div className="text-text-muted">No anomalies detected</div>
<div className="text-text-muted">{t("noAnomaliesDetected")}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-2 font-medium text-text-muted">API Key</th>
<th className="text-right p-2 font-medium text-text-muted">XP (1h)</th>
<th className="text-right p-2 font-medium text-text-muted">Z-Score</th>
<th className="text-left p-2 font-medium text-text-muted">{t("apiKey")}</th>
<th className="text-right p-2 font-medium text-text-muted">{t("xpLastHour")}</th>
<th className="text-right p-2 font-medium text-text-muted">{t("zScore")}</th>
<th className="text-center p-2 font-medium text-text-muted">Status</th>
</tr>
</thead>

View File

@@ -255,7 +255,7 @@ export default function HealthPage() {
<span className="material-symbols-outlined text-[18px]">database</span>
</div>
<div>
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
<h2 className="text-lg font-semibold text-text-main">{t("databaseHealth")}</h2>
<p className="text-sm text-text-muted">
Diagnose and repair stale quota/domain rows and broken combo references.
</p>
@@ -417,13 +417,13 @@ export default function HealthPage() {
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Sticky-bound sessions</div>
<div className="text-xs text-text-muted">{t("stickyBoundSessions")}</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{sessions?.stickyBoundCount ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Sessions by API key</div>
<div className="text-xs text-text-muted">{t("sessionsByApiKey")}</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{Object.keys(sessions?.byApiKey || {}).length}
</div>
@@ -453,7 +453,7 @@ export default function HealthPage() {
))}
</div>
) : (
<p className="text-sm text-text-muted">No active sessions tracked yet.</p>
<p className="text-sm text-text-muted">{t("noActiveSessionsTracked")}</p>
)}
</Card>
@@ -532,14 +532,14 @@ export default function HealthPage() {
))}
</div>
) : (
<p className="text-sm text-text-muted">No session quota monitors active.</p>
<p className="text-sm text-text-muted">{t("noSessionQuotaMonitorsActive")}</p>
)}
</Card>
</div>
{/* Graceful Degradation Status */}
{degradation && degradation.features && degradation.features.length > 0 && (
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">
<Card className="p-5" role="region" aria-label={t("gracefulDegradationStatus")}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">healing</span>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared";
@@ -26,6 +27,7 @@ const MEDAL_COLORS = [
const MEDAL_EMOJI = ["🥇", "🥈", "🥉"];
export default function LeaderboardPage() {
const t = useTranslations("common");
const [scope, setScope] = useState<LeaderboardScope>("global");
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
@@ -110,7 +112,7 @@ export default function LeaderboardPage() {
<Card>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-muted">Your Rank</p>
<p className="text-sm text-text-muted">{t("leaderboardYourRank")}</p>
<p className="text-3xl font-bold mt-1">#{myRank}</p>
</div>
<div className="text-right">
@@ -125,7 +127,7 @@ export default function LeaderboardPage() {
{loading ? (
<div className="flex items-center justify-center min-h-[200px]">
<div className="text-text-muted">Loading leaderboard...</div>
<div className="text-text-muted">{t("leaderboardLoading")}</div>
</div>
) : (
<>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { copyToClipboard } from "@/shared/utils/clipboard";
import McpDashboardPage from "../endpoint/components/MCPDashboard";
@@ -98,6 +99,7 @@ function TransportSelector({
disabled: boolean;
baseUrl: string;
}) {
const t = useTranslations("mcpDashboard");
const options: { value: McpTransport; label: string; desc: string }[] = [
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
@@ -181,7 +183,7 @@ function TransportSelector({
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
onClick={() => void copyToClipboard(urlMap[value])}
title="Copy URL"
title={t("mcpDashboardCopyUrl")}
>
Copy
</button>

View File

@@ -1,9 +1,11 @@
"use client";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import Image from "next/image";
export function TierFlowDiagram() {
const t = useTranslations("onboarding");
const { resolvedTheme } = useTheme();
const src =
resolvedTheme === "dark" ? "/images/tier-flow-dark.svg" : "/images/tier-flow-light.svg";
@@ -12,7 +14,7 @@ export function TierFlowDiagram() {
<div className="flex flex-col items-center gap-3 my-4">
<Image
src={src}
alt="OmniRoute 3-tier fallback diagram"
alt={t("tierFlowDiagramAlt")}
width={800}
height={420}
priority

View File

@@ -48,9 +48,18 @@ export default function ChatPlayground({
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
.map((m) => ({ value: m.id, label: m.id }));
const filteredModels = (() => {
const seen = new Set<string>();
const out: Array<{ value: string; label: string }> = [];
for (const m of models) {
if (typeof m?.id !== "string") continue;
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push({ value: m.id, label: m.id });
}
return out;
})();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -222,7 +231,7 @@ export default function ChatPlayground({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">chat</span>
<h3 className="text-sm font-semibold text-text-main">Conversational Chat</h3>
<h3 className="text-sm font-semibold text-text-main">{t("conversationalChat")}</h3>
{responseStatus !== null && (
<Badge variant={responseStatus < 400 ? "success" : "error"} size="sm">
{responseStatus}
@@ -235,7 +244,7 @@ export default function ChatPlayground({
<button
onClick={handleClear}
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
title="Clear chat"
title={t("clearChat")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -292,7 +301,7 @@ export default function ChatPlayground({
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
placeholder={t("typeMessagePlaceholder")}
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
rows={1}
disabled={loading || noModels}

View File

@@ -175,7 +175,7 @@ export default function SearchPlayground() {
<button
onClick={() => navigator.clipboard.writeText(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -194,7 +194,7 @@ export default function SearchPlayground() {
)
}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>

View File

@@ -259,6 +259,7 @@ export default function PlaygroundPage() {
const providerSet = new Set<string>();
modelList.forEach((m) => {
if (typeof m?.id !== "string") return;
const parts = m.id.split("/");
if (parts.length >= 2) providerSet.add(parts[0]);
});
@@ -270,7 +271,9 @@ export default function PlaygroundPage() {
setSelectedProvider(providerOpts[0].value);
}
})
.catch(() => {});
.catch((err) => {
console.error("[playground] Failed to load models:", err);
});
// Fetch ALL connections (once)
fetch("/api/providers/client")
@@ -291,9 +294,18 @@ export default function PlaygroundPage() {
.catch(() => {});
}, []);
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
.map((m) => ({ value: m.id, label: m.id }));
const filteredModels = (() => {
const seen = new Set<string>();
const out: Array<{ value: string; label: string }> = [];
for (const m of models) {
if (typeof m?.id !== "string") continue;
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push({ value: m.id, label: m.id });
}
return out;
})();
const generateDefaultBody = (endpoint: string, model: string) => {
const template = { ...DEFAULT_BODIES[endpoint] };
@@ -307,7 +319,9 @@ export default function PlaygroundPage() {
setSelectedProvider(newProvider);
setSelectedConnection("");
const providerModels = models
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
.filter(
(m) => typeof m?.id === "string" && (!newProvider || m.id.startsWith(newProvider + "/"))
)
.map((m) => m.id);
const firstModel = providerModels[0] || "";
setSelectedModel(firstModel);

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Badge } from "@/shared/components";
import {
xpForLevel,
@@ -57,6 +58,7 @@ const RARITY_COLORS: Record<string, string> = {
};
export default function ProfilePage() {
const t = useTranslations("common");
const [userLevel, setUserLevel] = useState<UserLevel | null>(null);
const [allBadges, setAllBadges] = useState<BadgeDef[]>([]);
const [earnedBadges, setEarnedBadges] = useState<UserBadge[]>([]);
@@ -99,7 +101,7 @@ export default function ProfilePage() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-text-muted">Loading profile...</div>
<div className="text-text-muted">{t("profileLoading")}</div>
</div>
);
}
@@ -269,7 +271,7 @@ export default function ProfilePage() {
{selectedBadge.criteria && (
<div className="p-3 rounded-lg bg-surface/50 border border-border/50">
<p className="text-xs font-medium text-text-muted mb-1">How to earn</p>
<p className="text-xs font-medium text-text-muted mb-1">{t("profileHowToEarn")}</p>
<p className="text-sm">{selectedBadge.criteria}</p>
</div>
)}

View File

@@ -3373,8 +3373,8 @@ export default function ProviderDetailPage() {
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed
IDE stored in the OS keychain and import them as connections. Requires Zed IDE
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
installed on this machine.
</p>
</div>
@@ -3408,8 +3408,8 @@ export default function ProviderDetailPage() {
<p className="text-sm text-text-muted">
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
API key that Zed stored under{" "}
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy it
from the Zed AI settings panel.
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
it from the Zed AI settings panel.
</p>
<div className="flex gap-2 flex-col sm:flex-row">
<select
@@ -3530,13 +3530,13 @@ export default function ProviderDetailPage() {
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">{t("connections")}</h2>
{providerId === "codex" && (
<div title="Apply Codex Fast tier to all Codex connections by default">
<div title={t("providerDetailFastTierTooltip")}>
<Toggle
size="sm"
checked={codexGlobalFastServiceTier}
onChange={handleToggleCodexGlobalFastServiceTier}
disabled={savingCodexGlobalFastServiceTier}
label="Fast default"
label={t("providerDetailFastDefaultLabel")}
ariaLabel="Toggle Codex Fast default"
className="rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1"
/>
@@ -7274,7 +7274,9 @@ function AddApiKeyModal({
open_in_new
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-text-main">Browser/manual connect</p>
<p className="font-medium text-text-main">
{t("providerDetailBrowserManualConnect")}
</p>
<p className="mt-1 text-xs text-text-muted">
Open Command Code Studio, then paste the returned key/JSON/URL into the API
key field below.
@@ -7287,7 +7289,9 @@ function AddApiKeyModal({
{commandCodeAuthState?.authUrl && (
<div className="mt-3 space-y-2">
<div>
<p className="mb-1 text-xs font-medium text-text-main">Auth URL</p>
<p className="mb-1 text-xs font-medium text-text-main">
{t("providerDetailAuthUrl")}
</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.authUrl}
@@ -7306,7 +7310,9 @@ function AddApiKeyModal({
</div>
{commandCodeAuthState.callbackUrl && (
<div>
<p className="mb-1 text-xs font-medium text-text-main">Callback URL</p>
<p className="mb-1 text-xs font-medium text-text-main">
{t("providerDetailCallbackUrl")}
</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.callbackUrl}
@@ -8253,9 +8259,7 @@ function ApplyCodexAuthModal({
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.codex/auth.json
</code>
<p className="mt-1 text-xs text-text-muted">
Path is auto-detected per OS (Linux/Mac/Windows).
</p>
<p className="mt-1 text-xs text-text-muted">{t("providerDetailPathAutoDetectedAllOs")}</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
@@ -8709,7 +8713,9 @@ function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthModalProp
className="block w-full text-sm"
/>
{singleJson && previewClaudeJson(singleJson).valid && (
<p className="mt-1 text-xs text-emerald-500">Valid Claude credentials file</p>
<p className="mt-1 text-xs text-emerald-500">
{t("providerDetailValidClaudeCredentialsFile")}
</p>
)}
{singleJson && !previewClaudeJson(singleJson).valid && (
<p className="mt-1 text-xs text-red-500">
@@ -9753,7 +9759,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
? "text-red-400"
: health?.status === "warning"
? "text-yellow-400"
: "text-green-400";
: "text-text-muted";
const statusIcon =
health?.status === "invalid" ? "🔴" : health?.status === "warning" ? "🟡" : "🟢";
const statusLabel =
@@ -9766,7 +9772,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
return (
<div className="flex items-center gap-2">
<span
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate ${statusColor}`}
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border truncate ${statusColor}`}
>
{statusIcon} {t("primaryKey")}: {connection.apiKey.slice(0, 6)}...
{connection.apiKey.slice(-4)}
@@ -9820,7 +9826,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
? "text-red-400"
: health?.status === "warning"
? "text-yellow-400"
: "text-green-400";
: "text-text-muted";
const statusIcon =
health?.status === "invalid"
? "🔴"
@@ -9837,7 +9843,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
return (
<div key={idx} className="flex items-center gap-2">
<span
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate ${statusColor}`}
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border truncate ${statusColor}`}
>
{statusIcon}{" "}
{t("extraApiKeyMasked", {

View File

@@ -17,6 +17,7 @@ interface ProviderStats {
total?: number;
connected?: number;
error?: number;
warning?: number;
errorCode?: string | null;
errorTime?: string | null;
allDisabled?: boolean;
@@ -57,6 +58,7 @@ const DOT_COLORS: Record<string, string> = {
function getStatusDisplay(
connected: number,
error: number,
warning: number,
errorCode: string | null | undefined,
t: ReturnType<typeof useTranslations>,
afterConnected?: ReactNode
@@ -70,6 +72,13 @@ function getStatusDisplay(
);
if (afterConnected) parts.push(afterConnected);
}
if (warning > 0) {
parts.push(
<Badge key="warning" variant="warning" size="sm" dot>
{t("warningCount", { count: warning })}
</Badge>
);
}
if (error > 0) {
const errText = errorCode
? t("errorCount", { count: error, code: errorCode })
@@ -206,7 +215,14 @@ export default function ProviderCard({
</Badge>
) : (
<>
{getStatusDisplay(connected, error, stats.errorCode, t, codexFastChip)}
{getStatusDisplay(
connected,
error,
Number(stats.warning || 0),
stats.errorCode,
t,
codexFastChip
)}
{stats.expiryStatus === "expired" && (
<Badge variant="error" size="sm" dot>
{t("expiredBadge")}

View File

@@ -22,7 +22,7 @@ import {
isClaudeCodeCompatibleProvider,
CLOUD_AGENT_PROVIDERS,
} from "@/shared/constants/providers";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
@@ -142,12 +142,20 @@ export default function ProvidersPage() {
const tc = useTranslations("common");
const ccCompatibleLabel = t("ccCompatibleLabel");
const addCcCompatibleLabel = t("addCcCompatible");
const searchParams = useSearchParams();
useEffect(() => {
setShowConfiguredOnly(readConfiguredOnlyPreference());
setConfiguredOnlyPreferenceReady(true);
}, []);
useEffect(() => {
const searchFromUrl = searchParams.get("search");
if (searchFromUrl) {
setSearchQuery(searchFromUrl);
}
}, [searchParams]);
useEffect(() => {
const fetchData = async () => {
try {
@@ -282,9 +290,19 @@ export default function ProvidersPage() {
getCodexEffectiveFastServiceTier(connection.providerSpecificData, false)
));
// Count API keys in "warning" state across all connections
const warning = providerConnections.reduce((warnCount, conn) => {
const health = (conn as any).providerSpecificData?.apiKeyHealth as
| Record<string, { status: string }>
| undefined;
if (!health) return warnCount;
return warnCount + Object.values(health).filter((h) => h.status === "warning").length;
}, 0);
return {
connected,
error,
warning,
total,
errorCode,
errorTime,

View File

@@ -492,7 +492,7 @@ export default function AppearanceTab() {
{(settings.customLogoUrl || settings.customLogoBase64) && (
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
alt={t("appearanceLogoPreviewAlt")}
className="h-10 w-10 rounded border border-border object-contain bg-surface"
onError={(e) => {
e.currentTarget.style.display = "none";
@@ -562,7 +562,7 @@ export default function AppearanceTab() {
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
alt={t("appearanceLogoPreviewAlt")}
className="h-12 w-auto max-w-full rounded"
/>
</div>
@@ -586,7 +586,7 @@ export default function AppearanceTab() {
{(settings.customFaviconUrl || settings.customFaviconBase64) && (
<img
src={settings.customFaviconBase64 || settings.customFaviconUrl}
alt="Favicon preview"
alt={t("appearanceFaviconPreviewAlt")}
className="h-10 w-10 rounded border border-border object-contain bg-surface"
onError={(e) => {
e.currentTarget.style.display = "none";
@@ -658,7 +658,7 @@ export default function AppearanceTab() {
<p className="text-xs text-text-muted mb-2">{t("faviconPreview")}</p>
<img
src={settings.customFaviconBase64 || settings.customFaviconUrl}
alt="Favicon preview"
alt={t("appearanceFaviconPreviewAlt")}
className="h-8 w-8 rounded"
/>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, Input, Toggle } from "@/shared/components";
interface Settings {
@@ -28,6 +29,7 @@ function isValidUrl(value: string): boolean {
}
export default function CliproxyapiSettingsTab() {
const t = useTranslations("settings");
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -139,7 +141,7 @@ export default function CliproxyapiSettingsTab() {
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
</div>
<div>
<h3 className="font-medium text-sm">CLIProxyAPI Fallback</h3>
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
<p className="text-xs text-text-muted">
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
</p>
@@ -148,7 +150,7 @@ export default function CliproxyapiSettingsTab() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm text-text-main">Enable CLIProxyAPI Fallback</label>
<label className="text-sm text-text-main">{t("cliproxyapiEnableFallback")}</label>
<Toggle
checked={cpaEnabled}
onChange={(checked) => updateSetting("cliproxyapi_fallback_enabled", checked)}
@@ -158,7 +160,9 @@ export default function CliproxyapiSettingsTab() {
{cpaEnabled && (
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block">CLIProxyAPI URL</label>
<label className="text-xs text-text-muted mb-1.5 block">
{t("cliproxyapiUrl")}
</label>
<Input
value={cpaUrl}
onChange={(e) => updateSetting("cliproxyapi_url", e.target.value)}
@@ -184,7 +188,7 @@ export default function CliproxyapiSettingsTab() {
</Card>
<Card padding="md">
<h3 className="font-medium text-sm mb-4">CLIProxyAPI Status</h3>
<h3 className="font-medium text-sm mb-4">{t("cliproxyapiStatus")}</h3>
{loading ? (
<div className="flex items-center gap-2 text-text-muted text-sm">
<span className="material-symbols-outlined animate-spin text-base">
@@ -237,7 +241,7 @@ export default function CliproxyapiSettingsTab() {
</div>
</div>
) : (
<p className="text-sm text-text-muted">CLIProxyAPI not detected</p>
<p className="text-sm text-text-muted">{t("cliproxyapiNotDetected")}</p>
)}
</Card>
</div>

View File

@@ -340,7 +340,9 @@ export default function CompressionSettingsTab() {
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">Auto trigger mode</span>
<span className="text-sm text-text-muted">
{t("compressionSettingsAutoTriggerMode")}
</span>
<select
value={config.autoTriggerMode ?? "lite"}
onChange={(e) => save({ autoTriggerMode: e.target.value as CompressionMode })}
@@ -386,7 +388,9 @@ export default function CompressionSettingsTab() {
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">MCP description compression</span>
<span className="text-sm text-text-muted">
{t("compressionSettingsMcpDescriptionCompression")}
</span>
<button
onClick={() =>
save({
@@ -484,7 +488,9 @@ export default function CompressionSettingsTab() {
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">Caveman intensity</span>
<span className="text-sm text-text-muted">
{t("compressionSettingsCavemanIntensity")}
</span>
<select
value={config.cavemanConfig.intensity}
onChange={(e) =>
@@ -556,7 +562,9 @@ export default function CompressionSettingsTab() {
<div className="space-y-3 pt-4 border-t border-border/30">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-text-main">Caveman output mode</h4>
<h4 className="text-sm font-medium text-text-main">
{t("compressionSettingsCavemanOutputMode")}
</h4>
<p className="text-xs text-text-muted mt-0.5">
Injects terse response instructions without rewriting provider output.
</p>
@@ -583,7 +591,9 @@ export default function CompressionSettingsTab() {
</div>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">Output intensity</span>
<span className="text-sm text-text-muted">
{t("compressionSettingsOutputIntensity")}
</span>
<select
value={config.cavemanOutputMode.intensity}
onChange={(e) =>
@@ -603,7 +613,9 @@ export default function CompressionSettingsTab() {
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">Auto clarity bypass</span>
<span className="text-sm text-text-muted">
{t("compressionSettingsAutoClarityBypass")}
</span>
<button
onClick={() =>
save({

View File

@@ -757,7 +757,7 @@ export default function MemorySkillsTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">SkillsMP Marketplace</h3>
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
<p className="text-sm text-text-muted">
Connect to SkillsMP to discover and install skills from the marketplace.
</p>
@@ -769,12 +769,14 @@ export default function MemorySkillsTab() {
</span>
)}
{skillsmpStatus === "error" && (
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
<span className="ml-auto text-xs font-medium text-red-500">
{t("memorySkillsFailedToSave")}
</span>
)}
</div>
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<label className="text-sm font-medium block mb-2">API Key</label>
<label className="text-sm font-medium block mb-2">{t("memorySkillsApiKey")}</label>
<div className="flex gap-2">
<input
type="password"
@@ -807,7 +809,7 @@ export default function MemorySkillsTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Active Skills Provider</h3>
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
<p className="text-sm text-text-muted">
Choose which provider the Skills page uses for search and install.
</p>
@@ -819,7 +821,9 @@ export default function MemorySkillsTab() {
</span>
)}
{skillsProviderStatus === "error" && (
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
<span className="ml-auto text-xs font-medium text-red-500">
{t("memorySkillsFailedToSave")}
</span>
)}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
@@ -20,6 +21,7 @@ function formatRemaining(ms: number): string {
}
export default function ModelCooldownsCard() {
const t = useTranslations("settings");
const notify = useNotificationStore();
const [items, setItems] = useState<CooldownItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -95,7 +97,7 @@ export default function ModelCooldownsCard() {
<Card className="p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-text-main">Models in cooldown</h2>
<h2 className="text-lg font-bold text-text-main">{t("modelCooldownsTitle")}</h2>
<p className="mt-1 text-sm text-text-muted">
Models temporarily isolated after a failure. When the cooldown expires they come back
automatically.
@@ -120,7 +122,7 @@ export default function ModelCooldownsCard() {
{loading ? (
<p className="text-sm text-text-muted">Loading...</p>
) : !hasItems ? (
<p className="text-sm text-text-muted">No models in cooldown right now.</p>
<p className="text-sm text-text-muted">{t("modelCooldownsEmpty")}</p>
) : (
sorted.map((item) => {
const rowKey = `${item.provider}::${item.model}`;

View File

@@ -140,7 +140,7 @@ export default function OneproxyTab() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">1proxy Free Proxy Marketplace</h2>
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
<p className="text-sm text-text-muted mt-1">
Fetch and rotate free validated proxies from the 1proxy community platform
</p>
@@ -173,7 +173,7 @@ export default function OneproxyTab() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-2xl font-bold text-text-main">{stats.total}</div>
<div className="text-sm text-text-muted">Total Proxies</div>
<div className="text-sm text-text-muted">{t("oneproxyTotalProxies")}</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
@@ -183,13 +183,15 @@ export default function OneproxyTab() {
<div className="text-2xl font-bold text-text-main">
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
</div>
<div className="text-sm text-text-muted">Avg Quality</div>
<div className="text-sm text-text-muted">{t("oneproxyAvgQuality")}</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-text-main">
{status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"}
{status?.lastSyncAt
? new Date(status.lastSyncAt).toLocaleTimeString()
: t("oneproxyNever")}
</div>
<div className="text-sm text-text-muted">Last Sync</div>
<div className="text-sm text-text-muted">{t("oneproxyLastSync")}</div>
</Card>
</div>
)}
@@ -201,7 +203,7 @@ export default function OneproxyTab() {
onChange={(e) => setFilterProtocol(e.target.value)}
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border"
>
<option value="">All Protocols</option>
<option value="">{t("oneproxyAllProtocols")}</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
@@ -209,14 +211,14 @@ export default function OneproxyTab() {
</select>
<input
type="text"
placeholder="Country code (e.g. US)"
placeholder={t("oneproxyCountryCodePlaceholder")}
value={filterCountry}
onChange={(e) => setFilterCountry(e.target.value)}
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40"
/>
<input
type="number"
placeholder="Min quality"
placeholder={t("oneproxyMinQualityPlaceholder")}
value={minQuality}
onChange={(e) => setMinQuality(e.target.value)}
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32"
@@ -226,7 +228,7 @@ export default function OneproxyTab() {
<Card className="p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">Loading proxies...</div>
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
) : proxies.length === 0 ? (
<div className="text-center py-8 text-text-muted">
No 1proxy proxies found. Click &quot;Sync Now&quot; to fetch free proxies.
@@ -300,25 +302,27 @@ export default function OneproxyTab() {
{status && (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-main mb-2">Sync Status</h3>
<h3 className="text-sm font-semibold text-text-main mb-2">
{t("oneproxySyncStatusTitle")}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-text-muted">Last sync: </span>
<span className="text-text-muted">{t("oneproxyLastSyncLabel")} </span>
<span className={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
{status.lastSyncSuccess ? "Success" : "Failed"}
{status.lastSyncSuccess ? t("oneproxySuccess") : t("oneproxyFailed")}
</span>
</div>
<div>
<span className="text-text-muted">Proxies fetched: </span>
<span className="text-text-muted">{t("oneproxyProxiesFetched")} </span>
<span className="text-text-main">{status.lastSyncCount}</span>
</div>
<div>
<span className="text-text-muted">Consecutive failures: </span>
<span className="text-text-muted">{t("oneproxyConsecutiveFailures")} </span>
<span className="text-text-main">{status.consecutiveFailures}</span>
</div>
{status.lastSyncError && (
<div className="col-span-full">
<span className="text-text-muted">Error: </span>
<span className="text-text-muted">{t("oneproxyErrorLabel")} </span>
<span className="text-red-600 text-xs">{status.lastSyncError}</span>
</div>
)}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
const EMPTY_PAYLOAD_RULES_TEMPLATE = {
@@ -55,6 +56,7 @@ function getErrorMessage(payload: unknown): string {
}
export default function PayloadRulesTab() {
const t = useTranslations("settings");
const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -162,7 +164,7 @@ export default function PayloadRulesTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Payload Rules</h3>
<h3 className="text-lg font-semibold">{t("payloadRulesTitle")}</h3>
<p className="text-sm text-text-muted mt-1">
Configure request payload mutations by model and protocol. Changes are persisted in
settings and hot reloaded into the runtime immediately after save.

View File

@@ -849,7 +849,7 @@ export default function ProxyRegistryManager() {
onClose={() => {
if (!bulkSaving) setBulkOpen(false);
}}
title="Bulk Proxy Assignment"
title={t("bulkProxyAssignment")}
maxWidth="lg"
>
<div className="flex flex-col gap-3">
@@ -874,7 +874,7 @@ export default function ProxyRegistryManager() {
value={bulkProxyId}
onChange={(e) => setBulkProxyId(e.target.value)}
>
<option value="">(clear assignment)</option>
<option value="">{t("clearAssignment")}</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})

View File

@@ -62,16 +62,17 @@ function SectionDescription({
trigger: string;
effect: string;
}) {
const t = useTranslations("settings");
return (
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
<div>
<span className="font-semibold text-text-main">Scope:</span> {scope}
<span className="font-semibold text-text-main">{t("resilienceScope")}</span> {scope}
</div>
<div>
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
<span className="font-semibold text-text-main">{t("resilienceTrigger")}</span> {trigger}
</div>
<div>
<span className="font-semibold text-text-main">Effect:</span> {effect}
<span className="font-semibold text-text-main">{t("resilienceEffect")}</span> {effect}
</div>
</div>
);
@@ -203,6 +204,7 @@ function RequestQueueCard({
onSave: (next: RequestQueueSettings) => Promise<void>;
saving: boolean;
}) {
const t = useTranslations("settings");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -216,7 +218,7 @@ function RequestQueueCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">speed</span>
<h2 className="text-lg font-bold">Request Queue & Rate</h2>
<h2 className="text-lg font-bold">{t("resilienceRequestQueueTitle")}</h2>
</div>
<SectionDescription
scope="Per request queue"
@@ -248,7 +250,7 @@ function RequestQueueCard({
{editing ? (
<>
<BooleanField
label="Auto-enable for API-key providers"
label={t("resilienceAutoEnableApiKeyProviders")}
description="Enable queue protection by default for active API-key connections."
checked={draft.autoEnableApiKeyProviders}
onChange={(autoEnableApiKeyProviders) =>
@@ -256,13 +258,13 @@ function RequestQueueCard({
}
/>
<NumberField
label="Requests per minute"
label={t("resilienceRequestsPerMinute")}
value={draft.requestsPerMinute}
min={1}
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
/>
<NumberField
label="Minimum time between requests"
label={t("resilienceMinTimeBetweenRequests")}
value={draft.minTimeBetweenRequestsMs}
suffix="ms"
onChange={(minTimeBetweenRequestsMs) =>
@@ -270,7 +272,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Concurrent requests"
label={t("resilienceConcurrentRequests")}
value={draft.concurrentRequests}
min={1}
onChange={(concurrentRequests) =>
@@ -278,7 +280,7 @@ function RequestQueueCard({
}
/>
<NumberField
label="Maximum queue wait time"
label={t("resilienceMaxQueueWaitTime")}
value={draft.maxWaitMs}
min={1}
suffix="ms"
@@ -288,31 +290,33 @@ function RequestQueueCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Auto-enable for API-key providers</div>
<div className="text-xs text-text-muted">
{t("resilienceAutoEnableApiKeyProviders")}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Requests per minute</div>
<div className="text-xs text-text-muted">{t("resilienceRequestsPerMinute")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.requestsPerMinute}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Minimum time between requests</div>
<div className="text-xs text-text-muted">{t("resilienceMinTimeBetweenRequests")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.minTimeBetweenRequestsMs)}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Concurrent requests</div>
<div className="text-xs text-text-muted">{t("resilienceConcurrentRequests")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.concurrentRequests}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Maximum queue wait time</div>
<div className="text-xs text-text-muted">{t("resilienceMaxQueueWaitTime")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)}
</div>
@@ -333,6 +337,7 @@ function ConnectionCooldownCard({
onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise<void>;
saving: boolean;
}) {
const t = useTranslations("settings");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -347,7 +352,7 @@ function ConnectionCooldownCard({
{editing ? (
<>
<NumberField
label="Base cooldown"
label={t("resilienceBaseCooldown")}
value={current.baseCooldownMs}
min={0}
suffix="ms"
@@ -356,7 +361,7 @@ function ConnectionCooldownCard({
}
/>
<BooleanField
label="Use upstream retry hints"
label={t("resilienceUseUpstreamRetryHints")}
description="Use upstream retry-after/reset values when available."
checked={current.useUpstreamRetryHints}
onChange={(useUpstreamRetryHints) =>
@@ -368,7 +373,9 @@ function ConnectionCooldownCard({
/>
<div className="flex flex-col gap-1">
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-text-muted">Use upstream 429 hints for breaker cooldown</span>
<span className="text-text-muted">
{t("resilienceUseUpstream429HintsForBreaker")}
</span>
<select
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
value={
@@ -396,9 +403,9 @@ function ConnectionCooldownCard({
});
}}
>
<option value="default">Default (per provider)</option>
<option value="on">Always on</option>
<option value="off">Always off</option>
<option value="default">{t("resilienceDefaultPerProvider")}</option>
<option value="on">{t("resilienceAlwaysOn")}</option>
<option value="off">{t("resilienceAlwaysOff")}</option>
</select>
</label>
<p className="text-xs text-text-muted">
@@ -409,7 +416,7 @@ function ConnectionCooldownCard({
</p>
</div>
<NumberField
label="Max backoff steps"
label={t("resilienceMaxBackoffSteps")}
value={current.maxBackoffSteps}
min={0}
onChange={(maxBackoffSteps) =>
@@ -420,27 +427,27 @@ function ConnectionCooldownCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Base cooldown</span>
<span className="text-text-muted">{t("resilienceBaseCooldownLabel")}</span>
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Use upstream retry hints</span>
<span className="text-text-muted">{t("resilienceUseUpstreamRetryHintsLabel")}</span>
<span className="font-mono text-text-main">
{current.useUpstreamRetryHints ? "Yes" : "No"}
{current.useUpstreamRetryHints ? t("resilienceYes") : t("resilienceNo")}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
<span className="text-text-muted">{t("resilienceUseUpstream429BreakerLabel")}</span>
<span className="font-mono text-text-main">
{current.useUpstream429BreakerHints === true
? "Yes"
? t("resilienceYes")
: current.useUpstream429BreakerHints === false
? "No"
: "Default"}
? t("resilienceNo")
: t("resilienceDefault")}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Max backoff steps</span>
<span className="text-text-muted">{t("resilienceMaxBackoffStepsLabel")}</span>
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
</div>
</>
@@ -455,7 +462,7 @@ function ConnectionCooldownCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">timer_off</span>
<h2 className="text-lg font-bold">Connection Cooldown</h2>
<h2 className="text-lg font-bold">{t("resilienceConnectionCooldownTitle")}</h2>
</div>
<SectionDescription
scope="Individual connection"
@@ -519,6 +526,7 @@ function ProviderBreakerCard({
onSave: (next: ResilienceResponse["providerBreaker"]) => Promise<void>;
saving: boolean;
}) {
const t = useTranslations("settings");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -533,7 +541,7 @@ function ProviderBreakerCard({
{editing ? (
<>
<NumberField
label="Failure threshold"
label={t("resilienceFailureThreshold")}
value={current.failureThreshold}
min={1}
onChange={(failureThreshold) =>
@@ -541,7 +549,7 @@ function ProviderBreakerCard({
}
/>
<NumberField
label="Reset timeout"
label={t("resilienceResetTimeout")}
value={current.resetTimeoutMs}
min={1000}
suffix="ms"
@@ -553,11 +561,11 @@ function ProviderBreakerCard({
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Failure threshold</span>
<span className="text-text-muted">{t("resilienceFailureThresholdLabel")}</span>
<span className="font-mono text-text-main">{current.failureThreshold}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Reset timeout</span>
<span className="text-text-muted">{t("resilienceResetTimeoutLabel")}</span>
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
</div>
</>
@@ -574,7 +582,7 @@ function ProviderBreakerCard({
<span className="material-symbols-outlined text-xl text-primary">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breaker per Provider</h2>
<h2 className="text-lg font-bold">{t("resilienceProviderBreakerTitle")}</h2>
</div>
<SectionDescription
scope="Entire provider"
@@ -619,6 +627,7 @@ function WaitForCooldownCard({
onSave: (next: WaitForCooldownSettings) => Promise<void>;
saving: boolean;
}) {
const t = useTranslations("settings");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -632,7 +641,7 @@ function WaitForCooldownCard({
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
<h2 className="text-lg font-bold">Wait for Cooldown</h2>
<h2 className="text-lg font-bold">{t("resilienceWaitForCooldown")}</h2>
</div>
<SectionDescription
scope="Current client request"
@@ -663,19 +672,19 @@ function WaitForCooldownCard({
{editing ? (
<>
<BooleanField
label="Enable server-side wait"
label={t("resilienceEnableServerSideWait")}
description="When enabled, OmniRoute waits for the first cooldown to expire and retries automatically."
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
<NumberField
label="Maximum retries"
label={t("resilienceMaximumRetries")}
value={draft.maxRetries}
min={0}
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
/>
<NumberField
label="Maximum wait per retry"
label={t("resilienceMaximumWaitPerRetry")}
value={draft.maxRetryWaitSec}
min={0}
suffix="sec"
@@ -685,17 +694,17 @@ function WaitForCooldownCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Enable server-side wait</div>
<div className="text-xs text-text-muted">{t("resilienceEnableServerSideWait")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? "Enabled" : "Disabled"}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Maximum retries</div>
<div className="text-xs text-text-muted">{t("resilienceMaximumRetries")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">Maximum wait per retry</div>
<div className="text-xs text-text-muted">{t("resilienceMaximumWaitPerRetry")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.maxRetryWaitSec}s
</div>

View File

@@ -303,6 +303,7 @@ function StringListEditor({
onChange: (next: string[]) => void;
disabled?: boolean;
}) {
const t = useTranslations("settings");
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-text-main">{label}</span>
@@ -324,7 +325,7 @@ function StringListEditor({
size="sm"
icon="close"
disabled={disabled}
aria-label="Remove entry"
aria-label={t("routingRemoveEntry")}
onClick={() => {
const next = [...items];
next.splice(idx, 1);
@@ -356,6 +357,7 @@ function OpEditor({
onChange: (next: any) => void;
disabled?: boolean;
}) {
const t = useTranslations("settings");
const updateField = (field: string, value: any) => onChange({ ...op, [field]: value });
const kind = op?.kind as TransformOpKind | undefined;
const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null;
@@ -376,14 +378,14 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<StringListEditor
label="Needles (substrings to match)"
label={t("routingNeedlesSubstrings")}
hint={FIELD_HINTS.needles}
items={op.needles || []}
onChange={(next) => updateField("needles", next)}
disabled={disabled}
/>
<Toggle
label="Case sensitive"
label={t("routingCaseSensitive")}
description={FIELD_HINTS.caseSensitive}
checked={op.caseSensitive !== false}
onChange={(c) => updateField("caseSensitive", c)}
@@ -396,14 +398,14 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<StringListEditor
label="Prefixes"
label={t("routingPrefixes")}
hint={FIELD_HINTS.prefixes}
items={op.prefixes || []}
onChange={(next) => updateField("prefixes", next)}
disabled={disabled}
/>
<Toggle
label="Case sensitive"
label={t("routingCaseSensitive")}
description={FIELD_HINTS.caseSensitive}
checked={op.caseSensitive !== false}
onChange={(c) => updateField("caseSensitive", c)}
@@ -416,21 +418,21 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<Input
label="Match"
label={t("routingMatch")}
hint={FIELD_HINTS.matchLiteral}
value={op.match || ""}
disabled={disabled}
onChange={(e) => updateField("match", e.target.value)}
/>
<Input
label="Replacement"
label={t("routingReplacement")}
hint={FIELD_HINTS.replacementText}
value={op.replacement || ""}
disabled={disabled}
onChange={(e) => updateField("replacement", e.target.value)}
/>
<Toggle
label="Replace all occurrences"
label={t("routingReplaceAllOccurrences")}
description={FIELD_HINTS.allOccurrences}
checked={op.allOccurrences !== false}
onChange={(c) => updateField("allOccurrences", c)}
@@ -443,21 +445,21 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<Input
label="Pattern (regex)"
label={t("routingPatternRegex")}
hint={FIELD_HINTS.pattern}
value={op.pattern || ""}
disabled={disabled}
onChange={(e) => updateField("pattern", e.target.value)}
/>
<Input
label="Flags"
label={t("routingFlags")}
hint={FIELD_HINTS.regexFlags}
value={op.flags || "g"}
disabled={disabled}
onChange={(e) => updateField("flags", e.target.value)}
/>
<Input
label="Replacement"
label={t("routingReplacement")}
hint={FIELD_HINTS.replacementText}
value={op.replacement || ""}
disabled={disabled}
@@ -468,7 +470,7 @@ function OpEditor({
case "drop_block_if_contains":
return wrap(
<StringListEditor
label="Needles"
label={t("routingNeedles")}
hint={FIELD_HINTS.needles}
items={op.needles || []}
onChange={(next) => updateField("needles", next)}
@@ -480,7 +482,7 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-main">Block text</label>
<label className="text-sm font-medium text-text-main">{t("routingBlockText")}</label>
<textarea
rows={3}
value={op.text || ""}
@@ -491,7 +493,7 @@ function OpEditor({
<p className="text-xs text-text-muted">{FIELD_HINTS.blockText}</p>
</div>
<Input
label="Idempotency key"
label={t("routingIdempotencyKey")}
hint={FIELD_HINTS.idempotencyKey}
value={op.idempotencyKey || ""}
disabled={disabled}
@@ -503,14 +505,14 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<Input
label="Entrypoint"
label={t("routingEntrypoint")}
hint={FIELD_HINTS.billingEntrypoint}
value={op.entrypoint || "sdk-cli"}
disabled={disabled}
onChange={(e) => updateField("entrypoint", e.target.value)}
/>
<Select
label="Version format"
label={t("routingVersionFormat")}
hint={FIELD_HINTS.billingVersionFormat}
value={op.versionFormat || "ex-machina"}
disabled={disabled}
@@ -521,7 +523,7 @@ function OpEditor({
]}
/>
<Select
label="CCH algorithm"
label={t("routingCchAlgorithm")}
hint={FIELD_HINTS.billingCchAlgo}
value={op.cchAlgo || "sha256-first-user"}
disabled={disabled}
@@ -538,7 +540,7 @@ function OpEditor({
return wrap(
<div className="flex flex-col gap-2">
<StringListEditor
label="Words to obfuscate (ZWJ inserted after first char)"
label={t("routingWordsToObfuscate")}
hint={FIELD_HINTS.obfuscateWords}
items={op.words || []}
onChange={(next) => updateField("words", next)}
@@ -994,7 +996,7 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Antigravity Signature Cache Mode</h3>
<h3 className="text-lg font-semibold">{t("routingAntigravitySignatureTitle")}</h3>
<p className="text-sm text-text-muted">
Control whether OmniRoute reuses only stored Gemini thought signatures or accepts
validated client-provided signatures in Antigravity-compatible tool-call flows.
@@ -1068,7 +1070,7 @@ export default function RoutingTab() {
</div>
<div className="mb-5">
<h4 className="text-sm font-semibold mb-2">Header fingerprint (per provider)</h4>
<h4 className="text-sm font-semibold mb-2">{t("routingHeaderFingerprintTitle")}</h4>
<p className="text-xs text-text-muted mb-2">
{t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })}
</p>
@@ -1228,7 +1230,7 @@ export default function RoutingTab() {
role="alert"
className="mb-3 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300"
>
<span className="font-medium"> Server rejected save:</span>{" "}
<span className="font-medium">{t("routingServerRejectedSave")}</span>{" "}
<span className="break-words font-mono">{providerSaveErrors[providerId]}</span>
<p className="mt-1 text-[11px] text-red-200/80">
Your local edits are kept. Fix the field above and the next change will
@@ -1299,7 +1301,7 @@ export default function RoutingTab() {
{/* Add op row */}
<div className="flex items-end gap-2 mb-3">
<Select
label="Add a transform op"
label={t("routingAddTransformOp")}
className="flex-1"
value={selectedKind}
onChange={(e) =>
@@ -1398,7 +1400,7 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Client Cache Control</h3>
<h3 className="text-lg font-semibold">{t("routingClientCacheControlTitle")}</h3>
<p className="text-sm text-text-muted">
Configure whether OmniRoute preserves client-provided cache_control markers
</p>
@@ -1466,7 +1468,7 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
<h3 className="text-lg font-semibold">{t("routingZeroConfigTitle")}</h3>
<p className="text-sm text-text-muted mt-1">
Enable automatic provider selection using the auto/ prefix. When enabled, requests
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
@@ -1488,7 +1490,7 @@ export default function RoutingTab() {
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/30">
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
<label className="block text-sm font-medium mb-2">{t("routingDefaultAutoVariant")}</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },

View File

@@ -551,7 +551,7 @@ export default function SystemStorageTab() {
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium text-text-main">Logs Settings</p>
<p className="text-sm font-medium text-text-main">{t("logsSettingsTitle")}</p>
<p className="text-xs text-text-muted">
Configure detailed logging and call log pipeline settings
</p>
@@ -560,26 +560,26 @@ export default function SystemStorageTab() {
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Detailed Logs Enabled</span>
<p className="text-xs text-text-muted">Enable detailed request/response logging</p>
<span className="font-medium">{t("detailedLogsLabel")}</span>
<p className="text-xs text-text-muted">{t("detailedLogsDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Call Log Pipeline</span>
<p className="text-xs text-text-muted">Enable call log processing pipeline</p>
<span className="font-medium">{t("callLogPipelineLabel")}</span>
<p className="text-xs text-text-muted">{t("callLogPipelineDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Max Detail Size (KB)</span>
<p className="text-xs text-text-muted">Maximum size for detailed log entries</p>
<span className="font-medium">{t("maxDetailSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("maxDetailSizeDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Ring Buffer Size</span>
<p className="text-xs text-text-muted">Size of the ring buffer for logs</p>
<span className="font-medium">{t("ringBufferSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("ringBufferSizeDesc")}</p>
</label>
</div>
</div>
@@ -589,7 +589,7 @@ export default function SystemStorageTab() {
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium text-text-main">Cache Settings</p>
<p className="text-sm font-medium text-text-main">{t("cacheSettings")}</p>
<p className="text-xs text-text-muted">
Configure semantic and prompt caching behavior
</p>
@@ -598,7 +598,7 @@ export default function SystemStorageTab() {
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Semantic Cache Enabled</span>
<span className="font-medium">{t("semanticCacheEnabledLabel")}</span>
<p className="text-xs text-text-muted">
Enable semantic caching for similar requests
</p>
@@ -606,13 +606,13 @@ export default function SystemStorageTab() {
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Semantic Cache Max Size</span>
<p className="text-xs text-text-muted">Maximum number of semantic cache entries</p>
<span className="font-medium">{t("semanticCacheMaxSizeLabel")}</span>
<p className="text-xs text-text-muted">{t("semanticCacheMaxSizeDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Semantic Cache TTL</span>
<span className="font-medium">{t("semanticCacheTTLLabel")}</span>
<p className="text-xs text-text-muted">
Time-to-live for semantic cache entries (ms)
</p>
@@ -620,20 +620,20 @@ export default function SystemStorageTab() {
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Prompt Cache Enabled</span>
<p className="text-xs text-text-muted">Enable prompt caching</p>
<span className="font-medium">{t("promptCacheEnabledLabel")}</span>
<p className="text-xs text-text-muted">{t("promptCacheEnabledDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Prompt Cache Strategy</span>
<p className="text-xs text-text-muted">Strategy for prompt caching</p>
<span className="font-medium">{t("promptCacheStrategyLabel")}</span>
<p className="text-xs text-text-muted">{t("promptCacheStrategyDesc")}</p>
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm">
<span className="font-medium">Always Preserve Client Cache</span>
<p className="text-xs text-text-muted">Client cache preservation policy</p>
<span className="font-medium">{t("alwaysPreserveClientCacheLabel")}</span>
<p className="text-xs text-text-muted">{t("alwaysPreserveClientCacheDesc")}</p>
</label>
</div>
</div>
@@ -642,7 +642,7 @@ export default function SystemStorageTab() {
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium text-text-main">Log retention policy</p>
<p className="text-sm font-medium text-text-main">{t("logRetentionPolicyTitle")}</p>
<p className="text-xs text-text-muted">
Request logs retain up to <code>CALL_LOGS_TABLE_MAX_ROWS</code> rows (default:
100,000). Proxy logs retain up to <code>PROXY_LOGS_TABLE_MAX_ROWS</code> rows. Older
@@ -666,7 +666,9 @@ export default function SystemStorageTab() {
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap mb-3">
<div>
<p className="text-sm font-medium text-text-main">Database backup retention</p>
<p className="text-sm font-medium text-text-main">
{t("storageDatabaseBackupRetention")}
</p>
<p className="text-xs text-text-muted">
Automatic SQLite backups are stored in <code>db_backups</code>. Configure how many
snapshots to keep and optionally delete backups older than N days.
@@ -1050,7 +1052,7 @@ export default function SystemStorageTab() {
>
delete_forever
</span>
<p className="font-medium">Purge Data</p>
<p className="font-medium">{t("storagePurgeData")}</p>
</div>
<p className="text-xs text-text-muted">
Immediately delete all records (no retention check). Use with caution.
@@ -1389,7 +1391,9 @@ export default function SystemStorageTab() {
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted mb-1">Quota Snapshots (days)</label>
<label className="block text-xs text-text-muted mb-1">
{t("retentionQuotaSnapshots")}
</label>
<input
type="number"
min="1"
@@ -1429,7 +1433,7 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">MCP Audit (days)</label>
<label className="block text-xs text-text-muted mb-1">{t("retentionMcpAudit")}</label>
<input
type="number"
min="1"
@@ -1448,7 +1452,9 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">A2A Events (days)</label>
<label className="block text-xs text-text-muted mb-1">
{t("retentionA2aEvents")}
</label>
<input
type="number"
min="1"
@@ -1467,7 +1473,7 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Call Logs (days)</label>
<label className="block text-xs text-text-muted mb-1">{t("retentionCallLogs")}</label>
<input
type="number"
min="1"
@@ -1486,7 +1492,9 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Usage History (days)</label>
<label className="block text-xs text-text-muted mb-1">
{t("retentionUsageHistory")}
</label>
<input
type="number"
min="1"
@@ -1505,7 +1513,9 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Memory Entries (days)</label>
<label className="block text-xs text-text-muted mb-1">
{t("retentionMemoryEntries")}
</label>
<input
type="number"
min="1"
@@ -1633,7 +1643,9 @@ export default function SystemStorageTab() {
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted mb-1">Auto Vacuum Mode</label>
<label className="block text-xs text-text-muted mb-1">
{t("storageAutoVacuumMode")}
</label>
<select
value={dbSettings.optimization.autoVacuumMode}
onChange={(e) =>
@@ -1653,7 +1665,9 @@ export default function SystemStorageTab() {
</select>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Scheduled Vacuum</label>
<label className="block text-xs text-text-muted mb-1">
{t("storageScheduledVacuum")}
</label>
<select
value={dbSettings.optimization.scheduledVacuum}
onChange={(e) =>
@@ -1674,7 +1688,9 @@ export default function SystemStorageTab() {
</select>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Vacuum Hour (0-23)</label>
<label className="block text-xs text-text-muted mb-1">
{t("storageVacuumHour")}
</label>
<input
type="number"
min="0"
@@ -1693,7 +1709,7 @@ export default function SystemStorageTab() {
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Page Size (bytes)</label>
<label className="block text-xs text-text-muted mb-1">{t("storagePageSize")}</label>
<input
type="number"
min="512"
@@ -1790,23 +1806,23 @@ export default function SystemStorageTab() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Database Size</p>
<p className="text-xs text-text-muted mb-1">{t("storageDatabaseSize")}</p>
<p className="text-sm font-semibold">
{formatBytes(dbSettings.stats.databaseSizeBytes)}
</p>
</div>
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Page Count</p>
<p className="text-xs text-text-muted mb-1">{t("storagePageCount")}</p>
<p className="text-sm font-semibold">{dbSettings.stats.pageCount.toLocaleString()}</p>
</div>
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Freelist Count</p>
<p className="text-xs text-text-muted mb-1">{t("storageFreelistCount")}</p>
<p className="text-sm font-semibold">
{dbSettings.stats.freelistCount.toLocaleString()}
</p>
</div>
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Last Vacuum</p>
<p className="text-xs text-text-muted mb-1">{t("storageLastVacuum")}</p>
<p className="text-sm font-semibold">
{dbSettings.stats.lastVacuumAt
? new Date(dbSettings.stats.lastVacuumAt).toLocaleString(locale)
@@ -1814,7 +1830,7 @@ export default function SystemStorageTab() {
</p>
</div>
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Last Optimization</p>
<p className="text-xs text-text-muted mb-1">{t("storageLastOptimization")}</p>
<p className="text-sm font-semibold">
{dbSettings.stats.lastOptimizationAt
? new Date(dbSettings.stats.lastOptimizationAt).toLocaleString(locale)
@@ -1822,12 +1838,12 @@ export default function SystemStorageTab() {
</p>
</div>
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<p className="text-xs text-text-muted mb-1">Integrity Check</p>
<p className="text-xs text-text-muted mb-1">{t("storageIntegrityCheck")}</p>
<p className="text-sm font-semibold">
{dbSettings.stats.integrityCheck === "ok" ? (
<span className="text-green-500"> OK</span>
<span className="text-green-500">{t("storageIntegrityOk")}</span>
) : dbSettings.stats.integrityCheck === "error" ? (
<span className="text-red-500"> Error</span>
<span className="text-red-500">{t("storageIntegrityError")}</span>
) : (
"Not checked"
)}
@@ -1866,7 +1882,7 @@ export default function SystemStorageTab() {
pin
</span>
<div>
<p className="font-medium">Usage Token Buffer</p>
<p className="font-medium">{t("storageUsageTokenBuffer")}</p>
<p className="text-sm text-text-muted mt-1">
Extra tokens added to reported usage to account for system prompt overhead. Set to 0
to report raw provider token counts. Default: 2000.

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
@@ -13,6 +14,7 @@ type SettingsState = {
};
export default function VisionBridgeSettingsTab() {
const t = useTranslations("settings");
const [settings, setSettings] = useState<SettingsState>({
visionBridgeEnabled: VISION_BRIDGE_DEFAULTS.enabled,
visionBridgeModel: VISION_BRIDGE_DEFAULTS.model,
@@ -63,7 +65,7 @@ export default function VisionBridgeSettingsTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Vision Bridge</h3>
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
<p className="text-sm text-text-muted">
Run an automatic vision-to-text fallback before routing image requests to text-only
models.
@@ -88,7 +90,7 @@ export default function VisionBridgeSettingsTab() {
<div className="pt-4 border-t border-border space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Bridge Model</label>
<label className="block text-sm font-medium mb-1">{t("visionBridgeModel")}</label>
<input
type="text"
value={settings.visionBridgeModel}
@@ -97,7 +99,7 @@ export default function VisionBridgeSettingsTab() {
}
onBlur={() => updateSetting({ visionBridgeModel: settings.visionBridgeModel.trim() })}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder="openai/gpt-4o-mini"
placeholder={t("visionBridgeModelPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">
Any OmniRoute model ID that supports vision can be used here.
@@ -105,7 +107,7 @@ export default function VisionBridgeSettingsTab() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Bridge Prompt</label>
<label className="block text-sm font-medium mb-1">{t("visionBridgePrompt")}</label>
<textarea
value={settings.visionBridgePrompt}
onChange={(e) =>
@@ -115,7 +117,7 @@ export default function VisionBridgeSettingsTab() {
updateSetting({ visionBridgePrompt: settings.visionBridgePrompt.trim() })
}
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder="Describe this image concisely."
placeholder={t("visionBridgePromptPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">
Sent to the vision model before the extracted description is injected back into the
@@ -125,7 +127,7 @@ export default function VisionBridgeSettingsTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Timeout (ms)</label>
<label className="block text-sm font-medium mb-1">{t("visionBridgeTimeoutMs")}</label>
<input
type="number"
min={1000}
@@ -152,7 +154,9 @@ export default function VisionBridgeSettingsTab() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Max Images Per Request</label>
<label className="block text-sm font-medium mb-1">
{t("visionBridgeMaxImagesPerRequest")}
</label>
<input
type="number"
min={1}

View File

@@ -372,7 +372,7 @@ export default function SkillsPage() {
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Filter skills by name, description, or tag"
placeholder={t("filterSkillsPlaceholder")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<select
@@ -380,7 +380,7 @@ export default function SkillsPage() {
onChange={(e) => setModeFilter(e.target.value as "all" | "on" | "off" | "auto")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="all">All modes</option>
<option value="all">{t("allModes")}</option>
<option value="on">On</option>
<option value="auto">Auto</option>
<option value="off">Off</option>
@@ -659,7 +659,7 @@ export default function SkillsPage() {
{activeTab === "marketplace" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-2">Skills Marketplace</h3>
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
<p className="text-sm text-text-muted mb-4">
Active provider:{" "}
<span className="font-medium">
@@ -775,7 +775,7 @@ export default function SkillsPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Install Skill</h2>
<h2 className="text-lg font-semibold">{t("installSkill")}</h2>
<button
onClick={() => {
setShowInstallModal(false);

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
interface TokenLedgerEntry {
@@ -34,6 +35,7 @@ interface ServerConnection {
}
export default function TokensPage() {
const t = useTranslations("common");
// Balance & History
const [balance, setBalance] = useState(0);
const [history, setHistory] = useState<TokenLedgerEntry[]>([]);
@@ -239,7 +241,7 @@ export default function TokensPage() {
<Card>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-muted">Token Balance</p>
<p className="text-sm text-text-muted">{t("tokensTokenBalance")}</p>
<p className="text-4xl font-bold mt-1">{balance.toLocaleString()}</p>
</div>
<div className="text-6xl opacity-20">🪙</div>
@@ -247,15 +249,17 @@ export default function TokensPage() {
</Card>
{/* Transfer Form */}
<Card title="Send Tokens" icon="send">
<Card title={t("tokensSendTokens")} icon="send">
<form onSubmit={handleTransfer} className="grid gap-4">
<div>
<label className="block text-sm text-text-muted mb-1">Recipient API Key ID</label>
<label className="block text-sm text-text-muted mb-1">
{t("tokensRecipientApiKeyId")}
</label>
<input
type="text"
value={toApiKeyId}
onChange={(e) => setToApiKeyId(e.target.value)}
placeholder="Enter recipient API key ID"
placeholder={t("tokensRecipientApiKeyIdPlaceholder")}
required
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
@@ -274,12 +278,14 @@ export default function TokensPage() {
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Reason (optional)</label>
<label className="block text-sm text-text-muted mb-1">
{t("tokensReasonOptional")}
</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. bonus, reward"
placeholder={t("tokensReasonPlaceholder")}
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
</div>
@@ -306,11 +312,11 @@ export default function TokensPage() {
</Card>
{/* Transaction History */}
<Card title="Transaction History" icon="history">
<Card title={t("tokensTransactionHistory")} icon="history">
{historyLoading ? (
<div className="text-center py-8 text-text-muted">Loading...</div>
) : history.length === 0 ? (
<div className="text-center py-8 text-text-muted">No transactions yet</div>
<div className="text-center py-8 text-text-muted">{t("tokensNoTransactionsYet")}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
@@ -364,11 +370,11 @@ export default function TokensPage() {
</Card>
{/* Invite Panel */}
<Card title="Invite Codes" icon="mail">
<Card title={t("tokensInviteCodes")} icon="mail">
<div className="flex flex-col gap-4">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-sm text-text-muted mb-1">Max Uses</label>
<label className="block text-sm text-text-muted mb-1">{t("tokensMaxUses")}</label>
<input
type="number"
value={inviteMaxUses}
@@ -398,12 +404,12 @@ export default function TokensPage() {
className="flex items-end gap-3 border-t border-border pt-4"
>
<div className="flex-1">
<label className="block text-sm text-text-muted mb-1">Redeem Code</label>
<label className="block text-sm text-text-muted mb-1">{t("tokensRedeemCode")}</label>
<input
type="text"
value={redeemCode}
onChange={(e) => setRedeemCode(e.target.value)}
placeholder="Enter invite code"
placeholder={t("tokensRedeemCodePlaceholder")}
required
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
@@ -432,7 +438,7 @@ export default function TokensPage() {
{/* Active Invites */}
{invites.length > 0 && (
<div className="border-t border-border pt-4">
<p className="text-sm text-text-muted mb-3">Your Active Invites</p>
<p className="text-sm text-text-muted mb-3">{t("tokensYourActiveInvites")}</p>
<div className="space-y-2">
{invites.map((inv) => (
<div
@@ -463,7 +469,7 @@ export default function TokensPage() {
</Card>
{/* Server Panel */}
<Card title="Community Servers" icon="dns">
<Card title={t("tokensCommunityServers")} icon="dns">
<div className="flex flex-col gap-4">
<form onSubmit={handleConnectServer} className="grid gap-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
@@ -471,7 +477,7 @@ export default function TokensPage() {
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder="Server name"
placeholder={t("tokensServerNamePlaceholder")}
required
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
@@ -487,7 +493,7 @@ export default function TokensPage() {
type="password"
value={serverApiKey}
onChange={(e) => setServerApiKey(e.target.value)}
placeholder="API key"
placeholder={t("tokensApiKeyPlaceholder")}
required
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>

View File

@@ -573,7 +573,7 @@ export default function PlaygroundMode() {
</div>
{compressionResult.techniquesUsed.length > 0 && (
<div className="text-xs text-text-muted">
<span className="font-semibold">Techniques:</span>{" "}
<span className="font-semibold">{t("techniques")}</span>{" "}
{compressionResult.techniquesUsed.join(", ")}
</div>
)}

View File

@@ -461,26 +461,29 @@ export default function BudgetTab() {
</div>
<div className="grid grid-cols-2 lg:grid-cols-6 gap-2">
<KpiBlock label="Today" value={formatCurrency(stats.today)} />
<KpiBlock label="This month" value={formatCurrency(stats.month)} />
<KpiBlock label={t("budgetKpiToday")} value={formatCurrency(stats.today)} />
<KpiBlock label={t("budgetKpiThisMonth")} value={formatCurrency(stats.month)} />
<KpiBlock
label="Proj EOM"
label={t("budgetKpiProjEom")}
value={formatCurrency(stats.projectionEom)}
tone={projectionOverBudget ? "amber" : undefined}
hint={projectionOverBudget ? "above limit ⚠" : "on track"}
/>
<KpiBlock
label="Blocked"
label={t("budgetKpiBlocked")}
value={String(stats.counts.blocked)}
tone={stats.counts.blocked > 0 ? "red" : undefined}
/>
<KpiBlock
label="At risk"
label={t("budgetKpiAtRisk")}
value={String(stats.counts.alerting)}
tone={stats.counts.alerting > 0 ? "amber" : undefined}
hint="≥ warning"
/>
<KpiBlock label="Active keys" value={`${stats.counts.active} / ${rows.length}`} />
<KpiBlock
label={t("budgetKpiActiveKeys")}
value={`${stats.counts.active} / ${rows.length}`}
/>
</div>
</Card>
@@ -493,7 +496,7 @@ export default function BudgetTab() {
</span>
<input
type="text"
placeholder="Search keys..."
placeholder={t("budgetSearchKeysPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
@@ -504,10 +507,10 @@ export default function BudgetTab() {
onChange={(e) => setSortKey(e.target.value as typeof sortKey)}
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer"
>
<option value="usedDesc">Sort: % Used </option>
<option value="todayDesc">Sort: Today $ </option>
<option value="monthDesc">Sort: Month $ </option>
<option value="name">Sort: Name (AZ)</option>
<option value="usedDesc">{t("budgetSortPctUsed")}</option>
<option value="todayDesc">{t("budgetSortTodayDollar")}</option>
<option value="monthDesc">{t("budgetSortMonthDollar")}</option>
<option value="name">{t("budgetSortNameAZ")}</option>
</select>
</div>
@@ -601,14 +604,14 @@ export default function BudgetTab() {
<div>Key</div>
<div className="text-right">Today</div>
<div className="text-right">Month</div>
<div className="text-right">Daily lim</div>
<div className="text-right">Monthly lim</div>
<div className="text-right">Used %</div>
<div className="text-right">{t("budgetColDailyLim")}</div>
<div className="text-right">{t("budgetColMonthlyLim")}</div>
<div className="text-right">{t("budgetColUsedPct")}</div>
<div className="text-center">Status</div>
</div>
{visibleRows.length === 0 ? (
<div className="py-10 text-center text-text-muted text-sm">No keys match filters</div>
<div className="py-10 text-center text-text-muted text-sm">{t("budgetNoKeysMatch")}</div>
) : (
visibleRows.map((row, idx) => (
<BudgetRow
@@ -853,16 +856,16 @@ function BudgetRowExpanded({
<h4 className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
Projection
</h4>
<span className="text-[10px] text-text-muted">linear extrapolation</span>
<span className="text-[10px] text-text-muted">{t("budgetLinearExtrapolation")}</span>
</div>
<div className="flex items-baseline gap-3">
<div>
<div className="text-[10px] text-text-muted">This month so far</div>
<div className="text-[10px] text-text-muted">{t("budgetThisMonthSoFar")}</div>
<div className="text-lg font-bold tabular-nums">{formatCurrency(month)}</div>
</div>
<span className="text-text-muted"></span>
<div>
<div className="text-[10px] text-text-muted">Projected end of month</div>
<div className="text-[10px] text-text-muted">{t("budgetProjectedEndOfMonth")}</div>
<div
className={`text-lg font-bold tabular-nums ${
projectionOver ? "text-amber-400" : "text-emerald-400"
@@ -882,12 +885,14 @@ function BudgetRowExpanded({
<h4 className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
Cost breakdown (30d)
</h4>
<span className="text-[10px] text-text-muted">by provider</span>
<span className="text-[10px] text-text-muted">{t("budgetByProvider")}</span>
</div>
{breakdown === undefined ? (
<div className="text-[11px] text-text-muted py-2 animate-pulse">Loading</div>
<div className="text-[11px] text-text-muted py-2 animate-pulse">
{t("budgetLoading")}
</div>
) : breakdown.length === 0 ? (
<div className="text-[11px] text-text-muted py-2">No spend in last 30 days</div>
<div className="text-[11px] text-text-muted py-2">{t("noSpendLast30Days")}</div>
) : (
<div className="space-y-1.5">
{breakdown.slice(0, 5).map((b) => (
@@ -918,7 +923,7 @@ function BudgetRowExpanded({
</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<Input
label="Daily $"
label={t("budgetDailyDollar")}
type="number"
step="0.01"
min="0"
@@ -927,7 +932,7 @@ function BudgetRowExpanded({
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
/>
<Input
label="Weekly $"
label={t("budgetWeeklyDollar")}
type="number"
step="0.01"
min="0"
@@ -936,7 +941,7 @@ function BudgetRowExpanded({
onChange={(e) => setForm({ ...form, weeklyLimitUsd: e.target.value })}
/>
<Input
label="Monthly $"
label={t("budgetMonthlyDollar")}
type="number"
step="0.01"
min="0"
@@ -945,7 +950,7 @@ function BudgetRowExpanded({
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
/>
<Input
label="Warn at %"
label={t("budgetWarnAtPct")}
type="number"
min="1"
max="100"
@@ -955,7 +960,7 @@ function BudgetRowExpanded({
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<div>
<label className="text-[11px] text-text-muted block mb-1">Reset interval</label>
<label className="text-[11px] text-text-muted block mb-1">{t("resetInterval")}</label>
<select
value={form.resetInterval}
onChange={(e) =>
@@ -969,7 +974,7 @@ function BudgetRowExpanded({
</select>
</div>
<Input
label="Reset time (UTC)"
label={t("resetTimeUtc")}
type="time"
value={form.resetTime}
onChange={(e) => setForm({ ...form, resetTime: e.target.value })}

Some files were not shown because too many files have changed in this diff Show More