Compare commits

...

28 Commits

Author SHA1 Message Date
Antigravity Assistant
f9e1e7f296 chore(release): attribute community contributors
Co-authored-by: rdself <rdself@users.noreply.github.com>
Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: clousky2020 <clousky2020@users.noreply.github.com>
Co-authored-by: benzntech <benzntech@users.noreply.github.com>
Co-authored-by: kang-heewon <kang-heewon@users.noreply.github.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: backryun <backryun@users.noreply.github.com>
Co-authored-by: tombii <tombii@users.noreply.github.com>
Co-authored-by: christopher-s <christopher-s@users.noreply.github.com>
Co-authored-by: zen0bit <zen0bit@users.noreply.github.com>
Co-authored-by: k0valik <k0valik@users.noreply.github.com>
Co-authored-by: zhangqiang8vip <zhangqiang8vip@users.noreply.github.com>
Co-authored-by: wlfonseca <wlfonseca@users.noreply.github.com>
Co-authored-by: RaviTharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: prakersh <prakersh@users.noreply.github.com>
Co-authored-by: payne0420 <payne0420@users.noreply.github.com>
Co-authored-by: only4copilot <only4copilot@users.noreply.github.com>
Co-authored-by: jay77721 <jay77721@users.noreply.github.com>
Co-authored-by: hijak <hijak@users.noreply.github.com>
Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: defhouse <defhouse@users.noreply.github.com>
Co-authored-by: xiaoge1688 <xiaoge1688@users.noreply.github.com>
Co-authored-by: xandr0s <xandr0s@users.noreply.github.com>
Co-authored-by: willbnu <willbnu@users.noreply.github.com>
Co-authored-by: slewis3600 <slewis3600@users.noreply.github.com>
Co-authored-by: sergey-v9 <sergey-v9@users.noreply.github.com>
Co-authored-by: razllivan <razllivan@users.noreply.github.com>
Co-authored-by: nmime <nmime@users.noreply.github.com>
Co-authored-by: Moutia-Ben-Yahia <Moutia-Ben-Yahia@users.noreply.github.com>
Co-authored-by: Mind-Dragon <Mind-Dragon@users.noreply.github.com>
Co-authored-by: mercs2910 <mercs2910@users.noreply.github.com>
Co-authored-by: MAINER4IK <MAINER4IK@users.noreply.github.com>
Co-authored-by: luandiasrj <luandiasrj@users.noreply.github.com>
Co-authored-by: knopki <knopki@users.noreply.github.com>
Co-authored-by: kfiramar <kfiramar@users.noreply.github.com>
Co-authored-by: ken2190 <ken2190@users.noreply.github.com>
Co-authored-by: keith8496 <keith8496@users.noreply.github.com>
Co-authored-by: jonesfernandess <jonesfernandess@users.noreply.github.com>
Co-authored-by: JasonLandbridge <JasonLandbridge@users.noreply.github.com>
Co-authored-by: i1hwan <i1hwan@users.noreply.github.com>
Co-authored-by: Gorchakov-Pressure <Gorchakov-Pressure@users.noreply.github.com>
Co-authored-by: foxy1402 <foxy1402@users.noreply.github.com>
Co-authored-by: dt418 <dt418@users.noreply.github.com>
Co-authored-by: dhaern <dhaern@users.noreply.github.com>
Co-authored-by: DavyMassoneto <DavyMassoneto@users.noreply.github.com>
Co-authored-by: dail45 <dail45@users.noreply.github.com>
Co-authored-by: congvc-dev <congvc-dev@users.noreply.github.com>
Co-authored-by: be0hhh <be0hhh@users.noreply.github.com>
Co-authored-by: andruwa13 <andruwa13@users.noreply.github.com>
Co-authored-by: AndrewDragonIV <AndrewDragonIV@users.noreply.github.com>
Co-authored-by: AndersonFirmino <AndersonFirmino@users.noreply.github.com>
Co-authored-by: alexsvdk <alexsvdk@users.noreply.github.com>
Co-authored-by: abhinavjnu <abhinavjnu@users.noreply.github.com>
2026-04-30 14:17:29 -03:00
Antigravity Assistant
12b29ba11d docs(changelog): update for analytics and mitm features 2026-04-30 14:06:07 -03:00
Antigravity Assistant
b5ae2af150 fix(mitm): enforce transparent interception on port 443 only
Reject non-443 MITM port updates in the settings API and normalize
stored configuration back to the required transparent interception
port.

Lock the dashboard port field to 443, update the validation copy, and
add integration coverage to prevent stale custom ports from being
accepted or surfaced.
2026-04-30 14:03:15 -03:00
Antigravity Assistant
9d9d1774c7 feat(analytics): add cost-based usage insights and activity streaks
Expand usage analytics to report total cost, per-series cost totals,
API key counts, and current activity streaks using pricing-aware token
calculations.

Also make probe-failed database recovery choose the newest backup by
its embedded timestamp instead of filesystem mtime so auto-restore
selects the intended snapshot reliably.
2026-04-30 13:47:23 -03:00
Antigravity Assistant
486c370eed docs(changelog): rewrite v3.7.6 with complete commit-accurate entries 2026-04-30 13:10:18 -03:00
Antigravity Assistant
6a2fdac7b4 chore(workflow): mandate implementation plan generation in resolve-issues 2026-04-30 13:04:48 -03:00
Antigravity Assistant
1d067f3c19 test: update expected db probe-failure error message for auto-restore feature 2026-04-30 12:59:43 -03:00
Antigravity Assistant
3207a84ade chore(release): v3.7.6 — final stability patches for production 2026-04-30 12:57:05 -03:00
Antigravity Assistant
5cb9cde5be fix: safely cast inputs to strings before calling trim() to avoid crashes on numeric fields in proxy modal (#1825) 2026-04-30 12:54:59 -03:00
Antigravity Assistant
c61ae8de67 feat: auto-restore probe-failed database to prevent data loss (#1810) 2026-04-30 12:53:28 -03:00
Antigravity Assistant
b2572acdbf feat: inject fallback tool names to prevent upstream 400 errors (#1775) 2026-04-30 12:52:26 -03:00
ipanghu
19df6472c9 fix: clear active requests and recover providers (#1824)
Integrated into release/v3.7.6
2026-04-30 12:27:47 -03:00
Antigravity Assistant
285a565691 docs(changelog): update for stability bug fixes #1804 #1805 2026-04-30 12:16:56 -03:00
Antigravity Assistant
09b5dee89e fix(stability): resolve codex input validation, enable combo circuit breaker, and fix broken unit tests 2026-04-30 12:16:14 -03:00
Paijo
950fdba44c fix(analytics): use pure SQL aggregations — no history rows loaded (#1802)
Integrated into release/v3.7.6
2026-04-30 11:47:32 -03:00
Randi
2bdc72a3ea Fix endpoint visibility, A2A status, and API catalog (#1806)
Integrated into release/v3.7.6
2026-04-30 11:46:19 -03:00
payne
ef622834db feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821)
Integrated into release/v3.7.6
2026-04-30 11:45:27 -03:00
Sergey Morozov
bc11f634ff fix(codex): omit compact client metadata (#1822)
Integrated into release/v3.7.6
2026-04-30 11:45:20 -03:00
Antigravity Assistant
42daf741c5 fix(dashboard): correct loadPresets ReferenceError in CostOverviewTab 2026-04-30 11:43:28 -03:00
Gi99lin
dff22ade64 fix(xiaomi-mimo): update models to V2.5, fix Token Plan validation and default region (#1823)
Integrated into release/v3.7.6
2026-04-30 11:43:12 -03:00
Antigravity Assistant
832c9124b6 feat(dashboard): implement remaining v3.7.6 dashboard features and fixes 2026-04-30 10:39:22 -03:00
Antigravity Assistant
fc7f71def5 fix: resolve security ReDoS in codex and bugs #1797 #1789 2026-04-30 02:54:49 -03:00
Antigravity Assistant
c7df426496 chore(release): expand contributor credits to 155 PRs across full project history
- Expanded acknowledgment table from 29 to 53 contributors
- Added 100+ previously uncredited PRs from project inception through v3.7.5
- Moved contributor credits section to v3.7.6 (current release)
- Synced llm.txt version to 3.7.6
2026-04-30 02:43:51 -03:00
Antigravity Assistant
44d4cb88ee chore(release): v3.7.6 — merge API key rename feature and sync docs 2026-04-30 02:15:08 -03:00
Antigravity Assistant
00926cdd9b Merge pull request #1796 from emdash/rename-api-keys-45un3 2026-04-30 02:09:53 -03:00
Antigravity Assistant
f4bd7f99e3 Merge release/v3.7.6 into emdash/rename-api-keys-45un3 2026-04-30 02:04:06 -03:00
Antigravity Assistant
76d295800f chore(release): bump version to 3.7.6 2026-04-30 01:59:39 -03:00
Andrew Munsell
c86c43f78c feat(api-keys): add rename support in permissions modal
Add an editable key name field at the top of the permissions modal,
allowing users to rename API keys alongside existing permission settings.

The backend already supported name updates via PATCH /api/keys/:id — this
wires the UI to send the name field and refreshes the key list on success.

Changes:
- Add keyName state and text input to PermissionsModal
- Update handleUpdatePermissions to validate and send name in PATCH body
- Add integration test for rename via PATCH (valid, empty, too-long names)
- Update E2E mock to handle PATCH requests
2026-04-29 17:30:36 -07:00
176 changed files with 14149 additions and 2928 deletions

View File

@@ -116,7 +116,8 @@ Before coding, perform deep source analysis to formulate a plan:
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
6. **DO NOT modify the codebase yet** — wait for user approval on your report first.
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
#### 5e. For "RESPOND" Issues

View File

@@ -15,7 +15,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: fix/claude-thinking-mapping
ref: fix/xiaomi-mimo-provider
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -35,7 +35,7 @@ jobs:
platforms: linux/amd64
push: true
tags: |
ghcr.io/inkorcloud/omniroute:fix-claude-thinking-mapping
ghcr.io/inkorcloud/omniroute:latest
ghcr.io/gi99lin/omniroute:fix-xiaomi-mimo-provider
ghcr.io/gi99lin/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored
View File

@@ -75,6 +75,7 @@ docs/*
!docs/CONTRIBUTING.md
!docs/USER_GUIDE.md
!docs/API_REFERENCE.md
!docs/TERMUX_GUIDE.md
!docs/TROUBLESHOOTING.md
!docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md

31
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "${env:HOME}/.nvm/versions/node/v22.22.2/bin/node",
"program": "${workspaceFolder}/scripts/run-next.mjs",
"args": ["dev"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"]
},
{
"name": "Debug Prod Server",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/scripts/run-next.mjs",
"args": ["start"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"]
},
{
"name": "Attach to Running Server",
"type": "node",
"request": "attach",
"port": 9229,
"skipFiles": ["<node_internals>/**", "node_modules/**"]
}
]
}

View File

@@ -2,9 +2,101 @@
## [Unreleased]
---
## [3.7.6] — 2026-04-30
### ✨ New Features
- **feat:** ongoing development
- **feat(api-keys):** add rename support in the permissions modal — editable key name field with validation (#1796)
- **feat(chatgpt-web):** support `thinking_effort` parameter (Standard/Extended) for thinking-capable models (#1821)
- **feat(dashboard):** implement remaining v3.7.6 dashboard features — Costs overview, Translator pipeline, and Endpoint tabs improvements
- **feat(tools):** inject fallback tool names to prevent upstream 400 errors on providers that require tool names (#1775)
- **feat(db):** auto-restore probe-failed database on startup to prevent data loss after failed upgrades (#1810)
- **feat(analytics):** add cost-based usage insights and activity streaks in the analytics dashboard
### 🔒 Security
- **fix(security):** resolve ReDoS vulnerability in Codex executor regex patterns (#1797, #1789)
### 🐛 Bug Fixes
- **fix(stability):** resolve codex input validation, enable combo circuit breaker, and fix broken unit tests (#1804, #1805)
- **fix(stability):** safely cast inputs to strings before calling `.trim()` to avoid crashes on numeric fields in proxy modal (#1825)
- **fix(stability):** clear active requests and recover providers after connection failures (#1824)
- **fix(xiaomi-mimo):** update models to V2.5, fix Token Plan validation and default region (#1823)
- **fix(codex):** omit compact client metadata to prevent upstream rejections (#1822)
- **fix(dashboard):** fix endpoint visibility, A2A status display, and API catalog consistency (#1806)
- **fix(analytics):** use pure SQL aggregations — no history rows loaded into memory (#1802)
- **fix(dashboard):** correct `loadPresets` ReferenceError in CostOverviewTab
- **fix(mitm):** enforce transparent interception on port 443 only
### 🧹 Chores
- **chore(workflow):** mandate implementation plan generation in `/resolve-issues` workflow before coding
- **chore(release):** expand contributor credits to 155 PRs across full project history
### 🏆 Community Contributors Acknowledgment
We identified that **155 community PRs** across the entire project history (from inception through v3.7.5) were manually integrated into release branches but closed instead of properly merged through GitHub, preventing contributors from receiving merge credit on their profiles. We sincerely apologize for this oversight and have since updated our workflows to ensure this never happens again.
**The following contributors had their code and ideas integrated across multiple releases without proper merge credit. Thank you for your invaluable contributions to OmniRoute:**
| Contributor | PRs (Total) | All Contributions |
| :----------------------------------------------------------- | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@rdself](https://github.com/rdself) | 28 | #542, #705, #717, #737, #738, #841, #851, #853, #875, #880, #888, #891, #903, #904, #974, #1069, #1089, #1196, #1267, #1272, #1299, #1300, #1356, #1357, #1441, #1443, #1549, #1742 |
| [@oyi77](https://github.com/oyi77) | 27 | #644, #672, #700, #850, #859, #862, #868, #874, #881, #883, #908, #926, #931, #983, #990, #1019, #1020, #1021, #1103, #1281, #1286, #1363, #1368, #1377, #1411, #1689, #1717 |
| [@clousky2020](https://github.com/clousky2020) | 15 | #1244, #1323, #1365, #1366, #1408, #1442, #1484, #1595, #1598, #1599, #1611, #1618, #1620, #1621, #1644 |
| [@benzntech](https://github.com/benzntech) | 8 | #158, #1264, #1435, #1436, #1437, #1440, #1444, #1677 |
| [@kang-heewon](https://github.com/kang-heewon) | 5 | #530, #854, #884, #1235, #1574 |
| [@herjarsa](https://github.com/herjarsa) | 4 | #1472, #1474, #1477, #1480 |
| [@backryun](https://github.com/backryun) | 4 | #1358, #1609, #1627, #1722 |
| [@tombii](https://github.com/tombii) | 4 | #708, #856, #900, #1013 |
| [@christopher-s](https://github.com/christopher-s) | 3 | #868, #885, #992 |
| [@zen0bit](https://github.com/zen0bit) | 3 | #561, #650, #912 |
| [@k0valik](https://github.com/k0valik) | 3 | #554, #587, #596 |
| [@zhangqiang8vip](https://github.com/zhangqiang8vip) | 2 | #470, #575 |
| [@wlfonseca](https://github.com/wlfonseca) | 2 | #997, #1016 |
| [@RaviTharuma](https://github.com/RaviTharuma) | 2 | #1188, #1277 |
| [@prakersh](https://github.com/prakersh) | 2 | #419, #480 |
| [@payne0420](https://github.com/payne0420) | 2 | #1593, #1670 |
| [@only4copilot](https://github.com/only4copilot) | 2 | #855, #1039 |
| [@jay77721](https://github.com/jay77721) | 2 | #581, #582 |
| [@hijak](https://github.com/hijak) | 2 | #295, #578 |
| [@hartmark](https://github.com/hartmark) | 2 | #1494, #1500 |
| [@defhouse](https://github.com/defhouse) | 2 | #906, #946 |
| [@xiaoge1688](https://github.com/xiaoge1688) | 1 | #1304 |
| [@xandr0s](https://github.com/xandr0s) | 1 | #1376 |
| [@willbnu](https://github.com/willbnu) | 1 | #882 |
| [@slewis3600](https://github.com/slewis3600) | 1 | #1624 |
| [@sergey-v9](https://github.com/sergey-v9) | 1 | #594 |
| [@razllivan](https://github.com/razllivan) | 1 | #987 |
| [@nmime](https://github.com/nmime) | 1 | #1271 |
| [@Moutia-Ben-Yahia](https://github.com/Moutia-Ben-Yahia) | 1 | #1663 |
| [@Mind-Dragon](https://github.com/Mind-Dragon) | 1 | #467 |
| [@mercs2910](https://github.com/mercs2910) | 1 | #1001 |
| [@MAINER4IK](https://github.com/MAINER4IK) | 1 | #196 |
| [@luandiasrj](https://github.com/luandiasrj) | 1 | #996 |
| [@knopki](https://github.com/knopki) | 1 | #1434 |
| [@kfiramar](https://github.com/kfiramar) | 1 | #389 |
| [@ken2190](https://github.com/ken2190) | 1 | #166 |
| [@keith8496](https://github.com/keith8496) | 1 | #569 |
| [@jonesfernandess](https://github.com/jonesfernandess) | 1 | #1118 |
| [@JasonLandbridge](https://github.com/JasonLandbridge) | 1 | #1626 |
| [@i1hwan](https://github.com/i1hwan) | 1 | #1386 |
| [@Gorchakov-Pressure](https://github.com/Gorchakov-Pressure) | 1 | #754 |
| [@foxy1402](https://github.com/foxy1402) | 1 | #934 |
| [@dt418](https://github.com/dt418) | 1 | #896 |
| [@dhaern](https://github.com/dhaern) | 1 | #1647 |
| [@DavyMassoneto](https://github.com/DavyMassoneto) | 1 | #211 |
| [@dail45](https://github.com/dail45) | 1 | #1413 |
| [@congvc-dev](https://github.com/congvc-dev) | 1 | #1569 |
| [@be0hhh](https://github.com/be0hhh) | 1 | #1581 |
| [@andruwa13](https://github.com/andruwa13) | 1 | #1457 |
| [@AndrewDragonIV](https://github.com/AndrewDragonIV) | 1 | #898 |
| [@AndersonFirmino](https://github.com/AndersonFirmino) | 1 | #362 |
| [@alexsvdk](https://github.com/alexsvdk) | 1 | #1280 |
| [@abhinavjnu](https://github.com/abhinavjnu) | 1 | #550 |
---
@@ -13,7 +105,6 @@
### ✨ New Features
- **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753)
- **feat(api-keys):** add rename support in the permissions modal — editable key name field with validation (#1796)
### 🐛 Bug Fixes
@@ -52,44 +143,6 @@
- **chore(ui):** speed up endpoint initial render with background task loading (#1760)
- **chore(workflows):** add strict PR contributor credit policy to prevent future merge credit loss
### 🏆 Community Contributors Acknowledgment
We identified that **37 community PRs** across past releases (v3.4.0 → v3.7.4) were manually integrated into release branches but closed instead of properly merged through GitHub, preventing contributors from receiving merge credit on their profiles. We sincerely apologize for this oversight and have since updated our workflows to ensure this never happens again.
**The following contributors had their code and ideas integrated across multiple releases without proper merge credit. Thank you for your invaluable contributions to OmniRoute:**
| Contributor | Contributions (PRs) |
| :----------------------------------------------------- | :----------------------------------------------------------------------- |
| [@rdself](https://github.com/rdself) | #1742, #1357, #1356, #1089, #1069, #904, #880, #875, #853, #851, #974 |
| [@oyi77](https://github.com/oyi77) | #1411, #1021, #990, #926, #908, #883, #881, #868, #862, #859, #850, #983 |
| [@benzntech](https://github.com/benzntech) | #1677, #1444, #1440, #1437, #1435 |
| [@clousky2020](https://github.com/clousky2020) | #1644, #1408 |
| [@christopher-s](https://github.com/christopher-s) | #885, #868, #992 |
| [@kang-heewon](https://github.com/kang-heewon) | #1235, #884 |
| [@backryun](https://github.com/backryun) | #1627, #1358, #1722 |
| [@tombii](https://github.com/tombii) | #900, #856 |
| [@slewis3600](https://github.com/slewis3600) | #1624 |
| [@dhaern](https://github.com/dhaern) | #1647 |
| [@JasonLandbridge](https://github.com/JasonLandbridge) | #1626 |
| [@hartmark](https://github.com/hartmark) | #1500 |
| [@herjarsa](https://github.com/herjarsa) | #1480 |
| [@andruwa13](https://github.com/andruwa13) | #1457 |
| [@i1hwan](https://github.com/i1hwan) | #1386 |
| [@xandr0s](https://github.com/xandr0s) | #1376 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #1188 |
| [@wlfonseca](https://github.com/wlfonseca) | #1016 |
| [@only4copilot](https://github.com/only4copilot) | #1039, #855 |
| [@AndrewDragonIV](https://github.com/AndrewDragonIV) | #898 |
| [@dt418](https://github.com/dt418) | #896 |
| [@willbnu](https://github.com/willbnu) | #882 |
| [@defhouse](https://github.com/defhouse) | #906 |
| [@mercs2910](https://github.com/mercs2910) | #1001 |
| [@zen0bit](https://github.com/zen0bit) | #912 |
| [@razllivan](https://github.com/razllivan) | #987 |
| [@foxy1402](https://github.com/foxy1402) | #934 |
| [@knopki](https://github.com/knopki) | #1434 |
| [@dail45](https://github.com/dail45) | #1413 |
---
## [3.7.4] — 2026-04-28

163
CLAUDE.md
View File

@@ -1,7 +1,6 @@
# CLAUDE.md — AI Agent Session Bootstrap
# CLAUDE.md
> Quick-start context for AI coding agents. For deep architecture details, see `AGENTS.md`.
> For contribution workflow, see `CONTRIBUTING.md`.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Quick Start
@@ -17,55 +16,47 @@ npm run check # lint + test combined
npm run check:cycles # Detect circular dependencies
```
### Running a Single Test
### Running Tests
```bash
# Node.js native test runner (most tests)
node --import tsx/esm --test tests/unit/your-file.test.mjs
# Single test file (Node.js native test runner most tests)
node --import tsx/esm --test tests/unit/your-file.test.ts
# Vitest (MCP server, autoCombo, cache)
npm run test:vitest
# All suites
npm run test:all
```
For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep architecture, see `AGENTS.md`.
---
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 100+ LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 160+ LLM providers, auto-fallback.
| Layer | Location | Purpose |
| --------------- | ------------------------ | ------------------------------------------ |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (22 files) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 25 tools, 3 transports, 10 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
| UI Components | `src/shared/components/` | React components (Tailwind CSS v4) |
| Provider Consts | `src/shared/constants/` | Provider registry (Zod-validated) |
| Validation | `src/shared/validation/` | Zod v4 schemas |
| Tests | `tests/` | Unit, integration, e2e, security, load |
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------ |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (22 files) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 29 tools, 3 transports, 10 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
### Monorepo Layout
```
OmniRoute/ # Root package
├── src/ # Next.js 16 app (TypeScript)
├── open-sse/ # @omniroute/open-sse workspace (streaming engine)
├── electron/ # Desktop app (Electron)
├── tests/ # All test suites
├── docs/ # Documentation
└── bin/ # CLI entry point
```
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
---
## Request Pipeline (Abbreviated)
## Request Pipeline
```
Client → /v1/chat/completions (Next.js route)
@@ -76,39 +67,45 @@ Client → /v1/chat/completions (Next.js route)
→ translateRequest() → getExecutor() → executor.execute()
→ fetch() upstream → retry w/ backoff
→ response translation → SSE stream or JSON
→ If Responses API: responsesTransformer.ts TransformStream
```
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
**Combo routing** (`open-sse/services/combo.ts`): 13 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks.
---
## Key Conventions
### Code Style
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = warn in `open-sse/` and `tests/`
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
### Database Access
### Database
- **Always** go through `src/lib/db/` domain modules
- **Never** write raw SQL in routes or handlers
- **Always** go through `src/lib/db/` domain modules**never** write raw SQL in routes or handlers
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
- Migrations: `src/lib/db/migrations/` 21 versioned SQL files
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
### Error Handling
- try/catch with specific error types, log with pino context
- Never swallow errors in SSE streams — use abort signals
- Never swallow errors in SSE streams — use abort signals for cleanup
- Return proper HTTP status codes (4xx/5xx)
### Security
- **Never** commit secrets/credentials
- **Never** use `eval()`, `new Function()`, or implied eval
- Validate all inputs with Zod schemas
- Encrypt credentials at rest (AES-256-GCM)
- Upstream header denylist: `src/shared/constants/upstreamHeaders.ts` — keep sanitize, Zod schemas, and unit tests aligned when editing
---
@@ -117,11 +114,11 @@ Client → /v1/chat/completions (Next.js route)
### Adding a New Provider
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
2. Add executor in `open-sse/executors/` if custom logic needed
2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`)
3. Add translator in `open-sse/translator/` if non-OpenAI format
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based
5. Register models in `open-sse/config/providerRegistry.ts`
6. Write tests in `tests/unit/` (registration, translation, error handling)
6. Write tests in `tests/unit/`
### Adding a New API Route
@@ -133,20 +130,18 @@ Client → /v1/chat/completions (Next.js route)
### Adding a New DB Module
1. Create `src/lib/db/yourModule.ts`
2. Import `getDbInstance` from `./core.ts`
3. Export CRUD functions for your domain table(s)
4. Add migration in `src/lib/db/migrations/` if new tables needed
5. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
6. Write tests
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
2. Export CRUD functions for your domain table(s)
3. Add migration in `src/lib/db/migrations/` if new tables needed
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
5. Write tests
### Adding a New MCP Tool
1. Add tool definition in `open-sse/mcp-server/tools/`
2. Define Zod input schema + async handler
3. Register in tool set (wired by `createMcpServer()`)
4. Assign to appropriate scope(s)
5. Write tests (tool invocation logged to `mcp_audit` table)
1. Add tool definition in `open-sse/mcp-server/tools/` with Zod input schema + async handler
2. Register in tool set (wired by `createMcpServer()`)
3. Assign to appropriate scope(s)
4. Write tests (tool invocation logged to `mcp_audit` table)
### Adding a New A2A Skill
@@ -157,25 +152,25 @@ Client → /v1/chat/completions (Next.js route)
---
## Testing Cheat Sheet
## Testing
| What | Command |
| ----------------------- | ------------------------------------------------------- |
| All tests | `npm run test:all` |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/file.test.mjs` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60% min all metrics) |
| Coverage report | `npm run coverage:report` |
| What | Command |
| ----------------------- | ------------------------------------------------------ |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60% min all metrics) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`,
you must include or update tests in the same PR.
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
**Copilot coverage policy**: When a PR changes production code and coverage is below 60%, do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
---
## Git Workflow
@@ -183,40 +178,34 @@ you must include or update tests in the same PR.
```bash
# Never commit directly to main
git checkout -b feat/your-feature
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature
```
**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/`
**Commit format** ([Conventional Commits](https://www.conventionalcommits.org/)):
**Commit format** (Conventional Commits): `feat(db): add circuit breaker` — scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills`
```
feat: add circuit breaker for provider calls
fix: resolve JWT secret validation edge case
docs: update AGENTS.md with pipeline internals
test: add MCP tool unit tests
refactor(db): consolidate rate limit tables
```
**Husky hooks**:
**Scopes**: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`,
`memory`, `skills`.
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
- **pre-push**: `npm run test:unit`
---
## Environment
- **Runtime**: Node.js ≥18 <24, ES Modules
- **TypeScript**: 5.9, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*``src/`, `@omniroute/open-sse``open-sse/`
- **Runtime**: Node.js ≥20.20.2 <21 || ≥22.22.2 <23 || ≥24 <25, ES Modules
- **TypeScript**: 5.9+, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*``src/`, `@omniroute/open-sse``open-sse/`, `@omniroute/open-sse/*``open-sse/*`
- **Default port**: 20128 (API + dashboard on same port)
- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/`
- **Key env vars**: `PORT`, `JWT_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL`
- **Key env vars**: `PORT`, `JWT_SECRET`, `API_KEY_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL`
- Setup: `cp .env.example .env` then generate `JWT_SECRET` (`openssl rand -base64 48`) and `API_KEY_SECRET` (`openssl rand -hex 32`)
---
## Hard Rules (Never Violate)
## Hard Rules
1. Never commit secrets or credentials
2. Never add logic to `localDb.ts`

View File

@@ -1060,7 +1060,7 @@ docker compose --profile base up -d
docker compose --profile cli up -d
```
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL. Endpoint tunnel panels, including Cloudflare, Tailscale, and ngrok, can be shown or hidden from `Settings → Appearance` without changing active tunnel state.
Notes:
@@ -1502,24 +1502,24 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy.
### ☁️ Deployment & Platform
| Feature | What It Does |
| ------------------------------ | --------------------------------------------------------------------- |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments |
| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard |
| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles |
| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls |
| 🔄 **Backup/Restore** | Export/import and disaster recovery flows |
| 🧙 **Onboarding Wizard** | First-run guided setup |
| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools |
| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard |
| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security |
| 🌐 **i18n (40+ languages)** | Full dashboard + docs language support with RTL coverage |
| 🧹 **Clear All Models** | One-click model list clearing in provider details |
| 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings |
| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features |
| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location |
| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` |
| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support |
| Feature | What It Does |
| ------------------------------ | ---------------------------------------------------------------------- |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments |
| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard |
| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles |
| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls |
| 🔄 **Backup/Restore** | Export/import and disaster recovery flows |
| 🧙 **Onboarding Wizard** | First-run guided setup |
| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools |
| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard |
| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security |
| 🌐 **i18n (40+ languages)** | Full dashboard + docs language support with RTL coverage |
| 🧹 **Clear All Models** | One-click model list clearing in provider details |
| 👁️ **Visibility Controls** 🆕 | Hide sidebar items and Endpoint tunnel panels from Appearance Settings |
| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features |
| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location |
| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` |
| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support |
### Feature Deep Dive
@@ -1611,6 +1611,9 @@ Useful APIs for automation:
<details>
<summary><b>🤝 A2A Setup (Agent2Agent)</b></summary>
Enable A2A from **Endpoints → A2A** before sending JSON-RPC tasks. When the toggle is off,
`/api/a2a/status` reports `disabled` and `POST /a2a` returns HTTP 503.
Discover the agent:
```bash

View File

@@ -22,6 +22,12 @@ Authorization: Bearer YOUR_OMNIROUTE_API_KEY
If no API key is configured on the server, authentication is bypassed.
## Enablement
A2A is controlled by the **Endpoints → A2A** toggle and is disabled by default. When disabled,
`GET /api/a2a/status` reports `status: "disabled"` and `online: false`; JSON-RPC calls to
`POST /a2a` return HTTP 503 with JSON-RPC error code `-32000`.
---
## JSON-RPC 2.0 Methods
@@ -148,6 +154,7 @@ submitted → working → completed
| -32601 | Method or skill not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32000 | A2A endpoint is disabled |
---

View File

@@ -259,6 +259,8 @@ Response example:
| -------------------------- | ------ | ----------------------------------------------------------------------- |
| `/api/tunnels/cloudflared` | GET | Read Cloudflare Quick Tunnel install/runtime status for the dashboard |
| `/api/tunnels/cloudflared` | POST | Enable or disable the Cloudflare Quick Tunnel (`action=enable/disable`) |
| `/api/tunnels/ngrok` | GET | Read ngrok Tunnel runtime status for the dashboard |
| `/api/tunnels/ngrok` | POST | Enable or disable the ngrok Tunnel (`action=enable/disable`) |
### CLI Tools

View File

@@ -70,7 +70,7 @@ Customizable color themes for the entire dashboard. Choose from 7 preset colors
Comprehensive settings panel with tabs:
- **General** — System storage, backup management (export/import database)
- **Appearance** — Theme selector (dark/light/system), color theme presets and custom colors, health log visibility, sidebar item visibility controls
- **Appearance** — Theme selector (dark/light/system), color theme presets and custom colors, health log visibility, sidebar item visibility controls, Endpoint tunnel visibility controls
- **Security** — API endpoint protection, custom provider blocking, IP filtering, session info
- **Routing** — Model aliases, background task degradation
- **Resilience** — Rate limit persistence, circuit breaker tuning, auto-disable banned accounts, provider expiration monitoring, **Context Relay** handoff threshold and summary model configuration
@@ -177,7 +177,7 @@ Real-time request logging with filtering by provider, model, account, and API ke
## 🌐 API Endpoint
Your unified API endpoint with capability breakdown: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, and registered API keys. Cloudflare Quick Tunnel integration and cloud proxy support for remote access.
Your unified API endpoint with capability breakdown: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, and registered API keys. Cloudflare Quick Tunnel, Tailscale Funnel, ngrok Tunnel, and cloud proxy support are available for remote access.
![Endpoint Dashboard](screenshots/09-endpoint.png)

160
docs/TERMUX_GUIDE.md Normal file
View File

@@ -0,0 +1,160 @@
# Termux Headless Setup
OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network.
## Prerequisites
Install Termux from F-Droid or GitHub releases, then update packages and install the build tools required by native dependencies such as `better-sqlite3`.
```bash
pkg update
pkg upgrade
pkg install nodejs-lts python build-essential git
```
If native package compilation fails, rerun the `pkg install` command above and then retry the OmniRoute install.
## Install
Run the latest published package directly:
```bash
npx -y omniroute@latest
```
You can also install it globally:
```bash
npm install -g omniroute
omniroute
```
## Run
Start OmniRoute in headless server mode:
```bash
omniroute
```
or:
```bash
npx omniroute
```
The dashboard listens on:
```text
http://localhost:20128
```
Open that URL in the Android browser. If you run clients inside Termux, use the same host and port as the OpenAI-compatible base URL.
## Background Execution
For a simple background process:
```bash
nohup omniroute > omniroute.log 2>&1 &
```
To stop it:
```bash
pkill -f omniroute
```
For automatic startup after device boot, install the Termux:Boot add-on and create a boot script:
```bash
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/omniroute.sh <<'EOF'
#!/data/data/com.termux/files/usr/bin/sh
cd "$HOME"
nohup omniroute > "$HOME/omniroute.log" 2>&1 &
EOF
chmod +x ~/.termux/boot/omniroute.sh
```
Android battery optimization can stop long-running background processes. Disable battery optimization for Termux if the server is expected to stay online.
## Access From Other Devices
Find the phone IP address on the WiFi network:
```bash
ip addr show wlan0
```
Then open the dashboard from another device:
```text
http://PHONE_IP:20128
```
For example:
```text
http://192.168.1.50:20128
```
Keep the phone and client on the same trusted network. If you expose OmniRoute outside the phone, enable API keys and dashboard authentication.
## Data Directory
By default OmniRoute stores data under the Termux home directory, following the same server-side data path behavior used on Linux. To place the database somewhere explicit:
```bash
export DATA_DIR="$HOME/.omniroute"
omniroute
```
## Limitations
- Electron does not run in Termux.
- There is no system tray or desktop integration.
- This setup is server-only: use the browser dashboard.
- Native dependencies may need local compilation.
- Low-memory Android devices may need fewer concurrent requests.
- MITM/system certificate features may require Android-level trust-store work outside Termux.
## Troubleshooting
### better-sqlite3 Build Errors
Install the Termux build toolchain:
```bash
pkg install nodejs-lts python build-essential
```
Then rerun:
```bash
npx -y omniroute@latest
```
### Port Already In Use
Check what is listening on the default port:
```bash
ss -ltnp | grep 20128
```
Stop the old process:
```bash
pkill -f omniroute
```
### Dashboard Not Reachable From Another Device
Verify both devices are on the same WiFi network, then test from Termux:
```bash
curl http://localhost:20128
```
If local access works but LAN access does not, check Android hotspot/WiFi isolation and any firewall or VPN profile on the phone.

View File

@@ -685,6 +685,7 @@ Returns models grouped by provider with types (`chat`, `embedding`, `image`).
- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained containers
- Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want to override the managed transport choice
- Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download
- Cloudflare Quick Tunnel, Tailscale Funnel, and ngrok Tunnel panels can be shown or hidden in **Settings → Appearance**. Hiding a panel does not stop a running tunnel.
### LLM Gateway Intelligence (Phase 9)
@@ -837,14 +838,14 @@ curl -X POST http://localhost:20128/api/db-backups/import \
The settings page is organized into 6 tabs for easy navigation:
| Tab | Contents |
| -------------- | -------------------------------------------------------------------------------------------- |
| **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
| Tab | Contents |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, appearance settings, theme controls, sidebar visibility, and Endpoint tunnel visibility |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
---

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.7.5
version: 3.7.6
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.7.5",
"version": "3.7.6",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
@@ -106,7 +106,8 @@
{
"target": "AppImage",
"arch": [
"x64"
"x64",
"arm64"
]
},
{

View File

@@ -10,6 +10,17 @@ const eslintConfig = [
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "prop-types",
message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.",
},
],
},
],
},
},
// Relaxed rules for open-sse and tests (incremental adoption)

View File

@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.7.5
**Current version:** 3.7.6
## Tech Stack
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
└── .env.example # Environment variable template
```
## Key Features (v3.7.5)
## Key Features (v3.7.6)
### Core Proxy
- **160+ AI providers** with automatic format translation

View File

@@ -228,7 +228,17 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
codestral: buildModels(["codestral-2405", "codestral-latest"]),
upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]),
maritalk: buildModels(["sabia-3", "sabia-3-small"]),
"xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]),
"xiaomi-mimo": buildModels([
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2.5-tts",
"mimo-v2.5-tts-voiceclone",
"mimo-v2.5-tts-voicedesign",
"mimo-v2-pro",
"mimo-v2-omni",
"mimo-v2-tts",
"mimo-v2-flash",
]),
"inference-net": buildModels([
"meta-llama/Llama-3.3-70B-Instruct",
"deepseek-ai/DeepSeek-R1",
@@ -740,19 +750,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "glm-cn",
format: "openai",
executor: "default",
baseUrl: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
models: [
{ id: "glm-4-plus", name: "GLM-4 Plus" },
{ id: "glm-4-0520", name: "GLM-4 0520" },
{ id: "glm-4-air", name: "GLM-4 Air" },
{ id: "glm-4-airx", name: "GLM-4 AirX" },
{ id: "glm-4-long", name: "GLM-4 Long", contextLength: 1000000 },
{ id: "glm-4-flashx", name: "GLM-4 FlashX" },
{ id: "glm-4-flash", name: "GLM-4 Flash" },
],
models: [{ id: "glm-5.1", name: "GLM-5.1" }],
passthroughModels: true,
},

View File

@@ -274,11 +274,18 @@ export class BaseExecutor {
const cloned = { ...body } as Record<string, unknown>;
if (Array.isArray(cloned.tools)) {
cloned.tools = cloned.tools.map((tool: any) => {
if (tool?.function && typeof tool.function === "object") {
const func = { ...tool.function };
if (func.description === "") delete func.description;
return { ...tool, function: func };
cloned.tools = cloned.tools.map((tool: unknown) => {
if (tool && typeof tool === "object" && !Array.isArray(tool)) {
const toolRecord = tool as JsonRecord;
const toolFunction = toolRecord.function;
if (toolFunction && typeof toolFunction === "object" && !Array.isArray(toolFunction)) {
const func = { ...(toolFunction as JsonRecord) };
if (func.description === "") delete func.description;
if (typeof func.name !== "string" || func.name.trim() === "") {
func.name = "unnamed_tool";
}
return { ...toolRecord, function: func };
}
}
return tool;
});

View File

@@ -36,6 +36,7 @@ const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`;
const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`;
const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`;
const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`;
const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`;
const CHATGPT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
@@ -92,6 +93,20 @@ const MODEL_MAP: Record<string, string> = {
o3: "o3",
};
/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint
* accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a
* new thinking entry there automatically extends this set. Includes the
* abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the
* reason this set exists at all rather than a substring match.
*
* Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or
* are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */
const THINKING_CAPABLE_SLUGS: ReadonlySet<string> = new Set(
Object.entries(MODEL_MAP)
.filter(([k]) => k.includes("thinking") || k === "o3")
.map(([, v]) => v)
);
// ─── Browser-like default headers ──────────────────────────────────────────
function browserHeaders(): Record<string, string> {
@@ -413,6 +428,158 @@ async function runSessionWarmup(
}
}
// ─── Thinking-effort preference (PATCH user_last_used_model_config) ────────
// chatgpt.com has two thinking levels for its dedicated thinking-models:
// • standard — default, faster
// • extended — longer reasoning budget
// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config`
// once, then issues the conversation request — the conversation endpoint itself
// has no `thinking_effort` field; the server reads the user's stored preference
// at routing time. We mirror that handshake when an OpenAI-style request
// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort`
// override).
//
// Cached per (cookie, slug, effort): the preference persists server-side, so
// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or
// whenever the caller switches efforts.
const thinkingEffortCache = new Map<string, number>();
const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000;
const THINKING_EFFORT_CACHE_MAX = 400;
/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking
* models and the o-series. PATCHing for a non-thinking surface is a no-op
* (the server accepts it but the routing-time read picks the wrong knob).
*
* Three branches because the input can arrive in three shapes:
* 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking
* variant carries the literal "thinking" substring here.
* 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`).
* 3. Resolved chatgpt.com slug that drops the substring under abbreviation
* (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is
* derived from MODEL_MAP itself so adding a new abbreviated thinking
* mapping automatically extends the check.
*
* Branch 3 also catches the case where a caller passes the chatgpt.com slug
* directly as the `model` field (no MODEL_MAP translation needed), which
* would otherwise silently bypass the PATCH. */
function isThinkingCapableModel(modelId: string, slug: string): boolean {
return (
modelId.includes("thinking") ||
modelId === "o3" ||
slug.includes("thinking") ||
THINKING_CAPABLE_SLUGS.has(slug) ||
THINKING_CAPABLE_SLUGS.has(modelId)
);
}
/** Map either a chatgpt.com-native value (`standard`/`extended`) or the
* OpenAI Chat Completions `reasoning_effort` field to the value the
* `user_last_used_model_config` endpoint expects.
*
* minimal | low | medium | standard → standard
* high | xhigh | extended → extended
*
* `medium` collapses to `standard` because chatgpt.com only has two levels —
* there is no separate medium tier on the web product. Returns null for
* absent/unknown inputs. */
function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null {
if (typeof input !== "string") return null;
const v = input.trim().toLowerCase();
if (v === "extended" || v === "high" || v === "xhigh") return "extended";
if (v === "standard" || v === "low" || v === "medium" || v === "minimal") {
return "standard";
}
return null;
}
/** Resolve the requested effort for this turn.
* Order: `providerSpecificData.thinkingEffort` (raw override, takes
* `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI
* Chat Completions field) > `body.reasoning.effort` (Responses-API nesting).
* Returns null when the caller did not request one. */
function resolveThinkingEffort(
body: unknown,
providerSpecificData: Record<string, unknown> | undefined
): "standard" | "extended" | null {
if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) {
return normalizeThinkingEffort(providerSpecificData.thinkingEffort);
}
const b = (body as Record<string, unknown> | null) ?? null;
if (!b) return null;
const top = normalizeThinkingEffort(b.reasoning_effort);
if (top) return top;
const nested = (b.reasoning as Record<string, unknown> | undefined)?.effort;
return normalizeThinkingEffort(nested);
}
async function setUserThinkingEffort(
modelSlug: string,
effort: "standard" | "extended",
accessToken: string,
accountId: string | null,
sessionId: string,
deviceId: string,
cookie: string,
signal: AbortSignal | null | undefined,
log:
| {
debug?: (tag: string, msg: string) => void;
warn?: (tag: string, msg: string) => void;
}
| null
| undefined
): Promise<void> {
const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`;
const now = Date.now();
const last = thinkingEffortCache.get(cacheKey);
if (last && now - last < THINKING_EFFORT_TTL_MS) {
log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`);
return;
}
if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) {
const first = thinkingEffortCache.keys().next().value;
if (first) thinkingEffortCache.delete(first);
}
const url =
`${USER_LAST_USED_MODEL_CONFIG_URL}` +
`?model_slug=${encodeURIComponent(modelSlug)}` +
`&thinking_effort=${encodeURIComponent(effort)}`;
const headers: Record<string, string> = {
...browserHeaders(),
...oaiHeaders(sessionId, deviceId),
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
Cookie: buildSessionCookieHeader(cookie),
Priority: "u=4",
};
if (accountId) headers["chatgpt-account-id"] = accountId;
try {
const r = await tlsFetchChatGpt(url, {
method: "PATCH",
headers,
timeoutMs: 15_000,
signal,
});
if (r.status >= 400) {
log?.warn?.(
"CGPT-WEB",
`thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)`
);
return;
}
thinkingEffortCache.set(cacheKey, now);
log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`);
} catch (err) {
log?.warn?.(
"CGPT-WEB",
`thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}`
);
}
}
async function prepareChatRequirements(
accessToken: string,
accountId: string | null,
@@ -2380,6 +2547,29 @@ export class ChatGptWebExecutor extends BaseExecutor {
log
);
// 2a''. Apply thinking_effort preference for thinking-capable models.
// Mirrors what chatgpt.com's web UI does when the user toggles the
// "Standard"/"Extended" thinking switch — PATCH the user-config endpoint
// before issuing the conversation. The conversation request itself has
// no `thinking_effort` field; the server reads the stored preference at
// routing time. Best-effort: a failed PATCH falls back to whatever the
// account's current preference is.
const earlyModelSlug = MODEL_MAP[model] ?? model;
const requestedEffort = resolveThinkingEffort(body, credentials.providerSpecificData);
if (requestedEffort && isThinkingCapableModel(model, earlyModelSlug)) {
await setUserThinkingEffort(
earlyModelSlug,
requestedEffort,
tokenEntry.accessToken,
tokenEntry.accountId,
sessionId,
deviceId,
cookie,
signal,
log
);
}
// 2b. Sentinel chat-requirements
let reqs: ChatRequirements;
try {
@@ -2656,6 +2846,7 @@ function stringToStream(text: string): ReadableStream<Uint8Array> {
export function __resetChatGptWebCachesForTesting(): void {
tokenCache.clear();
warmupCache.clear();
thinkingEffortCache.clear();
deviceIdCache.clear();
__resetChatGptImageCacheForTesting();
dplCache = null;

View File

@@ -481,7 +481,13 @@ function stripStoredItemReferences(body: Record<string, unknown>): void {
// Codex rejects previous_response_id for passthrough requests.
delete body.previous_response_id;
if (Array.isArray(body.input) && body.input.length === 0) {
delete body.input;
body.input = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "continue" }],
},
];
}
if (!Array.isArray(body.input)) return;
@@ -619,10 +625,27 @@ function normalizeCodexTools(body: Record<string, unknown>): void {
}
function getResponsesSubpath(endpointPath: unknown): string | null {
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i);
if (!match) return null;
return match[1] || "";
let normalizedEndpoint = String(endpointPath || "");
while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) {
normalizedEndpoint = normalizedEndpoint.slice(0, -1);
}
const lower = normalizedEndpoint.toLowerCase();
if (lower === "responses" || lower.endsWith("/responses")) {
return "";
}
const responsesSlash = "/responses/";
const idx = lower.lastIndexOf(responsesSlash);
if (idx !== -1) {
return normalizedEndpoint.slice(idx + "/responses".length);
}
if (lower.startsWith("responses/")) {
return normalizedEndpoint.slice("responses".length);
}
return null;
}
export function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
@@ -1146,6 +1169,7 @@ export class CodexExecutor extends BaseExecutor {
if (isCompactRequest) {
delete body.stream;
delete body.stream_options;
delete body.client_metadata;
} else {
body.stream = true;
}
@@ -1288,7 +1312,9 @@ export class CodexExecutor extends BaseExecutor {
body.prompt_cache_key = cacheSessionId;
}
}
applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity);
if (!isCompactRequest) {
applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity);
}
// Delete session_id and conversation_id from the body.
// These are often injected by OmniRoute's fallback logic for store=true,

View File

@@ -1226,7 +1226,7 @@ export async function handleChatCore({
const pipelinePayloads = detailedLoggingEnabled ? reqLogger?.getPipelinePayloads?.() : null;
if (pipelinePayloads) {
if (providerResponse !== undefined) {
if (providerResponse !== undefined && !pipelinePayloads.providerResponse) {
pipelinePayloads.providerResponse = providerResponse as Record<string, unknown>;
}
if (clientResponse !== undefined) {
@@ -1696,7 +1696,36 @@ export async function handleChatCore({
) => {
const preserveToolResultBlocks = options?.preserveToolResultBlocks === true;
if (!Array.isArray(payload.messages)) return;
const messages = payload.messages as ClaudeMessage[];
let messages = payload.messages as ClaudeMessage[];
// Extract system role messages (Issue #1797)
const systemMessages = messages.filter((m) => m.role === "system");
if (systemMessages.length > 0) {
const extraBlocks: ClaudeContentBlock[] = [];
for (const sm of systemMessages) {
if (typeof sm.content === "string" && sm.content.length > 0) {
extraBlocks.push({ type: "text", text: sm.content });
} else if (Array.isArray(sm.content)) {
for (const block of sm.content as ClaudeContentBlock[]) {
if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
extraBlocks.push(block);
}
}
}
}
if (extraBlocks.length > 0) {
const existingSystem = payload.system;
if (typeof existingSystem === "string" && existingSystem.length > 0) {
payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks];
} else if (Array.isArray(existingSystem)) {
payload.system = [...(existingSystem as ClaudeContentBlock[]), ...extraBlocks];
} else {
payload.system = extraBlocks;
}
}
messages = messages.filter((m) => m.role !== "system");
payload.messages = messages;
}
// Anthropic rejects empty text blocks in native Messages payloads.
for (const msg of messages) {
@@ -1969,6 +1998,28 @@ export async function handleChatCore({
}
translatedBody.model = finalModelToUpstream;
// #1789: Prevent output_config.effort from overriding effort encoded in model name (Codex)
if (provider === "codex" || provider?.startsWith("codex")) {
const hasEffortSuffix = finalModelToUpstream.match(/-(low|medium|high|xhigh)$/i);
if (
hasEffortSuffix &&
translatedBody.output_config &&
typeof translatedBody.output_config === "object"
) {
const oc = translatedBody.output_config as Record<string, unknown>;
if (oc.effort) {
log?.warn?.(
"PARAMS",
`Stripped output_config.effort="${oc.effort}" because model "${finalModelToUpstream}" already encodes effort`
);
delete oc.effort;
if (Object.keys(oc).length === 0) {
delete translatedBody.output_config;
}
}
}
}
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
const unsupported = getUnsupportedParams(provider, model);
if (unsupported.length > 0) {
@@ -2081,7 +2132,13 @@ export async function handleChatCore({
};
// Create stream controller for disconnect detection
const streamController = createStreamController({ onDisconnect, log, provider, model });
const streamController = createStreamController({
onDisconnect,
log,
provider,
model,
connectionId,
});
const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}`, stream };
const dedupEnabled = shouldDeduplicate(dedupRequestBody);

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.7.5",
"version": "3.7.6",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

View File

@@ -980,6 +980,15 @@ export function checkFallbackError(
dailyQuotaExhausted: true,
};
}
if (
status === HTTP_STATUS.FORBIDDEN &&
provider &&
getProviderCategory(provider) === "apikey" &&
!errorStr.toLowerCase().includes("has not been used in project")
) {
return buildRetryableFallback(RateLimitReason.AUTH_ERROR);
}
}
const configuredRule =

View File

@@ -8,6 +8,8 @@ import {
checkFallbackError,
formatRetryAfter,
getRuntimeProviderProfile,
recordProviderFailure,
isProviderFailureCode,
} from "./accountFallback.ts";
import { errorResponse, unavailableResponse } from "../utils/error.ts";
import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
@@ -1674,6 +1676,11 @@ export async function handleComboChat({
profile
);
// Trigger shared provider circuit breaker for 5xx errors and connection failures
if (isProviderFailureCode(result.status)) {
recordProviderFailure(provider, log);
}
// Check if this is a transient error worth retrying on same model
const isTransient =
!isStreamReadinessTimeout && [408, 429, 500, 502, 503, 504].includes(result.status);

View File

@@ -143,6 +143,9 @@ export function classifyProviderError(
if (bodyStr.includes("has not been used in project")) {
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
}
if (provider && getProviderCategory(provider) === "apikey") {
return null;
}
return PROVIDER_ERROR_TYPES.FORBIDDEN;
}
if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR;

View File

@@ -330,7 +330,7 @@ export function openaiToOpenAIResponsesRequest(
const msg = toRecord(messageValue);
const role = toString(msg.role);
if (role === "system") {
if (role === "system" || role === "developer") {
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;

View File

@@ -172,11 +172,16 @@ export async function parseUpstreamError(response, provider = null) {
retryAfterMs = MAX_RETRY_MS;
}
const responseHeaders: Record<string, string> | null = response.headers
? Object.fromEntries(response.headers.entries())
: null;
return {
statusCode: response.status,
message: messageStr,
retryAfterMs,
responseBody,
responseHeaders,
};
}

View File

@@ -58,6 +58,13 @@ export { COLORS, formatSSE };
type JsonRecord = Record<string, unknown>;
const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared";
function markPendingRequestCleared(error: Error): Error {
(error as Error & Record<string, unknown>)[PENDING_REQUEST_CLEARED_MARKER] = true;
return error;
}
function buildResponsesOutputItemKey(item: unknown): string | null {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
@@ -855,7 +862,7 @@ export function createSSEStream(options: StreamOptions = {}) {
}).catch(() => {});
const timeoutError = new Error(timeoutMsg);
timeoutError.name = "StreamIdleTimeoutError";
controller.error(timeoutError);
controller.error(markPendingRequestCleared(timeoutError));
}
}, 10_000);
}
@@ -1335,7 +1342,9 @@ export function createSSEStream(options: StreamOptions = {}) {
}
clearIdleTimer();
trackPendingRequest(model, provider, connectionId, false);
controller.error(new Error(failurePayload.message || "Upstream failure"));
controller.error(
markPendingRequestCleared(new Error(failurePayload.message || "Upstream failure"))
);
return;
}
if (!trimmed) {
@@ -1729,7 +1738,9 @@ export function createSSEStream(options: StreamOptions = {}) {
}
clearIdleTimer();
controller.error(new Error(err.message || "Upstream failure"));
controller.error(
markPendingRequestCleared(new Error(err.message || "Upstream failure"))
);
return;
}

View File

@@ -1,5 +1,9 @@
import { trackPendingRequest } from "@/lib/usageDb";
// Stream handler with disconnect detection - shared for all providers
const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared";
type StreamDisconnectEvent = {
reason: string;
duration: number;
@@ -10,6 +14,7 @@ type StreamControllerOptions = {
log?: unknown;
provider?: string;
model?: string;
connectionId?: string | null;
};
type StreamController = ReturnType<typeof createStreamController>;
@@ -38,11 +43,13 @@ export function createStreamController({
log,
provider,
model,
connectionId,
}: StreamControllerOptions = {}) {
const abortController = new AbortController();
const startTime = Date.now();
let disconnected = false;
let abortTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingRequestCleared = false;
const logStream = (status) => {
const duration = Date.now() - startTime;
@@ -52,6 +59,24 @@ export function createStreamController({
);
};
const clearPendingRequest = (error?: unknown) => {
if (pendingRequestCleared) return;
if (
error &&
typeof error === "object" &&
(error as Record<string, unknown>)[PENDING_REQUEST_CLEARED_MARKER] === true
) {
pendingRequestCleared = true;
return;
}
pendingRequestCleared = true;
if (!model && !provider && !connectionId) return;
try {
trackPendingRequest(model || "", provider || "", connectionId ?? null, false);
} catch {}
};
return {
signal: abortController.signal,
startTime,
@@ -65,6 +90,10 @@ export function createStreamController({
logStream(`disconnect: ${reason}`);
// Decrement pending request counter — the TransformStream flush() won't
// fire when the client aborts mid-stream, so we must clean up here.
clearPendingRequest();
// Delay abort to allow cleanup
abortTimeout = setTimeout(() => {
abortController.abort();
@@ -93,6 +122,8 @@ export function createStreamController({
abortTimeout = null;
}
clearPendingRequest(error);
if (error instanceof Error && error.name === "AbortError") {
logStream("aborted");
return;

7
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.7.5",
"version": "3.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.7.5",
"version": "3.7.6",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -81,7 +81,6 @@
"jsdom": "^29.0.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"prop-types": "^15.8.1",
"tailwindcss": "^4",
"typescript": "^6.0.2",
"typescript-eslint": "^8.56.0",
@@ -15202,7 +15201,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.7.5"
"version": "3.7.6"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.7.5",
"version": "3.7.6",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -174,7 +174,6 @@
"jsdom": "^29.0.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"prop-types": "^15.8.1",
"tailwindcss": "^4",
"typescript": "^6.0.2",
"typescript-eslint": "^8.56.0",

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "omniroute-pwa-v1";
const CACHE_NAME = "omniroute-pwa-v2";
const APP_SHELL = [
"/",
"/offline",
@@ -38,6 +38,7 @@ self.addEventListener("fetch", (event) => {
const isExcludedPath = EXCLUDED_PATH_PREFIXES.some((prefix) =>
requestUrl.pathname.startsWith(prefix)
);
const isNextAsset = requestUrl.pathname.startsWith("/_next/");
const destination = event.request.destination;
const isStaticAsset = ["style", "script", "image", "font"].includes(destination);
const isNavigateRequest = event.request.mode === "navigate";
@@ -61,6 +62,19 @@ self.addEventListener("fetch", (event) => {
return fetch(event.request);
}
if (isNextAsset) {
try {
const networkResponse = await fetch(event.request);
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
}
return networkResponse;
} catch {
return (await caches.match(event.request)) || Response.error();
}
}
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;

View File

@@ -3,7 +3,6 @@
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -30,6 +29,39 @@ type VersionInfo = {
news?: NewsAnnouncement | null;
};
type HomePageClientProps = {
machineId?: string;
};
type ProviderSummaryItem = {
id: string;
provider: {
id: string;
name: string;
color?: string;
textIcon?: string;
alias?: string;
};
total: number;
connected: number;
errors: number;
modelCount: number;
authType: "free" | "oauth" | "apikey" | string;
};
type ProviderMetricSummary = {
totalRequests?: number;
totalSuccesses?: number;
successRate?: number;
avgLatencyMs?: number;
};
type ProviderModelSummary = {
fullModel: string;
alias?: string;
model?: string;
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) {
@@ -43,7 +75,7 @@ function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) {
return next;
}
export default function HomePageClient({ machineId }) {
export default function HomePageClient({ machineId }: HomePageClientProps) {
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
@@ -774,11 +806,15 @@ export default function HomePageClient({ machineId }) {
);
}
HomePageClient.propTypes = {
machineId: PropTypes.string,
};
function ProviderOverviewCard({ item, metrics, onClick }) {
function ProviderOverviewCard({
item,
metrics,
onClick,
}: {
item: ProviderSummaryItem;
metrics?: ProviderMetricSummary;
onClick: () => void;
}) {
const t = useTranslations("home");
const tc = useTranslations("common");
@@ -839,32 +875,15 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
);
}
ProviderOverviewCard.propTypes = {
item: PropTypes.shape({
id: PropTypes.string.isRequired,
provider: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
textIcon: PropTypes.string,
alias: PropTypes.string,
}).isRequired,
total: PropTypes.number.isRequired,
connected: PropTypes.number.isRequired,
errors: PropTypes.number.isRequired,
modelCount: PropTypes.number.isRequired,
authType: PropTypes.string.isRequired,
}).isRequired,
metrics: PropTypes.shape({
totalRequests: PropTypes.number,
totalSuccesses: PropTypes.number,
successRate: PropTypes.number,
avgLatencyMs: PropTypes.number,
}),
onClick: PropTypes.func.isRequired,
};
function ProviderModelsModal({ provider, models, onClose }) {
function ProviderModelsModal({
provider,
models,
onClose,
}: {
provider: ProviderSummaryItem;
models: ProviderModelSummary[];
onClose: () => void;
}) {
const [copiedModel, setCopiedModel] = useState(null);
const notify = useNotificationStore();
const router = useRouter();
@@ -966,9 +985,3 @@ function ProviderModelsModal({ provider, models, onClose }) {
</Modal>
);
}
ProviderModelsModal.propTypes = {
provider: PropTypes.object.isRequired,
models: PropTypes.array.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -5,13 +5,6 @@ import Link from "next/link";
import { Card, Button, Input } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { useTranslations } from "next-intl";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import {
CLI_COMPAT_PROVIDER_IDS,
CLI_COMPAT_TOGGLE_IDS,
normalizeCliCompatProviderId,
} from "@/shared/constants/cliCompatProviders";
interface AgentInfo {
id: string;
@@ -66,7 +59,6 @@ export default function AgentsPage() {
const [refreshing, setRefreshing] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [settings, setSettings] = useState<Record<string, any>>({});
const [newAgent, setNewAgent] = useState({
name: "",
binary: "",
@@ -74,7 +66,6 @@ export default function AgentsPage() {
spawnArgs: "",
});
const t = useTranslations("agents");
const ts = useTranslations("settings");
const fetchAgents = useCallback(async () => {
try {
@@ -91,34 +82,8 @@ export default function AgentsPage() {
useEffect(() => {
fetchAgents();
// Also fetch settings for CLI fingerprint
fetch("/api/settings")
.then((r) => r.json())
.then((d) => setSettings(d))
.catch(() => {});
}, [fetchAgents]);
const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) setSettings((prev) => ({ ...prev, [key]: value }));
} catch (err) {
console.error("Failed to update setting:", err);
}
};
const normalizedCliCompatProviders = Array.from(
new Set(
(settings.cliCompatProviders || [])
.map((providerId: string) => normalizeCliCompatProviderId(providerId))
.filter((providerId: string) => CLI_COMPAT_PROVIDER_IDS.includes(providerId))
)
);
const handleRefresh = async () => {
setRefreshing(true);
try {
@@ -215,20 +180,88 @@ export default function AgentsPage() {
{t("cliToolsRedirectCta")}
</Link>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="rounded-full bg-primary/10 px-3 py-1 font-medium text-primary">
{t("flowOmniRoute")}
</span>
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="rounded-full bg-amber-500/10 px-3 py-1 font-medium text-amber-600 dark:text-amber-400">
{t("flowSpawn")}
</span>
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="rounded-full bg-emerald-500/10 px-3 py-1 font-medium text-emerald-600 dark:text-emerald-400">
{t("flowLocalBinary")}
</span>
<span className="rounded-full bg-surface/60 px-3 py-1 font-medium text-text-main">
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="rounded-full bg-blue-500/10 px-3 py-1 font-medium text-blue-500">
{t("flowExecute")}
</span>
</div>
<div className="rounded-lg border border-border/30 bg-surface/20 p-4">
<div className="flex flex-col items-stretch gap-0 md:flex-row">
<div className="flex flex-1 flex-col items-center p-3 text-center">
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-text-main/10">
<span className="material-symbols-outlined text-[20px] text-text-main">
devices
</span>
</div>
<p className="text-xs font-semibold text-text-main">{t("flowDiagramClient")}</p>
<p className="mt-0.5 text-[10px] text-text-muted">{t("flowDiagramClientDesc")}</p>
</div>
<div className="flex items-center justify-center px-2 py-1 md:py-0">
<span className="material-symbols-outlined rotate-90 text-[20px] text-primary md:rotate-0">
arrow_forward
</span>
</div>
<div className="flex flex-1 flex-col items-center rounded-lg border border-primary/20 bg-primary/5 p-3 text-center">
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<span className="material-symbols-outlined text-[20px] text-primary">hub</span>
</div>
<p className="text-xs font-semibold text-primary">{t("flowDiagramOmniRoute")}</p>
<p className="mt-0.5 text-[10px] text-text-muted">
{t("flowDiagramOmniRouteDesc")}
</p>
</div>
<div className="flex items-center justify-center px-2 py-1 md:py-0">
<span className="material-symbols-outlined rotate-90 text-[20px] text-amber-500 md:rotate-0">
arrow_forward
</span>
</div>
<div className="flex flex-1 flex-col items-center rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-center">
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<span className="material-symbols-outlined text-[20px] text-amber-600 dark:text-amber-400">
launch
</span>
</div>
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400">
{t("flowDiagramSpawn")}
</p>
<p className="mt-0.5 text-[10px] text-text-muted">{t("flowDiagramSpawnDesc")}</p>
</div>
<div className="flex items-center justify-center px-2 py-1 md:py-0">
<span className="material-symbols-outlined rotate-90 text-[20px] text-emerald-500 md:rotate-0">
arrow_forward
</span>
</div>
<div className="flex flex-1 flex-col items-center rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 text-center">
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
<span className="material-symbols-outlined text-[20px] text-emerald-600 dark:text-emerald-400">
terminal
</span>
</div>
<p className="text-xs font-semibold text-emerald-600 dark:text-emerald-400">
{t("flowDiagramCli")}
</p>
<p className="mt-0.5 text-[10px] text-text-muted">{t("flowDiagramCliDesc")}</p>
</div>
</div>
</div>
<div className="rounded-lg border border-blue-500/15 bg-surface/40 p-3 text-sm text-text-muted">
<span className="font-medium text-text-main">{t("cliToolsRedirectTitle")}</span>{" "}
{t("cliToolsRedirectDesc")}{" "}
@@ -240,6 +273,67 @@ export default function AgentsPage() {
</div>
</Card>
<Card className="border-amber-500/20 bg-amber-500/5">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-amber-500/10 p-2 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
compare_arrows
</span>
</div>
<h3 className="text-sm font-semibold text-text-main">{t("comparisonTitle")}</h3>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-blue-500">
arrow_forward
</span>
<p className="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
{t("comparisonCliToolsLabel")}
</p>
</div>
<p className="mb-1 text-sm font-medium text-text-main">
{t("comparisonCliToolsTitle")}
</p>
<p className="text-xs text-text-muted">{t("comparisonCliToolsDesc")}</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-[11px] font-mono text-blue-500">
<span>IDE</span>
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
<span>OmniRoute</span>
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
<span>Provider API</span>
</div>
</div>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-emerald-500">
arrow_back
</span>
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t("comparisonAgentsLabel")}
</p>
</div>
<p className="mb-1 text-sm font-medium text-text-main">
{t("comparisonAgentsTitle")}
</p>
<p className="text-xs text-text-muted">{t("comparisonAgentsDesc")}</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-[11px] font-mono text-emerald-500">
<span>Client</span>
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
<span>OmniRoute</span>
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
<span>CLI Binary</span>
</div>
</div>
</div>
<p className="text-xs text-text-muted">{t("comparisonSummary")}</p>
</div>
</Card>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
@@ -305,69 +399,14 @@ export default function AgentsPage() {
<p className="text-xs text-text-muted">{t("setupGuideCommandMissingDesc")}</p>
</div>
</div>
</Card>
{/* CLI Fingerprint Matching */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
fingerprint
</span>
</div>
<h3 className="text-lg font-semibold">{ts("cliFingerprint")}</h3>
</div>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{ts("cliFingerprintDesc")}</p>
<div className="flex flex-wrap gap-2">
{CLI_COMPAT_TOGGLE_IDS.map((toggleId) => {
const providerId = normalizeCliCompatProviderId(toggleId);
const providerMeta = Object.values(AI_PROVIDERS).find((p: any) => p.id === providerId) as any;
const toolMeta = CLI_TOOLS[toggleId as keyof typeof CLI_TOOLS] as any;
const isEnabled = normalizedCliCompatProviders.includes(providerId);
const displayName = toolMeta?.name || providerMeta?.name || toggleId;
const icon = providerMeta?.icon || "terminal";
const color = providerMeta?.color || "#888";
return (
<button
key={toggleId}
onClick={() => {
const current = normalizedCliCompatProviders;
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
updateSetting("cliCompatProviders", updated);
}}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isEnabled
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
>
<span
className="material-symbols-outlined text-[14px]"
style={{ color: isEnabled ? undefined : color }}
>
{isEnabled ? "fingerprint" : icon}
</span>
{displayName}
{isEnabled && (
<span className="material-symbols-outlined text-[12px] text-emerald-500">
check
</span>
)}
</button>
);
})}
</div>
{normalizedCliCompatProviders.length > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">verified</span>
{ts("cliFingerprintEnabled", {
count: normalizedCliCompatProviders.length,
})}
</p>
)}
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border/30 bg-surface/20 p-3">
<span className="material-symbols-outlined text-[14px] text-text-muted">fingerprint</span>
<p className="text-xs text-text-muted">
{t("fingerprintSettingsHint")}{" "}
<Link href="/dashboard/settings" className="text-primary hover:underline">
{t("openSettings")}
</Link>
</p>
</div>
</Card>
@@ -419,9 +458,14 @@ export default function AgentsPage() {
</div>
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border/30">
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-500 font-mono">
{agent.protocol}
</span>
<div>
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-500 font-mono">
{agent.protocol}
</span>
{agent.installed && (
<p className="mt-1 text-[10px] text-text-muted">{t("agentUseCaseHint")}</p>
)}
</div>
{agent.isCustom && (
<button
onClick={() => handleRemoveAgent(agent.id)}

View File

@@ -0,0 +1,385 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
type AuditEntry = {
id: number;
timestamp: string;
action: string;
actor: string;
target?: string | null;
details?: unknown;
metadata?: unknown;
ip_address?: string | null;
ip?: string | null;
resourceType?: string | null;
status?: string | null;
requestId?: string | null;
};
type Severity = "info" | "warning" | "critical";
const PAGE_SIZE = 50;
function getSeverity(entry: AuditEntry): Severity {
const action = entry.action.toLowerCase();
const status = (entry.status || "").toLowerCase();
if (
status === "error" ||
status === "failed" ||
status === "blocked" ||
action.includes("blocked") ||
action.includes("denied") ||
action.includes("violation") ||
action.includes("delete") ||
action.includes("remove")
) {
return "critical";
}
if (status === "warning" || action.includes("warning") || action.includes("validate")) {
return "warning";
}
return "info";
}
function formatJson(value: unknown) {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatLocalDate(value: string) {
if (!value) return "";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
export default function ComplianceTab() {
const t = useTranslations("compliance");
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [eventType, setEventType] = useState("");
const [severity, setSeverity] = useState<"all" | Severity>("all");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [offset, setOffset] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [selectedEntry, setSelectedEntry] = useState<AuditEntry | null>(null);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
params.set("offset", String(offset));
if (eventType) params.set("action", eventType);
if (from) params.set("from", from);
if (to) params.set("to", to);
const response = await fetch(`/api/compliance/audit-log?${params.toString()}`);
const data = await response.json().catch(() => []);
if (!response.ok) {
throw new Error(data.error || t("failedFetch"));
}
setEntries(Array.isArray(data) ? data : []);
const total = Number(response.headers.get("x-total-count") || "0");
setTotalCount(Number.isFinite(total) ? total : 0);
} catch (err) {
setError(err instanceof Error ? err.message : t("failedFetch"));
} finally {
setLoading(false);
}
}, [eventType, from, offset, t, to]);
useEffect(() => {
void fetchEntries();
}, [fetchEntries]);
const visibleEntries = useMemo(() => {
if (severity === "all") return entries;
return entries.filter((entry) => getSeverity(entry) === severity);
}, [entries, severity]);
const eventTypes = useMemo(() => {
return Array.from(new Set(entries.map((entry) => entry.action).filter(Boolean))).sort();
}, [entries]);
const canGoNext = offset + PAGE_SIZE < totalCount;
const resetFilters = () => {
setEventType("");
setSeverity("all");
setFrom("");
setTo("");
setOffset(0);
};
const exportVisibleEntries = () => {
const payload = JSON.stringify(visibleEntries, null, 2);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `omniroute-compliance-audit-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const severityClass = (value: Severity) => {
if (value === "critical") return "border-red-500/30 bg-red-500/10 text-red-600";
if (value === "warning") return "border-amber-500/30 bg-amber-500/10 text-amber-600";
return "border-blue-500/30 bg-blue-500/10 text-blue-600";
};
return (
<div className="space-y-5">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-text-main">{t("title")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("description")}</p>
<p className="mt-2 text-xs text-text-muted">
{t("showing", { count: visibleEntries.length, total: totalCount })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => void fetchEntries()}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[16px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
{t("refresh")}
</button>
<button
onClick={exportVisibleEntries}
disabled={visibleEntries.length === 0}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:opacity-40"
>
<span className="material-symbols-outlined text-[16px]">download</span>
{t("export")}
</button>
</div>
</div>
</Card>
<Card className="p-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("eventType")}
</span>
<input
list="compliance-event-types"
value={eventType}
onChange={(event) => {
setOffset(0);
setEventType(event.target.value);
}}
placeholder={t("eventTypePlaceholder")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<datalist id="compliance-event-types">
{eventTypes.map((type) => (
<option key={type} value={type} />
))}
</datalist>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("severity")}
</span>
<select
value={severity}
onChange={(event) => {
setOffset(0);
setSeverity(event.target.value as "all" | Severity);
}}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
>
<option value="all">{t("allSeverities")}</option>
<option value="info">{t("info")}</option>
<option value="warning">{t("warning")}</option>
<option value="critical">{t("critical")}</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("from")}
</span>
<input
type="datetime-local"
value={from}
onChange={(event) => {
setOffset(0);
setFrom(event.target.value);
}}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("to")}
</span>
<input
type="datetime-local"
value={to}
onChange={(event) => {
setOffset(0);
setTo(event.target.value);
}}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
<div className="flex items-end">
<button
onClick={resetFilters}
className="w-full rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar"
>
{t("clearFilters")}
</button>
</div>
</div>
</Card>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
<Card className="overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-text-muted">{t("loading")}</div>
) : visibleEntries.length === 0 ? (
<div className="p-10 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted">policy</span>
<p className="mt-3 text-sm text-text-muted">{t("noEvents")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[1040px] text-left text-sm">
<thead className="border-b border-border bg-sidebar/40 text-xs uppercase tracking-wider text-text-muted">
<tr>
<th className="px-4 py-3 font-medium">{t("timestamp")}</th>
<th className="px-4 py-3 font-medium">{t("eventType")}</th>
<th className="px-4 py-3 font-medium">{t("severity")}</th>
<th className="px-4 py-3 font-medium">{t("sourceIp")}</th>
<th className="px-4 py-3 font-medium">{t("userOrKey")}</th>
<th className="px-4 py-3 font-medium">{t("action")}</th>
<th className="px-4 py-3 font-medium">{t("result")}</th>
<th className="px-4 py-3 text-right font-medium">{t("details")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visibleEntries.map((entry) => {
const entrySeverity = getSeverity(entry);
return (
<tr key={entry.id} className="transition-colors hover:bg-sidebar/30">
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-text-muted">
{formatLocalDate(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span className="rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-text-main">
{entry.action}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${severityClass(entrySeverity)}`}
>
{t(entrySeverity)}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-text-muted">
{entry.ip_address || entry.ip || t("notAvailable")}
</td>
<td className="px-4 py-3 text-text-main">{entry.actor || t("system")}</td>
<td className="max-w-[220px] truncate px-4 py-3 text-text-muted">
{entry.target || entry.resourceType || t("notAvailable")}
</td>
<td className="px-4 py-3 text-text-muted">
{entry.status || t("notAvailable")}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setSelectedEntry(entry)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-sidebar"
>
{t("viewDetails")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setOffset((current) => Math.max(0, current - PAGE_SIZE))}
disabled={offset === 0 || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("previous")}
</button>
<button
onClick={() => setOffset((current) => current + PAGE_SIZE)}
disabled={!canGoNext || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("next")}
</button>
</div>
{selectedEntry && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
aria-label={t("closeDetails")}
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={() => setSelectedEntry(null)}
/>
<div className="relative w-full max-w-3xl rounded-xl border border-border bg-surface shadow-2xl">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-semibold text-text-main">{t("details")}</h3>
<button
onClick={() => setSelectedEntry(null)}
className="rounded-lg p-2 text-text-muted hover:bg-sidebar hover:text-text-main"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<pre className="max-h-[70vh] overflow-auto p-4 text-xs text-text-main">
{formatJson(selectedEntry)}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,246 @@
import { redirect } from "next/navigation";
"use client";
export default function ConfigAuditPage() {
redirect("/dashboard/logs?tab=audit-logs");
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, SegmentedControl } from "@/shared/components";
import ComplianceTab from "./ComplianceTab";
type McpAuditEntry = {
id: number;
toolName: string;
inputHash: string;
outputSummary: string;
durationMs: number;
apiKeyId: string | null;
success: boolean;
errorCode: string | null;
createdAt: string;
};
type McpAuditResponse = {
entries: McpAuditEntry[];
total: number;
limit: number;
offset: number;
};
const MCP_PAGE_SIZE = 25;
function McpAuditTab() {
const t = useTranslations("compliance");
const [data, setData] = useState<McpAuditResponse>({
entries: [],
total: 0,
limit: MCP_PAGE_SIZE,
offset: 0,
});
const [loading, setLoading] = useState(true);
const [toolFilter, setToolFilter] = useState("");
const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all");
const [offset, setOffset] = useState(0);
const fetchAudit = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(MCP_PAGE_SIZE));
params.set("offset", String(offset));
if (toolFilter) params.set("tool", toolFilter);
if (successFilter !== "all") params.set("success", successFilter);
const response = await fetch(`/api/mcp/audit?${params.toString()}`);
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(json.error || t("failedFetchMcpAudit"));
}
setData({
entries: Array.isArray(json.entries) ? json.entries : [],
total: Number(json.total || 0),
limit: Number(json.limit || MCP_PAGE_SIZE),
offset: Number(json.offset || offset),
});
} finally {
setLoading(false);
}
}, [offset, successFilter, t, toolFilter]);
useEffect(() => {
void fetchAudit();
}, [fetchAudit]);
return (
<div className="space-y-5">
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-text-main">{t("mcpAudit")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("mcpAuditDesc")}</p>
<p className="mt-2 text-xs text-text-muted">
{t("showing", { count: data.entries.length, total: data.total })}
</p>
</div>
<button
onClick={() => void fetchAudit()}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[16px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
{t("refresh")}
</button>
</div>
</Card>
<Card className="p-4">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("tool")}
</span>
<input
value={toolFilter}
onChange={(event) => {
setOffset(0);
setToolFilter(event.target.value);
}}
placeholder={t("toolPlaceholder")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("result")}
</span>
<select
value={successFilter}
onChange={(event) => {
setOffset(0);
setSuccessFilter(event.target.value as "all" | "true" | "false");
}}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
>
<option value="all">{t("allResults")}</option>
<option value="true">{t("success")}</option>
<option value="false">{t("failure")}</option>
</select>
</label>
<div className="flex items-end">
<button
onClick={() => {
setToolFilter("");
setSuccessFilter("all");
setOffset(0);
}}
className="w-full rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar"
>
{t("clearFilters")}
</button>
</div>
</div>
</Card>
<Card className="overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-text-muted">{t("loading")}</div>
) : data.entries.length === 0 ? (
<div className="p-10 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted">terminal</span>
<p className="mt-3 text-sm text-text-muted">{t("noMcpEvents")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-left text-sm">
<thead className="border-b border-border bg-sidebar/40 text-xs uppercase tracking-wider text-text-muted">
<tr>
<th className="px-4 py-3 font-medium">{t("timestamp")}</th>
<th className="px-4 py-3 font-medium">{t("tool")}</th>
<th className="px-4 py-3 font-medium">{t("duration")}</th>
<th className="px-4 py-3 font-medium">{t("result")}</th>
<th className="px-4 py-3 font-medium">{t("apiKey")}</th>
<th className="px-4 py-3 font-medium">{t("output")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.entries.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-sidebar/30">
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-text-muted">
{new Date(entry.createdAt).toLocaleString()}
</td>
<td className="px-4 py-3 font-mono text-xs text-text-main">{entry.toolName}</td>
<td className="px-4 py-3 text-text-muted">{entry.durationMs}ms</td>
<td className="px-4 py-3">
<span
className={`rounded-full border px-2 py-1 text-xs font-medium ${
entry.success
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "border-red-500/30 bg-red-500/10 text-red-600"
}`}
>
{entry.success ? t("success") : entry.errorCode || t("failure")}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{entry.apiKeyId || t("notAvailable")}
</td>
<td className="max-w-[280px] truncate px-4 py-3 text-xs text-text-muted">
{entry.outputSummary || t("notAvailable")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setOffset((current) => Math.max(0, current - MCP_PAGE_SIZE))}
disabled={offset === 0 || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("previous")}
</button>
<button
onClick={() => setOffset((current) => current + MCP_PAGE_SIZE)}
disabled={offset + MCP_PAGE_SIZE >= data.total || loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("next")}
</button>
</div>
</div>
);
}
export default function AuditPage() {
const t = useTranslations("compliance");
const [activeTab, setActiveTab] = useState("compliance");
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[24px] text-primary">policy</span>
<h1 className="text-3xl font-bold tracking-tight text-text-main">{t("auditTitle")}</h1>
</div>
<p className="mt-1 text-sm text-text-muted">{t("auditDescription")}</p>
</div>
<SegmentedControl
options={[
{ value: "compliance", label: t("complianceTab") },
{ value: "mcp", label: t("mcpTab") },
]}
value={activeTab}
onChange={setActiveTab}
/>
{activeTab === "compliance" ? <ComplianceTab /> : <McpAuditTab />}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Card, EmptyState, SegmentedControl, CardSkeleton } from "@/shared/components";
import {
@@ -14,6 +14,8 @@ import {
XAxis,
YAxis,
CartesianGrid,
BarChart,
Bar,
} from "recharts";
type CostRange = "7d" | "30d" | "90d" | "all";
@@ -24,6 +26,13 @@ interface UsageAnalyticsSummary {
uniqueModels: number;
uniqueAccounts: number;
uniqueApiKeys: number;
totalTokens: number;
promptTokens: number;
completionTokens: number;
fallbackCount: number;
fallbackRatePct: number;
requestedModelCoveragePct: number;
streak: number;
}
interface UsageAnalyticsProviderRow {
@@ -45,11 +54,34 @@ interface UsageAnalyticsTrendRow {
cost: number;
}
interface UsageAnalyticsApiKeyRow {
apiKey: string;
apiKeyId: string | null;
apiKeyName: string;
requests: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cost: number;
}
interface UsageAnalyticsAccountRow {
account: string;
totalTokens: number;
requests: number;
cost: number;
}
interface UsageAnalyticsPayload {
summary: UsageAnalyticsSummary;
byProvider: UsageAnalyticsProviderRow[];
byModel: UsageAnalyticsModelRow[];
byApiKey: UsageAnalyticsApiKeyRow[];
byAccount: UsageAnalyticsAccountRow[];
dailyTrend: UsageAnalyticsTrendRow[];
weeklyPattern: Array<{ day: string; avgTokens: number; totalTokens: number }>;
activityMap: Record<string, number>;
presetSummaries?: Record<string, { totalCost: number }>;
}
const RANGE_OPTIONS: Array<{ value: CostRange; labelKey: string }> = [
@@ -79,6 +111,104 @@ function createCurrencyFormatter(locale: string) {
});
}
function csvCell(value: string | number): string {
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function generateCSV(analytics: UsageAnalyticsPayload, locale: string): string {
const currencyFormatter = createCurrencyFormatter(locale);
const lines: string[] = [];
lines.push("# OmniRoute Cost Report");
lines.push(`# Generated: ${new Date().toISOString()}`);
lines.push("");
lines.push("## Summary");
lines.push("Metric,Value");
lines.push(`Total Cost,${csvCell(currencyFormatter.format(analytics.summary.totalCost))}`);
lines.push(`Total Requests,${analytics.summary.totalRequests}`);
lines.push(`Unique Models,${analytics.summary.uniqueModels}`);
lines.push(`Unique Accounts,${analytics.summary.uniqueAccounts}`);
lines.push(`Total Tokens,${analytics.summary.totalTokens}`);
lines.push("");
lines.push("## Daily Cost Trend");
lines.push("Date,Cost (USD)");
for (const row of analytics.dailyTrend) {
lines.push(`${csvCell(row.date)},${row.cost.toFixed(6)}`);
}
lines.push("");
lines.push("## Cost by Provider");
lines.push("Provider,Requests,Total Tokens,Cost (USD)");
for (const row of analytics.byProvider) {
lines.push(
[row.provider, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",")
);
}
lines.push("");
lines.push("## Cost by Model");
lines.push("Model,Requests,Total Tokens,Cost (USD)");
for (const row of analytics.byModel) {
lines.push(
[row.model, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",")
);
}
lines.push("");
lines.push("## Cost by API Key");
lines.push("API Key,Requests,Total Tokens,Cost (USD)");
for (const row of analytics.byApiKey || []) {
lines.push(
[row.apiKeyName || row.apiKey, row.requests, row.totalTokens, row.cost.toFixed(6)]
.map(csvCell)
.join(",")
);
}
lines.push("");
lines.push("## Cost by Account");
lines.push("Account,Requests,Total Tokens,Cost (USD)");
for (const row of analytics.byAccount || []) {
lines.push(
[row.account, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",")
);
}
return lines.join("\n");
}
function generateJSON(analytics: UsageAnalyticsPayload): string {
return JSON.stringify(
{
generatedAt: new Date().toISOString(),
summary: analytics.summary,
dailyTrend: analytics.dailyTrend,
weeklyPattern: analytics.weeklyPattern,
activityMap: analytics.activityMap,
byProvider: analytics.byProvider,
byModel: analytics.byModel,
byApiKey: analytics.byApiKey || [],
byAccount: analytics.byAccount || [],
},
null,
2
);
}
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export default function CostOverviewTab() {
const t = useTranslations("costs");
const locale = useLocale();
@@ -94,33 +224,37 @@ export default function CostOverviewTab() {
const [summaryLoading, setSummaryLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAnalytics = useCallback(
async (requestedRange: string) => {
const response = await fetch(`/api/usage/analytics?range=${requestedRange}`);
if (!response.ok) {
throw new Error(t("overviewLoadFailed"));
}
return (await response.json()) as UsageAnalyticsPayload;
},
[t]
);
useEffect(() => {
let active = true;
async function loadRange() {
try {
setLoading(true);
const payload = await fetchAnalytics(range);
setSummaryLoading(true);
const response = await fetch(
`/api/usage/analytics?range=${encodeURIComponent(range)}&presets=1d,7d,30d`
);
if (!response.ok) {
throw new Error(t("overviewLoadFailed"));
}
const payload = (await response.json()) as UsageAnalyticsPayload;
if (!active) return;
setAnalytics(payload);
if (payload.presetSummaries) {
setPresetCosts({
"1d": payload.presetSummaries["1d"]?.totalCost || 0,
"7d": payload.presetSummaries["7d"]?.totalCost || 0,
"30d": payload.presetSummaries["30d"]?.totalCost || 0,
});
}
setError(null);
} catch (loadError: any) {
} catch (loadError) {
if (!active) return;
setError(loadError?.message || t("overviewLoadFailed"));
setError(loadError instanceof Error ? loadError.message : t("overviewLoadFailed"));
} finally {
if (active) {
setLoading(false);
setSummaryLoading(false);
}
}
}
@@ -130,38 +264,7 @@ export default function CostOverviewTab() {
return () => {
active = false;
};
}, [fetchAnalytics, range, t]);
useEffect(() => {
let active = true;
async function loadPresets() {
try {
setSummaryLoading(true);
const [day, week, month] = await Promise.all([
fetchAnalytics("1d"),
fetchAnalytics("7d"),
fetchAnalytics("30d"),
]);
if (!active) return;
setPresetCosts({
"1d": day.summary?.totalCost || 0,
"7d": week.summary?.totalCost || 0,
"30d": month.summary?.totalCost || 0,
});
} finally {
if (active) {
setSummaryLoading(false);
}
}
}
void loadPresets();
return () => {
active = false;
};
}, [fetchAnalytics]);
}, [range, t]);
const selectedRangeLabel = t(
RANGE_OPTIONS.find((option) => option.value === range)?.labelKey || "range30d"
@@ -172,6 +275,13 @@ export default function CostOverviewTab() {
uniqueModels: 0,
uniqueAccounts: 0,
uniqueApiKeys: 0,
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
fallbackCount: 0,
fallbackRatePct: 0,
requestedModelCoveragePct: 0,
streak: 0,
};
const providersByCost = [...(analytics?.byProvider || [])]
.filter((provider) => provider.cost > 0)
@@ -179,8 +289,37 @@ export default function CostOverviewTab() {
const modelsByCost = [...(analytics?.byModel || [])]
.filter((model) => model.cost > 0)
.sort((left, right) => right.cost - left.cost);
const apiKeysByCost = [...(analytics?.byApiKey || [])]
.filter((apiKey) => apiKey.cost > 0)
.sort((left, right) => right.cost - left.cost);
const accountsByCost = [...(analytics?.byAccount || [])]
.filter((account) => account.cost > 0)
.sort((left, right) => right.cost - left.cost);
const avgCostPerRequest =
summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0;
const dailyTrend = analytics?.dailyTrend || [];
const recentDays = dailyTrend.slice(-7);
const avgDailyCost =
recentDays.length > 0
? recentDays.reduce((sum, day) => sum + (day.cost || 0), 0) / recentDays.length
: 0;
const today = new Date();
const daysRemainingInMonth =
new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate();
const projectedMonthEnd =
(presetCosts["30d"] || summary.totalCost) + avgDailyCost * daysRemainingInMonth;
const trendLength = dailyTrend.length;
const halfLength = Math.floor(trendLength / 2);
const firstHalf = dailyTrend.slice(0, halfLength);
const secondHalf = dailyTrend.slice(halfLength);
const firstHalfCost = firstHalf.reduce((sum, day) => sum + (day.cost || 0), 0);
const secondHalfCost = secondHalf.reduce((sum, day) => sum + (day.cost || 0), 0);
const costChangePct =
firstHalfCost > 0
? ((secondHalfCost - firstHalfCost) / firstHalfCost) * 100
: secondHalfCost > 0
? 100
: 0;
if (loading && !analytics) {
return <CardSkeleton />;
@@ -202,14 +341,57 @@ export default function CostOverviewTab() {
<h2 className="text-xl font-bold text-text-main">{t("overviewTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("overviewDescription")}</p>
</div>
<SegmentedControl
options={RANGE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
value={range}
onChange={(value) => setRange(value as CostRange)}
/>
<div className="flex flex-wrap items-center gap-3">
{summary.streak > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/20">
<span className="material-symbols-outlined text-amber-400 text-sm">
local_fire_department
</span>
<span className="text-sm font-semibold text-amber-400">{summary.streak}</span>
<span className="text-xs text-amber-400/70">{t("dayStreak")}</span>
</div>
)}
{analytics && summary.totalCost > 0 && (
<div className="flex items-center gap-1">
<button
onClick={() => {
const csv = generateCSV(analytics, locale);
const dateStr = new Date().toISOString().slice(0, 10);
downloadFile(csv, `omniroute-costs-${range}-${dateStr}.csv`, "text/csv");
}}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs text-text-muted hover:text-text-main hover:bg-surface/50 rounded-lg border border-border/30 transition-colors"
title={t("exportCSV")}
>
<span className="material-symbols-outlined text-sm">download</span>
CSV
</button>
<button
onClick={() => {
const json = generateJSON(analytics);
const dateStr = new Date().toISOString().slice(0, 10);
downloadFile(
json,
`omniroute-costs-${range}-${dateStr}.json`,
"application/json"
);
}}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs text-text-muted hover:text-text-main hover:bg-surface/50 rounded-lg border border-border/30 transition-colors"
title={t("exportJSON")}
>
<span className="material-symbols-outlined text-sm">download</span>
JSON
</button>
</div>
)}
<SegmentedControl
options={RANGE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
value={range}
onChange={(value) => setRange(value as CostRange)}
/>
</div>
</div>
</Card>
@@ -261,6 +443,184 @@ export default function CostOverviewTab() {
</div>
</Card>
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{t("tokenUsage")}
</h3>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<CompactMetric
label={t("totalTokens")}
value={new Intl.NumberFormat(locale, { notation: "compact" }).format(
summary.totalTokens || 0
)}
/>
<CompactMetric
label={t("inputTokens")}
value={new Intl.NumberFormat(locale, { notation: "compact" }).format(
summary.promptTokens || 0
)}
/>
<CompactMetric
label={t("outputTokens")}
value={new Intl.NumberFormat(locale, { notation: "compact" }).format(
summary.completionTokens || 0
)}
/>
<CompactMetric
label={t("inputOutputRatio")}
value={
summary.completionTokens > 0
? `${(summary.promptTokens / summary.completionTokens).toFixed(1)}:1`
: "-"
}
/>
</div>
</Card>
{summary.totalRequests > 0 && (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{t("routingEfficiency")}
</h3>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">
{t("fallbackCount")}
</p>
<p className="text-lg font-semibold text-text-main mt-1">
{new Intl.NumberFormat(locale).format(summary.fallbackCount || 0)}
</p>
<p className="text-xs text-text-muted mt-1">
{t("outOfRequests", {
total: new Intl.NumberFormat(locale).format(summary.totalRequests),
})}
</p>
</div>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">
{t("fallbackRate")}
</p>
<div className="flex items-center gap-2 mt-1">
<p
className={`text-lg font-semibold ${
(summary.fallbackRatePct || 0) > 10
? "text-red-400"
: (summary.fallbackRatePct || 0) > 5
? "text-amber-400"
: "text-emerald-400"
}`}
>
{(summary.fallbackRatePct || 0).toFixed(1)}%
</p>
<span
className="material-symbols-outlined text-sm"
style={{
color:
(summary.fallbackRatePct || 0) > 10
? "#f87171"
: (summary.fallbackRatePct || 0) > 5
? "#fbbf24"
: "#34d399",
}}
>
{(summary.fallbackRatePct || 0) > 5 ? "warning" : "check_circle"}
</span>
</div>
</div>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">
{t("modelCoverage")}
</p>
<p className="text-lg font-semibold text-text-main mt-1">
{(summary.requestedModelCoveragePct || 0).toFixed(1)}%
</p>
<p className="text-xs text-text-muted mt-1">{t("modelCoverageDesc")}</p>
</div>
</div>
</Card>
)}
{summary.totalCost > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="p-5">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sky-400 text-lg">trending_up</span>
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide">
{t("monthlyForecast")}
</h3>
</div>
<div className="flex items-end gap-3">
<p className="text-3xl font-bold text-sky-400">
{currencyFormatter.format(projectedMonthEnd)}
</p>
<p className="text-xs text-text-muted pb-1">
{t("forecastBasis", { days: recentDays.length })}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-muted">
<span>{t("avgDailyCost")}:</span>
<span className="font-mono">{currencyFormatter.format(avgDailyCost)}</span>
<span>/</span>
<span>{t("daysRemaining", { days: daysRemainingInMonth })}</span>
</div>
</Card>
<Card className="p-5">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-violet-400 text-lg">
compare_arrows
</span>
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide">
{t("periodComparison")}
</h3>
</div>
<div className="flex items-end gap-3">
<p
className={`text-3xl font-bold ${
costChangePct > 0
? "text-red-400"
: costChangePct < 0
? "text-emerald-400"
: "text-text-main"
}`}
>
{costChangePct > 0 ? "+" : ""}
{costChangePct.toFixed(1)}%
</p>
<span
className={`material-symbols-outlined text-lg pb-1 ${
costChangePct > 0
? "text-red-400"
: costChangePct < 0
? "text-emerald-400"
: "text-text-muted"
}`}
>
{costChangePct > 0
? "arrow_upward"
: costChangePct < 0
? "arrow_downward"
: "remove"}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
<div className="text-text-muted">
<p>{t("previousPeriod")}</p>
<p className="font-mono text-text-main">
{currencyFormatter.format(firstHalfCost)}
</p>
</div>
<div className="text-text-muted">
<p>{t("currentPeriod")}</p>
<p className="font-mono text-text-main">
{currencyFormatter.format(secondHalfCost)}
</p>
</div>
</div>
</Card>
</div>
)}
{summary.totalCost <= 0 ? (
<Card className="p-6">
<EmptyState
@@ -285,6 +645,8 @@ export default function CostOverviewTab() {
title={t("topProviders")}
nameKey="provider"
valueKey="cost"
secondaryKey="totalTokens"
secondaryLabel={t("tokens")}
rows={providersByCost}
locale={locale}
/>
@@ -292,10 +654,70 @@ export default function CostOverviewTab() {
title={t("topModels")}
nameKey="model"
valueKey="cost"
secondaryKey="totalTokens"
secondaryLabel={t("tokens")}
rows={modelsByCost}
locale={locale}
/>
</div>
{(apiKeysByCost.length > 0 || accountsByCost.length > 0) && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{apiKeysByCost.length > 0 && (
<CostBreakdownTable
title={t("costByApiKey")}
rows={apiKeysByCost.slice(0, 8)}
columns={[
{ key: "apiKeyName", label: t("apiKeyName"), align: "left" },
{ key: "requests", label: t("requests"), align: "right", format: "number" },
{
key: "totalTokens",
label: t("tokens"),
align: "right",
format: "compact",
},
{ key: "cost", label: t("cost"), align: "right", format: "currency" },
]}
locale={locale}
/>
)}
{accountsByCost.length > 0 && (
<CostBreakdownTable
title={t("costByAccount")}
rows={accountsByCost.slice(0, 8)}
columns={[
{ key: "account", label: t("account"), align: "left" },
{ key: "requests", label: t("requests"), align: "right", format: "number" },
{
key: "totalTokens",
label: t("tokens"),
align: "right",
format: "compact",
},
{ key: "cost", label: t("cost"), align: "right", format: "currency" },
]}
locale={locale}
/>
)}
</div>
)}
{summary.totalRequests > 0 && (
<div className="grid grid-cols-1 xl:grid-cols-[1fr_1.5fr] gap-4">
<WeeklyPatternCard
title={t("weeklyUsagePattern")}
rows={analytics?.weeklyPattern || []}
locale={locale}
/>
<ActivityHeatmap
title={t("activityHeatmap")}
activityMap={analytics?.activityMap || {}}
lessLabel={t("less")}
moreLabel={t("more")}
locale={locale}
/>
</div>
)}
</>
)}
</div>
@@ -463,17 +885,154 @@ function CostTrendCard({
);
}
function WeeklyPatternCard({
title,
rows,
locale,
}: {
title: string;
rows: Array<{ day: string; avgTokens: number; totalTokens: number }>;
locale: string;
}) {
const chartData = rows.map((row) => ({
day: row.day,
tokens: row.avgTokens || 0,
}));
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="h-[160px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 0 }}>
<XAxis
dataKey="day"
tick={{ fontSize: 11, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
tickFormatter={(value) =>
new Intl.NumberFormat(locale, { notation: "compact" }).format(Number(value || 0))
}
width={40}
/>
<Tooltip
formatter={(value: number) =>
`${new Intl.NumberFormat(locale).format(value || 0)} tokens`
}
contentStyle={{
background: "var(--surface)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "12px",
}}
/>
<Bar dataKey="tokens" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
);
}
function ActivityHeatmap({
title,
activityMap,
lessLabel,
moreLabel,
locale,
}: {
title: string;
activityMap: Record<string, number>;
lessLabel: string;
moreLabel: string;
locale: string;
}) {
const days: Array<{ date: string; value: number }> = [];
const today = new Date();
for (let index = 364; index >= 0; index--) {
const date = new Date(today);
date.setDate(date.getDate() - index);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate()
).padStart(2, "0")}`;
days.push({ date: key, value: activityMap[key] || 0 });
}
const maxValue = Math.max(...days.map((day) => day.value), 1);
const getIntensity = (value: number): string => {
if (value === 0) return "bg-surface/30";
const ratio = value / maxValue;
if (ratio < 0.25) return "bg-emerald-900/50";
if (ratio < 0.5) return "bg-emerald-700/60";
if (ratio < 0.75) return "bg-emerald-500/70";
return "bg-emerald-400";
};
const weeks: Array<Array<{ date: string; value: number }>> = [];
for (let index = 0; index < days.length; index += 7) {
weeks.push(days.slice(index, index + 7));
}
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="overflow-x-auto">
<div className="flex gap-[3px]">
{weeks.map((week) => (
<div key={week[0]?.date} className="flex flex-col gap-[3px]">
{week.map((day) => (
<div
key={day.date}
className={`w-[11px] h-[11px] rounded-[2px] ${getIntensity(day.value)}`}
title={`${day.date}: ${
day.value > 0
? `${new Intl.NumberFormat(locale).format(day.value)} tokens`
: "No activity"
}`}
/>
))}
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 mt-3 text-[10px] text-text-muted">
<span>{lessLabel}</span>
<div className="flex gap-[2px]">
<div className="w-[10px] h-[10px] rounded-[2px] bg-surface/30" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-emerald-900/50" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-emerald-700/60" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-emerald-500/70" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-emerald-400" />
</div>
<span>{moreLabel}</span>
</div>
</Card>
);
}
function TopListCard({
title,
rows,
nameKey,
valueKey,
secondaryKey,
secondaryLabel,
locale,
}: {
title: string;
rows: Array<Record<string, string | number>>;
nameKey: string;
valueKey: string;
secondaryKey?: string;
secondaryLabel?: string;
locale: string;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
@@ -490,12 +1049,101 @@ function TopListCard({
className="flex items-center justify-between gap-3 rounded-lg border border-border/20 bg-surface/20 px-4 py-3"
>
<span className="text-sm text-text-main truncate">{String(row[nameKey])}</span>
<span className="text-sm font-mono text-text-muted">
{currencyFormatter.format(Number(row[valueKey] || 0))}
</span>
<div className="flex items-center gap-3 shrink-0">
{secondaryKey ? (
<span className="text-xs text-text-muted">
{new Intl.NumberFormat(locale, { notation: "compact" }).format(
Number(row[secondaryKey] || 0)
)}{" "}
{secondaryLabel}
</span>
) : null}
<span className="text-sm font-mono text-text-muted">
{currencyFormatter.format(Number(row[valueKey] || 0))}
</span>
</div>
</div>
))}
</div>
</Card>
);
}
interface ColumnDef {
key: string;
label: string;
align: "left" | "right";
format?: "number" | "compact" | "currency";
}
function CostBreakdownTable({
title,
rows,
columns,
locale,
}: {
title: string;
rows: Array<Record<string, string | number | null>>;
columns: ColumnDef[];
locale: string;
}) {
const currencyFormatter = createCurrencyFormatter(locale);
function formatValue(value: unknown, format?: ColumnDef["format"]): string {
const num = Number(value || 0);
switch (format) {
case "currency":
return currencyFormatter.format(num);
case "compact":
return new Intl.NumberFormat(locale, { notation: "compact" }).format(num);
case "number":
return new Intl.NumberFormat(locale).format(num);
default:
return String(value ?? "-");
}
}
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide mb-4">
{title}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] text-text-muted uppercase border-b border-border/30">
{columns.map((column) => (
<th
key={column.key}
className={`pb-2 font-semibold ${
column.align === "right" ? "text-right" : "text-left"
}`}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{rows.map((row) => (
<tr key={String(row[columns[0].key])} className="hover:bg-surface/20">
{columns.map((column) => (
<td
key={column.key}
className={`py-2 ${
column.align === "right"
? "text-right font-mono text-text-muted"
: "text-left text-text-main truncate max-w-[200px]"
}`}
>
{formatValue(row[column.key], column.format)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -66,6 +66,7 @@ const WEBHOOK_EVENTS = [
/* ─── Main Component ─────────────────────────────────── */
export default function ApiEndpointsTab() {
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [section, setSection] = useState<"catalog" | "webhooks">("catalog");
const [search, setSearch] = useState("");
@@ -93,17 +94,26 @@ export default function ApiEndpointsTab() {
const res = await fetch("/api/openapi/spec");
if (res.ok) {
const data = await res.json();
return data;
return { data: data as CatalogData, error: null };
}
} catch {}
return null;
const body = await res.json().catch(() => null);
const message =
body && typeof body.error === "string"
? body.error
: `API catalog request failed with HTTP ${res.status}`;
return { data: null, error: message };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load API catalog";
return { data: null, error: message };
}
};
useEffect(() => {
let cancelled = false;
loadCatalog().then((data) => {
loadCatalog().then((result) => {
if (!cancelled) {
setCatalog(data);
setCatalog(result.data);
setCatalogError(result.error);
setLoading(false);
}
});
@@ -340,6 +350,32 @@ export default function ApiEndpointsTab() {
</div>
{/* ═══ API CATALOG ═══ */}
{section === "catalog" && !catalog && (
<Card className="p-6">
<div className="flex items-start gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-red-500/10">
<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>
<p className="text-xs text-text-muted mt-1">
{catalogError || "The OpenAPI specification could not be loaded."}
</p>
<a
href="/api/openapi/spec"
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1 mt-3 px-2.5 py-1.5 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Open JSON response
</a>
</div>
</div>
</Card>
)}
{section === "catalog" && catalog && (
<>
{/* Search & filter */}

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -88,14 +87,39 @@ type TunnelNotice = {
message: string;
};
type APIPageClientProps = {
machineId: string;
};
type EndpointProviderSummary = {
id: string;
provider: {
name: string;
alias?: string;
};
};
type EndpointModelSummary = {
id: string;
owned_by?: string;
parent?: string;
type?: string;
custom?: boolean;
root?: string;
};
type CopyHandler = (text: string, key?: string) => void | Promise<void>;
type EndpointTunnelVisibility = {
showCloudflaredTunnel: boolean;
showTailscaleFunnel: boolean;
showNgrokTunnel: boolean;
};
const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = {
showCloudflaredTunnel: true,
showTailscaleFunnel: true,
showNgrokTunnel: true,
};
function runEndpointBackgroundTask(taskName: string, task: () => Promise<unknown>) {
@@ -104,7 +128,7 @@ function runEndpointBackgroundTask(taskName: string, task: () => Promise<unknown
});
}
export default function APIPageClient({ machineId }) {
export default function APIPageClient({ machineId }: APIPageClientProps) {
const [resolvedMachineId, setResolvedMachineId] = useState(machineId || "");
const t = useTranslations("endpoint");
const tc = useTranslations("common");
@@ -291,7 +315,9 @@ export default function APIPageClient({ machineId }) {
if (tunnelVisibility.showTailscaleFunnel) {
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
}
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
if (tunnelVisibility.showNgrokTunnel) {
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
}
};
void loadPage();
@@ -405,6 +431,7 @@ export default function APIPageClient({ machineId }) {
const tunnelVisibility = {
showCloudflaredTunnel: data.hideEndpointCloudflaredTunnel !== true,
showTailscaleFunnel: data.hideEndpointTailscaleFunnel !== true,
showNgrokTunnel: data.hideEndpointNgrokTunnel !== true,
};
if (!shouldApplyState()) {
@@ -423,6 +450,7 @@ export default function APIPageClient({ machineId }) {
}
setShowCloudflaredTunnel(tunnelVisibility.showCloudflaredTunnel);
setShowTailscaleFunnel(tunnelVisibility.showTailscaleFunnel);
setShowNgrokTunnel(tunnelVisibility.showNgrokTunnel);
if (!tunnelVisibility.showCloudflaredTunnel) {
setCloudflaredStatus(null);
@@ -432,6 +460,10 @@ export default function APIPageClient({ machineId }) {
setTailscaleStatus(null);
setTailscaleNotice(null);
}
if (!tunnelVisibility.showNgrokTunnel) {
setNgrokStatus(null);
setNgrokNotice(null);
}
return tunnelVisibility;
}
@@ -479,6 +511,13 @@ export default function APIPageClient({ machineId }) {
}
}, [tailscaleNotice]);
useEffect(() => {
if (ngrokNotice) {
const timer = setTimeout(() => setNgrokNotice(null), 5000);
return () => clearTimeout(timer);
}
}, [ngrokNotice]);
useEffect(() => {
const interval = setInterval(() => {
void fetchProtocolStatus();
@@ -488,9 +527,19 @@ export default function APIPageClient({ machineId }) {
if (showTailscaleFunnel) {
void fetchTailscaleStatus(true);
}
if (showNgrokTunnel) {
void fetchNgrokStatus(true);
}
}, 30000);
return () => clearInterval(interval);
}, [fetchCloudflaredStatus, fetchTailscaleStatus, showCloudflaredTunnel, showTailscaleFunnel]);
}, [
fetchCloudflaredStatus,
fetchNgrokStatus,
fetchTailscaleStatus,
showCloudflaredTunnel,
showNgrokTunnel,
showTailscaleFunnel,
]);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));
@@ -2296,13 +2345,21 @@ export default function APIPageClient({ machineId }) {
);
}
APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
};
// -- Sub-component: Provider Models Modal ------------------------------------------
function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
function ProviderModelsModal({
provider,
models,
copy,
copied,
onClose,
}: {
provider: EndpointProviderSummary;
models: EndpointModelSummary[];
copy: CopyHandler;
copied?: string | null;
onClose: () => void;
}) {
const t = useTranslations("endpoint");
const tc = useTranslations("common");
// Get provider alias for matching models
@@ -2378,14 +2435,6 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
);
}
ProviderModelsModal.propTypes = {
provider: PropTypes.object.isRequired,
models: PropTypes.array.isRequired,
copy: PropTypes.func.isRequired,
copied: PropTypes.string,
onClose: PropTypes.func.isRequired,
};
// -- Sub-component: Endpoint Section ------------------------------------------
function EndpointSection({
@@ -2402,6 +2451,20 @@ function EndpointSection({
copied,
baseUrl,
modelsLoading = false,
}: {
icon: string;
iconColor: string;
iconBg: string;
title: string;
path: string;
description: string;
models: EndpointModelSummary[];
expanded: boolean;
onToggle: () => void;
copy: CopyHandler;
copied?: string | null;
baseUrl: string;
modelsLoading?: boolean;
}) {
const t = useTranslations("endpoint");
const grouped = useMemo(() => {
@@ -2510,19 +2573,3 @@ function EndpointSection({
</div>
);
}
EndpointSection.propTypes = {
icon: PropTypes.string.isRequired,
iconColor: PropTypes.string.isRequired,
iconBg: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
models: PropTypes.array.isRequired,
expanded: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
copy: PropTypes.func.isRequired,
copied: PropTypes.string,
baseUrl: PropTypes.string.isRequired,
modelsLoading: PropTypes.bool,
};

View File

@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ApiEndpointsTab from "../ApiEndpointsTab";
function jsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => data,
} as Response;
}
const cleanupCallbacks: Array<() => void> = [];
async function waitForText(text: string) {
const startedAt = Date.now();
while (!document.body.textContent?.includes(text)) {
if (Date.now() - startedAt > 1000) {
throw new Error(`Timed out waiting for text: ${text}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function renderApiEndpointsTab() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let mounted = true;
act(() => {
root.render(<ApiEndpointsTab />);
});
const unmount = () => {
if (!mounted) return;
act(() => {
root.unmount();
});
container.remove();
mounted = false;
};
cleanupCallbacks.push(unmount);
}
describe("ApiEndpointsTab", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("shows an API catalog error state instead of a blank page", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "openapi.yaml not found" }, 404));
renderApiEndpointsTab();
await waitForText("API catalog unavailable");
expect(document.body.textContent).toContain("openapi.yaml not found");
expect(document.body.textContent).toContain("Open JSON response");
});
it("renders catalog content when the OpenAPI catalog loads", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
info: { title: "OmniRoute API", version: "3.7.6" },
servers: [],
tags: [{ name: "Chat" }],
endpoints: [
{
method: "POST",
path: "/api/v1/chat/completions",
tags: ["Chat"],
summary: "Create chat completion",
description: "Create chat completion",
security: true,
parameters: [],
requestBody: true,
responses: ["200"],
},
],
schemas: [],
})
);
renderApiEndpointsTab();
await waitForText("OmniRoute API");
expect(document.body.textContent).toContain("1 endpoints across 1 categories");
expect(document.body.textContent).toContain("/api/v1/chat/completions");
});
});

View File

@@ -170,6 +170,7 @@ describe("EndpointPageClient", () => {
cloudConfigured: false,
hideEndpointCloudflaredTunnel: true,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
machineId: "machine-12345678",
})
);
@@ -196,6 +197,7 @@ describe("EndpointPageClient", () => {
expect(fetchMock).toHaveBeenCalledWith("/v1/models");
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/cloudflared", expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/tailscale", expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/ngrok", expect.anything());
modelsDeferred.resolve(
jsonResponse({
@@ -238,6 +240,7 @@ describe("EndpointPageClient", () => {
cloudConfigured: false,
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
hideEndpointNgrokTunnel: false,
})
);
await settingsDeferred.promise;

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { SegmentedControl } from "@/shared/components";
import { Card, SegmentedControl } from "@/shared/components";
import EndpointPageClient from "./EndpointPageClient";
import McpDashboardPage from "./components/MCPDashboard";
import A2ADashboardPage from "./components/A2ADashboard";
@@ -30,40 +30,39 @@ function ServiceToggle({
onToggle: () => void;
toggling: boolean;
}) {
const online = enabled && status.online;
const loading = enabled && status.loading;
return (
<div className="flex items-center gap-3 ml-auto">
<div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
style={{
borderColor: status.loading
borderColor: loading
? "var(--color-border)"
: status.online
: online
? "rgba(34,197,94,0.3)"
: "rgba(239,68,68,0.3)",
background: status.loading
background: loading
? "transparent"
: status.online
: online
? "rgba(34,197,94,0.1)"
: "rgba(239,68,68,0.1)",
color: status.loading
? "var(--color-text-muted)"
: status.online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
color: loading ? "var(--color-text-muted)" : online ? "rgb(34,197,94)" : "rgb(239,68,68)",
}}
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background: status.loading
background: loading
? "var(--color-text-muted)"
: status.online
: online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
animation: status.online ? "pulse 2s infinite" : "none",
animation: online ? "pulse 2s infinite" : "none",
}}
/>
{status.loading ? "..." : status.online ? "Online" : "Offline"}
{loading ? "..." : online ? "Online" : "Offline"}
</div>
<button
@@ -97,6 +96,38 @@ function ServiceToggle({
);
}
function DisabledServicePanel({ title, description }: { title: string; description: string }) {
return (
<Card className="p-6">
<div className="flex items-start gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ background: "var(--color-bg-tertiary)" }}
>
<span
aria-hidden="true"
className="relative block size-5 rounded-full border-2"
style={{ borderColor: "var(--color-text-muted)", color: "var(--color-text-muted)" }}
>
<span
className="absolute left-1/2 top-[-3px] h-3 w-0.5 -translate-x-1/2 rounded-full"
style={{ background: "var(--color-text-muted)" }}
/>
</span>
</div>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
{title}
</h2>
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{description}
</p>
</div>
</div>
</Card>
);
}
/* ────── Transport Selector ────── */
function TransportSelector({
value,
@@ -378,7 +409,15 @@ export default function EndpointPage() {
{activeTab === "endpoint-proxy" && <EndpointPageClient machineId="" />}
{activeTab === "mcp" && <McpDashboardPage />}
{activeTab === "a2a" && <A2ADashboardPage />}
{activeTab === "a2a" &&
(a2aEnabled ? (
<A2ADashboardPage />
) : (
<DisabledServicePanel
title="A2A is disabled"
description="Enable A2A above to view task telemetry, agent details, and validation tools."
/>
))}
{activeTab === "api-endpoints" && <ApiEndpointsTab />}
</div>
);

View File

@@ -0,0 +1,331 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
type TelemetryPayload = {
count?: number;
totalRequests?: number;
avg?: number;
avgLatencyMs?: number;
p50?: number;
p95?: number;
p99?: number;
uptime?: number;
errorRate?: number;
activeConnections?: number;
memoryUsage?: {
rss?: number;
heapUsed?: number;
heapTotal?: number;
};
sessions?: {
activeCount?: number;
};
quotaMonitor?: {
errors?: number;
};
};
type HealthPayload = {
system?: {
uptime?: number;
memoryUsage?: {
rss?: number;
heapUsed?: number;
heapTotal?: number;
};
};
activeConnections?: number;
};
type TelemetrySample = {
timestamp: number;
latencyMs: number;
throughput: number;
memoryBytes: number;
};
const REFRESH_MS = 30_000;
const MAX_SAMPLES = 24;
function formatDuration(seconds = 0) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatBytes(bytes = 0) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatMs(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${Math.round(value)}ms`;
}
function Sparkline({
samples,
field,
}: {
samples: TelemetrySample[];
field: keyof TelemetrySample;
}) {
const values = samples
.map((sample) => Number(sample[field]))
.filter((value) => Number.isFinite(value));
if (values.length < 2) {
return <div className="h-10 rounded-lg bg-sidebar/50" />;
}
const min = Math.min(...values);
const max = Math.max(...values);
const range = Math.max(1, max - min);
const points = values
.map((value, index) => {
const x = (index / Math.max(1, values.length - 1)) * 100;
const y = 36 - ((value - min) / range) * 32;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg viewBox="0 0 100 40" role="img" aria-hidden="true" className="h-10 w-full">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
points={points}
className="text-primary"
/>
</svg>
);
}
function getIndicatorTone(value: number, warning: number, critical: number, inverse = false) {
const healthy = inverse ? value >= warning : value <= warning;
const criticalHit = inverse ? value < critical : value >= critical;
if (criticalHit) return "bg-red-500/10 text-red-500";
if (!healthy) return "bg-amber-500/10 text-amber-500";
return "bg-emerald-500/10 text-emerald-500";
}
export default function TelemetryCard() {
const t = useTranslations("telemetry");
const [telemetry, setTelemetry] = useState<TelemetryPayload | null>(null);
const [health, setHealth] = useState<HealthPayload | null>(null);
const [samples, setSamples] = useState<TelemetrySample[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadTelemetry = useCallback(async () => {
try {
const [telemetryResult, healthResult] = await Promise.allSettled([
fetch("/api/telemetry/summary").then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<TelemetryPayload>;
}),
fetch("/api/monitoring/health").then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<HealthPayload>;
}),
]);
if (telemetryResult.status === "rejected" && healthResult.status === "rejected") {
throw telemetryResult.reason;
}
const nextTelemetry = telemetryResult.status === "fulfilled" ? telemetryResult.value : null;
const nextHealth = healthResult.status === "fulfilled" ? healthResult.value : null;
if (nextTelemetry) setTelemetry(nextTelemetry);
if (nextHealth) setHealth(nextHealth);
setError(null);
setLastUpdated(new Date());
const memoryBytes =
nextTelemetry?.memoryUsage?.rss || nextHealth?.system?.memoryUsage?.rss || 0;
const latencyMs =
nextTelemetry?.avgLatencyMs ?? nextTelemetry?.avg ?? nextTelemetry?.p50 ?? 0;
const throughput = nextTelemetry?.totalRequests ?? nextTelemetry?.count ?? 0;
setSamples((prev) => [
...prev.slice(Math.max(0, prev.length - MAX_SAMPLES + 1)),
{
timestamp: Date.now(),
latencyMs,
throughput,
memoryBytes,
},
]);
} catch (err) {
setError(err instanceof Error ? err.message : t("loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadTelemetry();
const interval = setInterval(() => void loadTelemetry(), REFRESH_MS);
return () => clearInterval(interval);
}, [loadTelemetry]);
const values = useMemo(() => {
const totalRequests = telemetry?.totalRequests ?? telemetry?.count ?? 0;
const avgLatency = telemetry?.avgLatencyMs ?? telemetry?.avg ?? telemetry?.p50;
const p95Latency = telemetry?.p95 ?? avgLatency ?? 0;
const quotaErrors = telemetry?.quotaMonitor?.errors ?? 0;
const errorRate =
typeof telemetry?.errorRate === "number"
? telemetry.errorRate
: totalRequests > 0
? (quotaErrors / Math.max(totalRequests, 1)) * 100
: 0;
return {
uptime: telemetry?.uptime ?? health?.system?.uptime ?? 0,
totalRequests,
avgLatency,
p95Latency,
errorRate,
activeConnections:
telemetry?.activeConnections ??
telemetry?.sessions?.activeCount ??
health?.activeConnections ??
0,
memoryUsage: telemetry?.memoryUsage ?? health?.system?.memoryUsage ?? {},
};
}, [health, telemetry]);
const metricCards = [
{
label: t("uptime"),
value: formatDuration(values.uptime),
icon: "timer",
tone: "bg-blue-500/10 text-blue-500",
},
{
label: t("totalRequests"),
value: values.totalRequests.toLocaleString(),
icon: "receipt_long",
tone: "bg-primary/10 text-primary",
},
{
label: t("avgLatency"),
value: formatMs(values.avgLatency),
icon: "speed",
tone: getIndicatorTone(values.p95Latency, 2_000, 10_000),
},
{
label: t("errorRate"),
value: `${values.errorRate.toFixed(2)}%`,
icon: "error",
tone: getIndicatorTone(values.errorRate, 1, 5),
},
{
label: t("activeConnections"),
value: values.activeConnections.toLocaleString(),
icon: "hub",
tone: "bg-cyan-500/10 text-cyan-500",
},
{
label: t("memoryUsage"),
value: formatBytes(values.memoryUsage.rss ?? values.memoryUsage.heapUsed ?? 0),
icon: "memory",
tone: "bg-violet-500/10 text-violet-500",
},
];
return (
<Card className="p-5">
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-main">
<span className="material-symbols-outlined text-[20px] text-primary">monitoring</span>
{t("title")}
</h2>
<p className="mt-1 text-sm text-text-muted">{t("description")}</p>
{lastUpdated && (
<p className="mt-2 text-xs text-text-muted">
{t("updatedAt", { time: lastUpdated.toLocaleTimeString() })}
</p>
)}
</div>
<button
onClick={() => void loadTelemetry()}
disabled={loading}
title={t("refresh")}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[16px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
{t("refresh")}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600">
{t("partialData", { error })}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{metricCards.map((metric) => (
<div key={metric.label} className="rounded-xl border border-border bg-surface/50 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-text-muted">
{metric.label}
</p>
<p className="mt-1 text-xl font-semibold text-text-main">{metric.value}</p>
</div>
<span
className={`material-symbols-outlined rounded-lg p-2 text-[20px] ${metric.tone}`}
>
{metric.icon}
</span>
</div>
</div>
))}
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-3">
<div className="rounded-xl border border-border bg-surface/40 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-text-muted">
<span>{t("latencyTrend")}</span>
<span>{formatMs(values.p95Latency)} p95</span>
</div>
<Sparkline samples={samples} field="latencyMs" />
</div>
<div className="rounded-xl border border-border bg-surface/40 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-text-muted">
<span>{t("throughputTrend")}</span>
<span>{values.totalRequests.toLocaleString()}</span>
</div>
<Sparkline samples={samples} field="throughput" />
</div>
<div className="rounded-xl border border-border bg-surface/40 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-text-muted">
<span>{t("memoryTrend")}</span>
<span>{formatBytes(values.memoryUsage.heapUsed ?? 0)}</span>
</div>
<Sparkline samples={samples} field="memoryBytes" />
</div>
</div>
</Card>
);
}

View File

@@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
import TelemetryCard from "./TelemetryCard";
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -59,7 +60,6 @@ export default function HealthPage() {
const [dbHealthError, setDbHealthError] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
const [telemetry, setTelemetry] = useState(null);
const [cache, setCache] = useState(null);
const [signatureCache, setSignatureCache] = useState(null);
const [degradation, setDegradation] = useState(null);
@@ -91,20 +91,18 @@ export default function HealthPage() {
}
}, []);
// Fetch telemetry, cache, and signature cache stats
// Fetch cache, signature cache, and degradation stats.
const fetchExtras = useCallback(async () => {
const results = await Promise.allSettled([
fetch("/api/telemetry/summary").then((r) => r.json()),
fetch("/api/cache/stats").then((r) => r.json()),
fetch("/api/rate-limits").then((r) => r.json()),
fetch("/api/health/degradation").then((r) => r.json()),
]);
if (results[0].status === "fulfilled") setTelemetry(results[0].value);
if (results[1].status === "fulfilled") setCache(results[1].value);
if (results[2].status === "fulfilled" && results[2].value.cacheStats) {
setSignatureCache(results[2].value.cacheStats);
if (results[0].status === "fulfilled") setCache(results[0].value);
if (results[1].status === "fulfilled" && results[1].value.cacheStats) {
setSignatureCache(results[1].value.cacheStats);
}
if (results[3].status === "fulfilled") setDegradation(results[3].value);
if (results[2].status === "fulfilled") setDegradation(results[2].value);
}, []);
useEffect(() => {
@@ -247,6 +245,8 @@ export default function HealthPage() {
</span>
</div>
<TelemetryCard />
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -617,38 +617,8 @@ export default function HealthPage() {
</Card>
)}
{/* Telemetry Cards — Latency & Prompt Cache */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Latency Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
{t("latency")}
</h3>
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("latencyP50")}</span>
<span className="font-mono">{fmtMs(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">{t("latencyP95")}</span>
<span className="font-mono">{fmtMs(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">{t("latencyP99")}</span>
<span className="font-mono">{fmtMs(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">
<span className="text-text-muted">{t("totalRequests")}</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
{/* Cache Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Prompt Cache Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">

View File

@@ -55,7 +55,7 @@ export default function LogsPage() {
try {
const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
if (!res.ok) throw new Error("Export failed");
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -66,7 +66,7 @@ export default function LogsPage() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
console.error(t("exportFailed"), err);
} finally {
setExporting(false);
}
@@ -111,7 +111,7 @@ export default function LogsPage() {
strokeLinejoin="round"
/>
</svg>
{exporting ? "Exporting..." : "Export"}
{exporting ? t("exporting") : t("export")}
</button>
{showExport && (
@@ -121,7 +121,7 @@ export default function LogsPage() {
shadow-xl overflow-hidden animate-in fade-in"
>
<div className="px-3 py-2 text-xs text-[var(--text-muted,#666)] border-b border-[var(--border,#333)] font-medium">
Time Range
{t("timeRange")}
</div>
{TIME_RANGES.map((range) => (
<button
@@ -132,9 +132,9 @@ export default function LogsPage() {
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
transition-colors flex items-center justify-between"
>
<span>Last {range.label}</span>
<span>{t("lastNHours", { hours: range.label })}</span>
<span className="text-xs text-[var(--text-muted,#666)]">
{range.hours === 24 ? "default" : ""}
{range.hours === 24 ? t("defaultRange") : ""}
</span>
</button>
))}

View File

@@ -3,7 +3,6 @@
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useNotificationStore } from "@/store/notificationStore";
import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -3591,23 +3590,6 @@ function ModelRow({
);
}
ModelRow.propTypes = {
model: PropTypes.shape({
id: PropTypes.string.isRequired,
}).isRequired,
fullModel: PropTypes.string.isRequired,
provider: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
t: PropTypes.func,
showDeveloperToggle: PropTypes.bool,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatDisabled: PropTypes.bool,
};
function ModelVisibilityToolbar({
t,
filterValue,
@@ -3842,27 +3824,6 @@ function PassthroughModelsSection({
);
}
PassthroughModelsSection.propTypes = {
providerAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired,
customModels: PropTypes.array,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onSetAlias: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatSavingModelId: PropTypes.string,
isModelHidden: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onBulkToggleHidden: PropTypes.func.isRequired,
bulkTogglePending: PropTypes.bool,
togglingModelId: PropTypes.string,
};
function PassthroughModelRow({
modelId,
fullModel,
@@ -3984,25 +3945,6 @@ function PassthroughModelRow({
);
}
PassthroughModelRow.propTypes = {
modelId: PropTypes.string.isRequired,
fullModel: PropTypes.string.isRequired,
source: PropTypes.string,
isHidden: PropTypes.bool,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
t: PropTypes.func,
showDeveloperToggle: PropTypes.bool,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatDisabled: PropTypes.bool,
onToggleHidden: PropTypes.func,
togglingHidden: PropTypes.bool,
};
// ============ Custom Models Section (for ALL providers) ============
function CustomModelsSection({
@@ -4516,14 +4458,6 @@ function CustomModelsSection({
);
}
CustomModelsSection.propTypes = {
providerId: PropTypes.string.isRequired,
providerAlias: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onModelsChanged: PropTypes.func,
};
function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
@@ -4811,42 +4745,6 @@ function CompatibleModelsSection({
);
}
CompatibleModelsSection.propTypes = {
providerStorageAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired,
customModels: PropTypes.array,
fallbackModels: PropTypes.array,
description: PropTypes.string.isRequired,
inputLabel: PropTypes.string.isRequired,
inputPlaceholder: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onSetAlias: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
connections: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
isActive: PropTypes.bool,
})
).isRequired,
isAnthropic: PropTypes.bool,
onImportWithProgress: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatSavingModelId: PropTypes.string,
onModelsChanged: PropTypes.func,
allowImport: PropTypes.bool.isRequired,
isModelHidden: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onBulkToggleHidden: PropTypes.func.isRequired,
bulkTogglePending: PropTypes.bool,
togglingModelId: PropTypes.string,
};
function CooldownTimer({ until }: CooldownTimerProps) {
const [remaining, setRemaining] = useState("");
@@ -4879,10 +4777,6 @@ function CooldownTimer({ until }: CooldownTimerProps) {
return <span className="text-xs text-orange-500 font-mono"> {remaining}</span>;
}
CooldownTimer.propTypes = {
until: PropTypes.string.isRequired,
};
const ERROR_TYPE_LABELS = {
runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" },
upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" },
@@ -5469,50 +5363,6 @@ function ConnectionRow({
);
}
ConnectionRow.propTypes = {
connection: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
displayName: PropTypes.string,
rateLimitedUntil: PropTypes.string,
rateLimitProtection: PropTypes.bool,
testStatus: PropTypes.string,
isActive: PropTypes.bool,
priority: PropTypes.number,
lastError: PropTypes.string,
lastErrorType: PropTypes.string,
lastErrorSource: PropTypes.string,
errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
globalPriority: PropTypes.number,
providerSpecificData: PropTypes.object,
}).isRequired,
isOAuth: PropTypes.bool.isRequired,
isClaude: PropTypes.bool,
isCodex: PropTypes.bool,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onToggleActive: PropTypes.func.isRequired,
onToggleRateLimit: PropTypes.func.isRequired,
onToggleClaudeExtraUsage: PropTypes.func,
onToggleCodex5h: PropTypes.func,
onToggleCodexWeekly: PropTypes.func,
isCcCompatible: PropTypes.bool,
cliproxyapiEnabled: PropTypes.bool,
onToggleCliproxyapiMode: PropTypes.func,
onRetest: PropTypes.func.isRequired,
isRetesting: PropTypes.bool,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onReauth: PropTypes.func,
onApplyCodexAuthLocal: PropTypes.func,
isApplyingCodexAuthLocal: PropTypes.bool,
onExportCodexAuthFile: PropTypes.func,
isExportingCodexAuthFile: PropTypes.bool,
};
const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
"azure-openai",
"bailian-coding-plan",
@@ -5527,7 +5377,7 @@ const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
"azure-openai": "https://example-resource.openai.azure.com",
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
"xiaomi-mimo": "https://token-plan-ams.xiaomimimo.com/v1",
"xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1",
"searxng-search": "http://localhost:8888/search",
petals: "https://chat.petals.dev/api/v1/generate",
};
@@ -6104,17 +5954,6 @@ function AddApiKeyModal({
);
}
AddApiKeyModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string,
providerName: PropTypes.string,
isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
isCcCompatible: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
try {
@@ -6860,20 +6699,6 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
);
}
EditConnectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
connection: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
priority: PropTypes.number,
authType: PropTypes.string,
provider: PropTypes.string,
}),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
function EditCompatibleNodeModal({
isOpen,
node,
@@ -7134,20 +6959,3 @@ function EditCompatibleNodeModal({
</Modal>
);
}
EditCompatibleNodeModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
node: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
prefix: PropTypes.string,
apiType: PropTypes.string,
baseUrl: PropTypes.string,
chatPath: PropTypes.string,
modelsPath: PropTypes.string,
}),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
isAnthropic: PropTypes.bool,
isCcCompatible: PropTypes.bool,
};

View File

@@ -0,0 +1,342 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select } from "@/shared/components";
type CompatibleMode = "openai" | "anthropic" | "cc";
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
interface AddCompatibleProviderModalProps {
isOpen: boolean;
mode: CompatibleMode;
title?: string;
onClose: () => void;
onCreated: (node: CompatibleProviderNode) => void;
}
interface CompatibleFormState {
name: string;
prefix: string;
apiType: string;
baseUrl: string;
chatPath: string;
modelsPath: string;
}
const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
const MODE_DEFAULTS: Record<
CompatibleMode,
{
baseUrl: string;
type: "openai-compatible" | "anthropic-compatible";
compatMode?: "cc";
chatPath: string;
hasApiType: boolean;
hasModelsPath: boolean;
hasWarning: boolean;
}
> = {
openai: {
baseUrl: "https://api.openai.com/v1",
type: "openai-compatible",
chatPath: "",
hasApiType: true,
hasModelsPath: true,
hasWarning: false,
},
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
type: "anthropic-compatible",
chatPath: "",
hasApiType: false,
hasModelsPath: true,
hasWarning: false,
},
cc: {
baseUrl: "",
type: "anthropic-compatible",
compatMode: "cc",
chatPath: CC_DEFAULT_CHAT_PATH,
hasApiType: false,
hasModelsPath: false,
hasWarning: true,
},
};
function createInitialForm(mode: CompatibleMode): CompatibleFormState {
const defaults = MODE_DEFAULTS[mode];
return {
name: "",
prefix: "",
apiType: "chat",
baseUrl: defaults.baseUrl,
chatPath: defaults.chatPath,
modelsPath: "",
};
}
export default function AddCompatibleProviderModal({
isOpen,
mode,
title,
onClose,
onCreated,
}: AddCompatibleProviderModalProps) {
const t = useTranslations("providers");
const defaults = MODE_DEFAULTS[mode];
const [formData, setFormData] = useState<CompatibleFormState>(() => createInitialForm(mode));
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiTypeOptions = useMemo(
() => [
{ value: "chat", label: t("chatCompletions") },
{ value: "responses", label: t("responsesApi") },
{ value: "embeddings", label: t("embeddings") },
{ value: "audio-transcriptions", label: t("audioTranscriptions") },
{ value: "audio-speech", label: t("audioSpeech") },
{ value: "images-generations", label: t("imagesGenerations") },
],
[t]
);
useEffect(() => {
if (!isOpen) return;
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
}, [isOpen, mode]);
const modalTitle =
title ||
(mode === "openai"
? t("addOpenAICompatible")
: mode === "anthropic"
? t("addAnthropicCompatible")
: t("addCcCompatible"));
const namePlaceholder =
mode === "cc"
? t("ccCompatibleNamePlaceholder")
: t("compatibleProdPlaceholder", {
type: mode === "openai" ? t("openai") : t("anthropic"),
});
const nameHint = mode === "cc" ? t("ccCompatibleNameHint") : t("nameHint");
const prefixPlaceholder =
mode === "openai"
? t("openaiPrefixPlaceholder")
: mode === "cc"
? t("ccCompatiblePrefixPlaceholder")
: t("anthropicPrefixPlaceholder");
const prefixHint = mode === "cc" ? t("ccCompatiblePrefixHint") : t("prefixHint");
const baseUrlPlaceholder =
mode === "openai"
? t("openaiBaseUrlPlaceholder")
: mode === "cc"
? t("ccCompatibleBaseUrlPlaceholder")
: t("anthropicBaseUrlPlaceholder");
const baseUrlHint =
mode === "cc"
? t("ccCompatibleBaseUrlHint")
: t("compatibleBaseUrlHint", {
type: mode === "openai" ? t("openai") : t("anthropic"),
});
const chatPathPlaceholder =
mode === "openai" ? "/v1/chat/completions" : mode === "cc" ? CC_DEFAULT_CHAT_PATH : "/messages";
const chatPathHint = mode === "cc" ? t("ccCompatibleChatPathHint") : t("chatPathHint");
const advancedId = `advanced-settings-${mode}`;
const hasRequiredFields = Boolean(
formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim()
);
const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim());
const resetAfterCreate = () => {
setFormData(createInitialForm(mode));
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
};
const handleSubmit = async () => {
if (!hasRequiredFields) return;
setSubmitting(true);
try {
const body: Record<string, unknown> = {
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
type: defaults.type,
chatPath: formData.chatPath || (mode === "cc" ? CC_DEFAULT_CHAT_PATH : ""),
};
if (defaults.hasApiType) body.apiType = formData.apiType;
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
if (defaults.compatMode) body.compatMode = defaults.compatMode;
const res = await fetch("/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as { node: CompatibleProviderNode };
if (res.ok) {
onCreated(data.node);
resetAfterCreate();
}
} catch (error) {
console.log(`Error creating ${mode} compatible node:`, error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const body: Record<string, unknown> = {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: defaults.type,
};
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
if (defaults.compatMode) {
body.compatMode = defaults.compatMode;
body.chatPath = formData.chatPath || CC_DEFAULT_CHAT_PATH;
}
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
return (
<Modal isOpen={isOpen} title={modalTitle} onClose={onClose}>
<div className="flex flex-col gap-4">
{defaults.hasWarning && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
<Input
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={namePlaceholder}
hint={nameHint}
/>
<Input
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={prefixPlaceholder}
hint={prefixHint}
/>
{defaults.hasApiType && (
<Select
label={t("apiTypeLabel")}
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
)}
<Input
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
hint={baseUrlHint}
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls={advancedId}
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
{">"}
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div id={advancedId} className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={chatPathPlaceholder}
hint={chatPathHint}
/>
{defaults.hasModelsPath && (
<Input
label={t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
/>
)}
</div>
)}
<div className="flex gap-2">
<Input
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
className="flex-1"
/>
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={!canValidate || validating}
variant="secondary"
>
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!hasRequiredFields || submitting}>
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,251 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Badge, Card, Toggle } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import {
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
interface ProviderStats {
total?: number;
connected?: number;
error?: number;
errorCode?: string | null;
errorTime?: string | null;
allDisabled?: boolean;
expiryStatus?: "expired" | "expiring_soon" | string | null;
}
interface ProviderCardProps {
providerId: string;
provider: {
id?: string;
name: string;
color?: string;
apiType?: string;
deprecated?: boolean;
deprecationReason?: string;
hasFree?: boolean;
freeNote?: string;
};
stats: ProviderStats;
authType?: string;
onToggle: (active: boolean) => void;
}
const DOT_COLORS: Record<string, string> = {
free: "bg-green-500",
oauth: "bg-blue-500",
apikey: "bg-amber-500",
compatible: "bg-orange-500",
"web-cookie": "bg-purple-500",
search: "bg-teal-500",
audio: "bg-rose-500",
local: "bg-emerald-500",
"upstream-proxy": "bg-indigo-500",
};
function getStatusDisplay(
connected: number,
error: number,
errorCode: string | null | undefined,
t: ReturnType<typeof useTranslations>
) {
const parts: ReactNode[] = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{t("connected", { count: connected })}
</Badge>
);
}
if (error > 0) {
const errText = errorCode
? t("errorCount", { count: error, code: errorCode })
: t("errorCountNoCode", { count: error });
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
);
}
if (parts.length === 0) {
return <span className="text-text-muted">{t("noConnections")}</span>;
}
return parts;
}
export default function ProviderCard({
providerId,
provider,
stats,
authType = "apikey",
onToggle,
}: ProviderCardProps) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const connected = Number(stats.connected || 0);
const error = Number(stats.error || 0);
const allDisabled = Boolean(stats.allDisabled);
const isCompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
const dotLabels: Record<string, string> = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
"web-cookie": t("webCookieProviders"),
search: t("searchProvidersHeading"),
audio: t("audioProvidersHeading"),
local: t("localProviders"),
"upstream-proxy": t("upstreamProxyProviders"),
};
const staticIconPath = (() => {
if (isCompatible) {
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
}
if (isAnthropicCompatible || isCcCompatible) return "/providers/anthropic-m.png";
return null;
})();
const handleToggle = (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
onToggle(allDisabled);
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
padding="xs"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${
allDisabled ? "opacity-50" : ""
} ${provider.deprecated ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 pr-2">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${provider.color || "#64748b"}15` }}
>
{staticIconPath ? (
<Image
src={staticIconPath}
alt={provider.name}
width={30}
height={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
sizes="30px"
/>
) : (
<ProviderIcon providerId={provider.id || providerId} size={28} type="color" />
)}
</div>
<div className="min-w-0">
<h3 className="font-semibold flex items-center gap-1.5 truncate">
<span className={provider.deprecated ? "line-through opacity-60" : ""}>
{provider.name}
</span>
{provider.deprecated && (
<Badge
variant="default"
size="sm"
title={provider.deprecationReason || t("deprecatedProvider")}
>
<span className="flex items-center gap-0.5">
<span className="material-symbols-outlined text-[10px]">block</span>
{t("deprecated")}
</span>
</Badge>
)}
<span
className={`size-2 rounded-full ${DOT_COLORS[authType] || DOT_COLORS.apikey} shrink-0`}
title={dotLabels[authType] || t("apiKeyLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{allDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, stats.errorCode, t)}
{(authType === "free" || provider.hasFree === true) && (
<Badge
variant="success"
size="sm"
title={provider.freeNote || t("freeTierAvailable")}
>
<span className="flex items-center gap-0.5">
<span className="material-symbols-outlined text-[10px]">redeem</span>
{t("freeTier")}
</span>
</Badge>
)}
{stats.expiryStatus === "expired" && (
<Badge variant="error" size="sm" dot>
{t("expiredBadge")}
</Badge>
)}
{stats.expiryStatus === "expiring_soon" && (
<Badge variant="warning" size="sm" dot>
{t("expiringSoonBadge")}
</Badge>
)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? t("responses") : t("chat")}
</Badge>
)}
{isCcCompatible && (
<Badge variant="default" size="sm">
CC
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
{t("messages")}
</Badge>
)}
{stats.errorTime && (
<span className="text-text-muted">* {stats.errorTime}</span>
)}
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{Number(stats.total || 0) > 0 && (
<div onClick={handleToggle}>
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</div>
</div>
</Card>
</Link>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ export default function AppearanceTab() {
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
const showNgrokTunnel = settings.hideEndpointNgrokTunnel !== true;
const getSettingsLabel = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
@@ -308,6 +309,23 @@ export default function AppearanceTab() {
disabled={loading}
/>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">{getSettingsLabel("showNgrokTunnel", "ngrok Tunnel")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"showNgrokTunnelDesc",
"Show ngrok Tunnel controls on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showNgrokTunnel}
onChange={(checked) => updateSetting("hideEndpointNgrokTunnel", !checked)}
disabled={loading}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,429 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
const TRANSPARENT_MITM_PORT = 443;
type MitmTargetRoute = {
id: string;
name: string;
targetHost: string;
targetPort: number;
localPort: number;
endpoints: string[];
enabled: boolean;
};
type MitmStatus = {
running: boolean;
pid: number | null;
dnsConfigured: boolean;
certExists: boolean;
hasCachedPassword: boolean;
port: number;
targets: MitmTargetRoute[];
stats: {
startedAt: string | null;
totalRequests: number;
interceptedRequests: number;
activeConnections: number;
lastRequestAt: string | null;
lastInterceptAt: string | null;
};
};
function emptyStatus(): MitmStatus {
return {
running: false,
pid: null,
dnsConfigured: false,
certExists: false,
hasCachedPassword: false,
port: 443,
targets: [],
stats: {
startedAt: null,
totalRequests: 0,
interceptedRequests: 0,
activeConnections: 0,
lastRequestAt: null,
lastInterceptAt: null,
},
};
}
function formatDate(value: string | null) {
if (!value) return "-";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
export default function MitmProxyTab() {
const t = useTranslations("mitm");
const [status, setStatus] = useState<MitmStatus>(emptyStatus);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [port, setPort] = useState("443");
const [apiKey, setApiKey] = useState("");
const [sudoPassword, setSudoPassword] = useState("");
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
null
);
const loadStatus = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/settings/mitm");
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || t("loadFailed"));
setStatus(data);
setPort(String(TRANSPARENT_MITM_PORT));
setFeedback(null);
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("loadFailed"),
});
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
const updateMitm = async (payload: Record<string, unknown>, successMessage: string) => {
setSaving(true);
setFeedback(null);
try {
const response = await fetch("/api/settings/mitm", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || t("saveFailed"));
setStatus(data);
setPort(String(TRANSPARENT_MITM_PORT));
setFeedback({ type: "success", message: successMessage });
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("saveFailed"),
});
} finally {
setSaving(false);
}
};
const savePort = () => {
const parsedPort = Number.parseInt(port, 10);
if (parsedPort !== TRANSPARENT_MITM_PORT) {
setFeedback({ type: "error", message: t("invalidPort") });
setPort(String(TRANSPARENT_MITM_PORT));
return;
}
void updateMitm({ port: TRANSPARENT_MITM_PORT }, t("settingsSaved"));
};
const toggleMitm = () => {
void updateMitm(
{
enabled: !status.running,
port: TRANSPARENT_MITM_PORT,
apiKey: String(apiKey || "").trim() || undefined,
sudoPassword: sudoPassword || undefined,
},
status.running ? t("stoppedSuccess") : t("startedSuccess")
);
};
const regenerateCertificate = async () => {
if (!confirm(t("regenerateConfirm"))) return;
setSaving(true);
setFeedback(null);
try {
const response = await fetch("/api/settings/mitm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "regenerate-cert" }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || t("regenerateFailed"));
setStatus(data);
setFeedback({ type: "success", message: t("regenerateSuccess") });
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("regenerateFailed"),
});
} finally {
setSaving(false);
}
};
const statusTone = status.running
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "border-border bg-sidebar text-text-muted";
return (
<Card className="p-6">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-main">
<span className="material-symbols-outlined text-[20px] text-primary">lan</span>
{t("title")}
</h2>
<p className="mt-1 text-sm text-text-muted">{t("description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium ${statusTone}`}
>
<span className="material-symbols-outlined text-[14px]">
{status.running ? "play_circle" : "pause_circle"}
</span>
{status.running ? t("running") : t("stopped")}
</span>
<button
onClick={() => void loadStatus()}
disabled={loading}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("refresh")}
</button>
</div>
</div>
{feedback && (
<div
className={`mb-5 rounded-lg border px-4 py-3 text-sm ${
feedback.type === "success"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "border-red-500/30 bg-red-500/10 text-red-600"
}`}
>
{feedback.message}
</div>
)}
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-4">
<div className="rounded-xl border border-border bg-surface/50 p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-text-main">{t("enable")}</p>
<p className="text-xs text-text-muted">{t("enableDesc")}</p>
</div>
<button
onClick={toggleMitm}
disabled={saving || loading}
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-40 ${
status.running ? "bg-red-500 hover:bg-red-600" : "bg-primary hover:bg-primary/90"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{status.running ? "stop_circle" : "play_circle"}
</span>
{status.running ? t("stop") : t("start")}
</button>
</div>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("port")}
</span>
<input
value={port}
readOnly
disabled={status.running}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-60"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("apiKey")}
</span>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={t("apiKeyPlaceholder")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("sudoPassword")}
</span>
<input
type="password"
value={sudoPassword}
onChange={(event) => setSudoPassword(event.target.value)}
placeholder={status.hasCachedPassword ? t("cachedPassword") : t("sudoPassword")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</label>
</div>
<button
onClick={savePort}
disabled={saving || status.running}
className="mt-3 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
{t("saveSettings")}
</button>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-text-main">{t("certificate")}</p>
<p className="text-xs text-text-muted">
{status.certExists ? t("certificateReady") : t("certificateMissing")}
</p>
</div>
<span
className={`rounded-full border px-2 py-1 text-xs ${
status.certExists
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "border-amber-500/30 bg-amber-500/10 text-amber-600"
}`}
>
{status.certExists ? t("available") : t("missing")}
</span>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/api/settings/mitm?download=cert"
className={`inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium transition-colors ${
status.certExists
? "text-text-main hover:bg-sidebar"
: "pointer-events-none text-text-muted opacity-50"
}`}
>
<span className="material-symbols-outlined text-[18px]">download</span>
{t("downloadCert")}
</a>
<button
onClick={regenerateCertificate}
disabled={saving || status.running}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-main transition-colors hover:bg-sidebar disabled:opacity-40"
>
<span className="material-symbols-outlined text-[18px]">autorenew</span>
{t("regenerateCert")}
</button>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{[
{
label: t("interceptedRequests"),
value: status.stats.interceptedRequests.toLocaleString(),
icon: "swap_horiz",
},
{
label: t("activeConnections"),
value: status.stats.activeConnections.toLocaleString(),
icon: "hub",
},
{
label: t("dnsConfigured"),
value: status.dnsConfigured ? t("yes") : t("no"),
icon: "dns",
},
{
label: t("pid"),
value: status.pid ? String(status.pid) : "-",
icon: "tag",
},
].map((item) => (
<div key={item.label} className="rounded-xl border border-border bg-surface/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-text-muted">
{item.label}
</p>
<p className="mt-1 text-xl font-semibold text-text-main">{item.value}</p>
</div>
<span className="material-symbols-outlined rounded-lg bg-primary/10 p-2 text-[20px] text-primary">
{item.icon}
</span>
</div>
</div>
))}
<div className="rounded-xl border border-border bg-surface/50 p-4 sm:col-span-2 lg:col-span-1">
<p className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("lastIntercept")}
</p>
<p className="mt-1 text-sm text-text-main">
{formatDate(status.stats.lastInterceptAt)}
</p>
</div>
</div>
</div>
<div className="mt-5 overflow-hidden rounded-xl border border-border">
<div className="border-b border-border bg-sidebar/40 px-4 py-3">
<h3 className="text-sm font-semibold text-text-main">{t("targetRoutes")}</h3>
</div>
{status.targets.length === 0 ? (
<div className="p-6 text-center text-sm text-text-muted">{t("noTargets")}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-text-muted">
<tr>
<th className="px-4 py-3 font-medium">{t("target")}</th>
<th className="px-4 py-3 font-medium">{t("host")}</th>
<th className="px-4 py-3 font-medium">{t("localPort")}</th>
<th className="px-4 py-3 font-medium">{t("endpoints")}</th>
<th className="px-4 py-3 font-medium">{t("status")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{status.targets.map((target) => (
<tr key={target.id}>
<td className="px-4 py-3 font-medium text-text-main">{target.name}</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{target.targetHost}:{target.targetPort}
</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{target.localPort}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{target.endpoints.map((endpoint) => (
<span
key={endpoint}
className="rounded-full border border-border bg-surface px-2 py-0.5 text-xs text-text-muted"
>
{endpoint}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<span className="rounded-full border border-border bg-sidebar px-2 py-1 text-xs text-text-muted">
{target.enabled ? t("enabled") : t("configured")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
);
}

View File

@@ -22,6 +22,7 @@ import ResilienceTab from "./components/ResilienceTab";
import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab";
import PayloadRulesTab from "./components/PayloadRulesTab";
import VisionBridgeSettingsTab from "./components/VisionBridgeSettingsTab";
import MitmProxyTab from "./components/MitmProxyTab";
import ModelRoutingSection from "@/shared/components/ModelRoutingSection";
const tabs = [
@@ -31,6 +32,7 @@ const tabs = [
{ id: "security", labelKey: "security", icon: "shield" },
{ id: "routing", labelKey: "routing", icon: "route" },
{ id: "resilience", labelKey: "resilience", icon: "electrical_services" },
{ id: "mitm", labelKey: "mitmProxy", icon: "lan" },
{ id: "advanced", labelKey: "advanced", icon: "tune" },
];
@@ -116,6 +118,8 @@ export default function SettingsPage() {
{activeTab === "resilience" && <ResilienceTab />}
{activeTab === "mitm" && <MitmProxyTab />}
{activeTab === "advanced" && (
<div className="flex flex-col gap-6">
<PayloadRulesTab />

View File

@@ -3,7 +3,7 @@
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { SegmentedControl } from "@/shared/components";
import { Badge, Card, SegmentedControl } from "@/shared/components";
import PlaygroundMode from "./components/PlaygroundMode";
import ChatTesterMode from "./components/ChatTesterMode";
import TestBenchMode from "./components/TestBenchMode";
@@ -12,6 +12,7 @@ import StreamTransformerMode from "./components/StreamTransformerMode";
export default function TranslatorPageClient() {
const t = useTranslations("translator");
const [showFeatures, setShowFeatures] = useState(false);
const translateOrFallback = useCallback(
(key: string, fallback: string) => {
try {
@@ -94,6 +95,79 @@ export default function TranslatorPageClient() {
</div>
</div>
<Card className="border-primary/10 bg-primary/5">
<button
onClick={() => setShowFeatures((prev) => !prev)}
className="flex w-full items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">
auto_fix_high
</span>
<h3 className="text-sm font-semibold text-text-main">{t("autoFeaturesTitle")}</h3>
<Badge variant="primary" size="sm">
{t("autoFeaturesCount")}
</Badge>
</div>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{showFeatures ? "expand_less" : "expand_more"}
</span>
</button>
{showFeatures && (
<div className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4">
<FeatureChip
icon="psychology"
title={t("featureReasoningCache")}
description={t("featureReasoningCacheDesc")}
color="purple"
/>
<FeatureChip
icon="schema"
title={t("featureSchemaCoercion")}
description={t("featureSchemaCoercionDesc")}
color="blue"
/>
<FeatureChip
icon="swap_vert"
title={t("featureRoleNormalization")}
description={t("featureRoleNormalizationDesc")}
color="amber"
/>
<FeatureChip
icon="fingerprint"
title={t("featureToolCallIds")}
description={t("featureToolCallIdsDesc")}
color="emerald"
/>
<FeatureChip
icon="add_circle"
title={t("featureMissingToolResponse")}
description={t("featureMissingToolResponseDesc")}
color="cyan"
/>
<FeatureChip
icon="tune"
title={t("featureThinkingBudget")}
description={t("featureThinkingBudgetDesc")}
color="orange"
/>
<FeatureChip
icon="alt_route"
title={t("featureDirectPaths")}
description={t("featureDirectPathsDesc")}
color="pink"
/>
<FeatureChip
icon="photo_size_select_large"
title={t("featureImageMapping")}
description={t("featureImageMappingDesc")}
color="indigo"
/>
</div>
)}
</Card>
{/* Mode Content */}
{mode === "playground" && <PlaygroundMode />}
{mode === "chat-tester" && <ChatTesterMode />}
@@ -103,3 +177,60 @@ export default function TranslatorPageClient() {
</div>
);
}
function FeatureChip({
icon,
title,
description,
color,
}: {
icon: string;
title: string;
description: string;
color: "purple" | "blue" | "amber" | "emerald" | "cyan" | "orange" | "pink" | "indigo";
}) {
const colorMap = {
purple: {
shell: "border-purple-500/20 bg-purple-500/5",
icon: "text-purple-500",
},
blue: {
shell: "border-blue-500/20 bg-blue-500/5",
icon: "text-blue-500",
},
amber: {
shell: "border-amber-500/20 bg-amber-500/5",
icon: "text-amber-500",
},
emerald: {
shell: "border-emerald-500/20 bg-emerald-500/5",
icon: "text-emerald-500",
},
cyan: {
shell: "border-cyan-500/20 bg-cyan-500/5",
icon: "text-cyan-500",
},
orange: {
shell: "border-orange-500/20 bg-orange-500/5",
icon: "text-orange-500",
},
pink: {
shell: "border-pink-500/20 bg-pink-500/5",
icon: "text-pink-500",
},
indigo: {
shell: "border-indigo-500/20 bg-indigo-500/5",
icon: "text-indigo-500",
},
}[color];
return (
<div className={`rounded-lg border p-3 ${colorMap.shell}`}>
<div className="mb-1 flex items-center gap-2">
<span className={`material-symbols-outlined text-[16px] ${colorMap.icon}`}>{icon}</span>
<p className="text-xs font-semibold text-text-main">{title}</p>
</div>
<p className="text-[10px] leading-relaxed text-text-muted">{description}</p>
</div>
);
}

View File

@@ -79,6 +79,17 @@ export default function ChatTesterMode() {
parts: [{ text: m.content }],
})),
};
} else if (clientFormat === "antigravity") {
clientRequest = {
request: {
contents: allMessages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
},
model,
userAgent: "antigravity",
};
} else if (clientFormat === "openai-responses") {
clientRequest = {
model,
@@ -89,6 +100,12 @@ export default function ChatTesterMode() {
})),
stream: true,
};
} else if (clientFormat === "cursor" || clientFormat === "kiro") {
clientRequest = {
model,
messages: allMessages,
stream: true,
};
} else {
clientRequest = {
model,
@@ -278,9 +295,7 @@ export default function ChatTesterMode() {
<Select
value={clientFormat}
onChange={(e) => setClientFormat(e.target.value)}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
options={FORMAT_OPTIONS}
/>
</div>
<div className="flex-1">

View File

@@ -118,6 +118,14 @@ export default function LiveMonitorMode() {
/>
</div>
<div className="flex items-center gap-2 rounded-lg border border-amber-500/10 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[14px]">memory</span>
<p>
{t("liveMonitorMemoryNote")}{" "}
<span className="text-text-muted">{t("liveMonitorMemoryCapNote")}</span>
</p>
</div>
{/* Controls */}
<Card>
<div className="p-3 flex items-center justify-between">
@@ -169,6 +177,15 @@ export default function LiveMonitorMode() {
</span>
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
<p className="text-xs text-center max-w-sm">{t("eventsAppearHint")}</p>
<div className="mt-3 rounded-lg border border-border/40 bg-bg-subtle/50 px-4 py-3 text-left">
<p className="text-[10px] font-semibold text-text-muted">
{t("eventSourcesLabel")}
</p>
<ul className="mt-1 space-y-1 text-[10px] text-text-muted">
<li>{t("eventSourceTranslatorPage")}</li>
<li>{t("eventSourceMainPipeline")}</li>
</ul>
</div>
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
{t("chatTesterTab")}

View File

@@ -16,6 +16,8 @@ export default function PlaygroundMode() {
const [targetFormat, setTargetFormat] = useState("openai");
const [inputContent, setInputContent] = useState("");
const [outputContent, setOutputContent] = useState("");
const [intermediateContent, setIntermediateContent] = useState("");
const [translationPath, setTranslationPath] = useState("");
const [detectedFormat, setDetectedFormat] = useState(null);
const [translating, setTranslating] = useState(false);
const [detecting, setDetecting] = useState(false);
@@ -61,28 +63,66 @@ export default function PlaygroundMode() {
setTranslating(true);
setOutputContent("");
setIntermediateContent("");
setTranslationPath("");
try {
const parsed = JSON.parse(inputContent);
if (sourceFormat === targetFormat) {
setOutputContent(JSON.stringify(parsed, null, 2));
setTranslationPath("passthrough");
setTranslating(false);
return;
}
let intermediate = parsed;
let hasIntermediate = false;
if (sourceFormat !== "openai" && targetFormat !== "openai") {
const step1 = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
step: "direct",
sourceFormat,
targetFormat: "openai",
body: parsed,
}),
});
const step1Data = await step1.json();
if (!step1Data.success) {
setOutputContent(JSON.stringify({ error: step1Data.error }, null, 2));
return;
}
intermediate = step1Data.result;
setIntermediateContent(JSON.stringify(intermediate, null, 2));
hasIntermediate = true;
}
const res = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
step: "direct",
sourceFormat,
sourceFormat: hasIntermediate ? "openai" : sourceFormat,
targetFormat,
body: parsed,
body: hasIntermediate ? intermediate : parsed,
}),
});
const data = await res.json();
if (data.success) {
setOutputContent(JSON.stringify(data.result, null, 2));
setTranslationPath(hasIntermediate ? "hub-and-spoke" : "direct");
} else {
setOutputContent(JSON.stringify({ error: data.error }, null, 2));
}
} catch (err) {
setOutputContent(JSON.stringify({ error: err.message }, null, 2));
setOutputContent(
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)
);
} finally {
setTranslating(false);
}
setTranslating(false);
};
const loadTemplate = (template) => {
@@ -90,6 +130,8 @@ export default function PlaygroundMode() {
setInputContent(JSON.stringify(formatData, null, 2));
setActiveTemplate(template.id);
setOutputContent("");
setIntermediateContent("");
setTranslationPath("");
};
const handleCopy = async (text) => {
@@ -105,6 +147,8 @@ export default function PlaygroundMode() {
setTargetFormat(sourceFormat);
setInputContent(outputContent);
setOutputContent("");
setIntermediateContent("");
setTranslationPath("");
setDetectedFormat(null);
};
@@ -194,8 +238,35 @@ export default function PlaygroundMode() {
</div>
</Card>
{translationPath && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined text-[14px]">route</span>
{translationPath === "hub-and-spoke" ? (
<span>
{t("translationPathHubSpoke", {
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
target: FORMAT_META[targetFormat]?.label || targetFormat,
})}
</span>
) : translationPath === "direct" ? (
<span>
{t("translationPathDirect", {
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
target: FORMAT_META[targetFormat]?.label || targetFormat,
})}
</span>
) : (
<span>{t("translationPathPassthrough")}</span>
)}
</div>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
className={`grid grid-cols-1 gap-4 ${
intermediateContent ? "xl:grid-cols-3" : "lg:grid-cols-2"
}`}
>
{/* Input Panel */}
<Card>
<div className="p-4 space-y-3">
@@ -258,6 +329,49 @@ export default function PlaygroundMode() {
</div>
</Card>
{/* Intermediate Panel */}
{intermediateContent && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-amber-500">hub</span>
<h3 className="text-sm font-semibold text-text-main">
{t("openaiIntermediatePanel")}
</h3>
<Badge variant="warning" size="sm">
Hub
</Badge>
</div>
<button
onClick={() => handleCopy(intermediateContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={intermediateContent}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
</Card>
)}
{/* Output Panel */}
<Card>
<div className="p-4 space-y-3">

View File

@@ -399,11 +399,9 @@ export const FORMAT_META = {
"openai-responses": { label: "OpenAI Responses", color: "amber", icon: "swap_horiz" },
claude: { label: "Claude", color: "orange", icon: "psychology" },
gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" },
"gemini-cli": { label: "Gemini CLI", color: "sky", icon: "terminal" },
antigravity: { label: "Antigravity", color: "purple", icon: "rocket_launch" },
kiro: { label: "Kiro", color: "cyan", icon: "terminal" },
cursor: { label: "Cursor", color: "pink", icon: "edit" },
codex: { label: "Codex", color: "yellow", icon: "code" },
};
/**

View File

@@ -11,7 +11,6 @@ const FORMAT_MODEL_PREFIXES = {
"openai-responses": ["gpt-", "o1-", "o3-", "o4-"],
claude: ["claude-"],
gemini: ["gemini-"],
"gemini-cli": ["gemini-"],
};
/**

View File

@@ -135,6 +135,34 @@ interface EvalSuiteDraft {
cases: EvalCaseDraft[];
}
interface ImportedEvalCase {
id?: string;
name?: string;
model?: string;
input?: {
messages?: Array<{ role?: string; content?: string }>;
};
expected?: {
strategy?: string;
value?: string;
};
tags?: string[];
}
interface ImportedEvalSuiteFile {
name?: string;
description?: string;
cases?: ImportedEvalCase[];
}
interface RunAllProgress {
current: number;
total: number;
suiteName: string;
completed: number;
failedSuites: number;
}
const STRATEGIES = [
{
name: "contains",
@@ -170,13 +198,6 @@ const STRATEGIES = [
},
];
const RESULT_COLUMNS = [
{ key: "caseName", labelKey: "columnCase" },
{ key: "status", labelKey: "columnStatus" },
{ key: "durationMs", labelKey: "columnLatency" },
{ key: "details", labelKey: "columnDetails" },
];
const HISTORY_COLUMNS = [
{ key: "suiteName", labelKey: "historyColumnSuiteName" },
{ key: "target", labelKey: "historyColumnTarget" },
@@ -213,6 +234,10 @@ function createEmptySuiteDraft(): EvalSuiteDraft {
};
}
function normalizeBuilderStrategy(value: unknown): BuilderStrategy {
return value === "exact" || value === "regex" ? value : "contains";
}
function joinPromptMessages(
messages: Array<{ role: string; content: string }> | undefined,
role: string
@@ -241,10 +266,7 @@ function suiteToDraft(suite: EvalSuite): EvalSuiteDraft {
.filter((message) => message.role !== "system")
.map((message) => message.content)
.join("\n\n"),
strategy:
evalCase.expected?.strategy === "exact" || evalCase.expected?.strategy === "regex"
? evalCase.expected.strategy
: "contains",
strategy: normalizeBuilderStrategy(evalCase.expected?.strategy),
expectedValue: evalCase.expected?.value || "",
tags: (evalCase.tags || []).join(", "),
}))
@@ -252,6 +274,87 @@ function suiteToDraft(suite: EvalSuite): EvalSuiteDraft {
};
}
function suiteToCloneDraft(
suite: EvalSuite,
t: (key: string, values?: Record<string, unknown>) => string
): EvalSuiteDraft {
const draft = suiteToDraft(suite);
return {
name: `${draft.name || suite.id} ${t("suiteBuilderCloneSuffix")}`.trim(),
description: draft.description,
cases: draft.cases.map((evalCase) => ({
...evalCase,
id: createDraftId(),
name: evalCase.name ? `${evalCase.name} ${t("suiteBuilderCloneSuffix")}`.trim() : "",
})),
};
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "";
}
function getResultExpectedValue(result: EvalResult): string {
if (result.details?.expected) return String(result.details.expected);
if (result.details?.searchTerm) return String(result.details.searchTerm);
if (result.details?.pattern) return String(result.details.pattern);
return "—";
}
function getResultActualValue(result: EvalResult, output?: string): string {
const actual = output || result.details?.actual || result.details?.actualSnippet || "";
return typeof actual === "string" && actual.trim().length > 0 ? actual : "—";
}
function createDraftFromImportedSuite(
payload: ImportedEvalSuiteFile,
fallbackName: string
): EvalSuiteDraft {
const cases = Array.isArray(payload.cases) ? payload.cases : [];
return {
name:
typeof payload.name === "string" && payload.name.trim().length > 0
? payload.name.trim()
: fallbackName,
description: typeof payload.description === "string" ? payload.description : "",
cases:
cases.length > 0
? cases.map((evalCase, index) => {
const importedMessages = evalCase.input?.messages;
const messages = Array.isArray(importedMessages)
? importedMessages
.map((message) => ({
role: typeof message.role === "string" ? message.role : "",
content: typeof message.content === "string" ? message.content : "",
}))
.filter((message) => message.role && message.content.trim())
: [];
return {
id: createDraftId(),
name:
typeof evalCase.name === "string" && evalCase.name.trim().length > 0
? evalCase.name.trim()
: `Case ${index + 1}`,
model: typeof evalCase.model === "string" ? evalCase.model : "",
systemPrompt: joinPromptMessages(messages, "system"),
userPrompt:
joinPromptMessages(messages, "user") ||
messages
.filter((message) => message.role !== "system")
.map((message) => message.content)
.join("\n\n"),
strategy: normalizeBuilderStrategy(evalCase.expected?.strategy),
expectedValue:
typeof evalCase.expected?.value === "string" ? evalCase.expected.value : "",
tags: Array.isArray(evalCase.tags) ? evalCase.tags.join(", ") : "",
};
})
: [createEmptyCaseDraft()],
};
}
function getTargetLabel(
target: { type: EvalTargetType; id: string | null },
t: (key: string, values?: Record<string, unknown>) => string
@@ -338,8 +441,11 @@ export default function EvalsTab() {
const [suiteRuns, setSuiteRuns] = useState<Record<string, EvalSuiteRunState>>({});
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [runningAll, setRunningAll] = useState(false);
const [runProgress, setRunProgress] = useState<RunAllProgress | null>(null);
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [isBuilderOpen, setIsBuilderOpen] = useState(false);
const [suiteDraft, setSuiteDraft] = useState<EvalSuiteDraft>(createEmptySuiteDraft());
@@ -422,6 +528,10 @@ export default function EvalsTab() {
];
const compareOptions = targetOptions.filter((option) => option.key !== selectedTargetKey);
const runAllPercent =
runProgress && runProgress.total > 0
? Math.round((runProgress.completed / runProgress.total) * 100)
: 0;
async function refreshDashboard() {
const response = await fetch("/api/evals");
@@ -446,6 +556,102 @@ export default function EvalsTab() {
setIsBuilderOpen(true);
}
function handleCloneSuite(suite: EvalSuite) {
setSuiteDraft(suiteToCloneDraft(suite, t));
setIsBuilderOpen(true);
}
function toggleResultExpansion(resultKey: string) {
setExpandedResults((prev) => {
const next = new Set(prev);
if (next.has(resultKey)) {
next.delete(resultKey);
} else {
next.add(resultKey);
}
return next;
});
}
function handleExportSuite(suite: EvalSuite) {
try {
const exportPayload = {
format: "omniroute.eval-suite.v1",
exportedAt: new Date().toISOString(),
id: suite.id,
name: suite.name || suite.id,
description: suite.description || "",
source: suite.source || "built-in",
cases: (suite.cases || []).map((evalCase) => ({
name: evalCase.name || "",
model: evalCase.model || "",
input: {
messages: Array.isArray(evalCase.input?.messages) ? evalCase.input?.messages : [],
},
expected: {
strategy: normalizeBuilderStrategy(evalCase.expected?.strategy),
value: evalCase.expected?.value || "",
},
tags: evalCase.tags || [],
})),
};
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const slug =
(suite.name || suite.id || "eval-suite")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "eval-suite";
anchor.href = url;
anchor.download = `${slug}.eval-suite.json`;
anchor.click();
URL.revokeObjectURL(url);
notify.success(t("suiteExported"), t("notifyEvalTitle", { name: suite.name || suite.id }));
} catch (error: unknown) {
notify.error(
t("notifyEvalRunFailedWithReason", {
reason: getErrorMessage(error) || t("suiteExportFailed"),
}),
t("suiteExportFailed")
);
}
}
async function handleImportSuite(file: File) {
try {
const text = await file.text();
const payload = JSON.parse(text) as ImportedEvalSuiteFile;
if (!payload || typeof payload !== "object" || !Array.isArray(payload.cases)) {
throw new Error(t("suiteImportInvalid"));
}
const fallbackName =
file.name
.replace(/\.eval-suite\.json$/i, "")
.replace(/\.json$/i, "")
.replace(/[-_]+/g, " ")
.trim() || t("suiteBuilderImportedSuite");
const draft = createDraftFromImportedSuite(payload, fallbackName);
setSuiteDraft({
...draft,
name: `${draft.name} ${t("suiteBuilderCloneSuffix")}`.trim(),
});
setIsBuilderOpen(true);
notify.success(t("suiteImportReady"), t("notifyEvalTitle", { name: draft.name }));
} catch (error: unknown) {
notify.error(
t("notifyEvalRunFailedWithReason", {
reason: getErrorMessage(error) || t("suiteImportInvalid"),
}),
t("suiteImportFailed")
);
}
}
async function handleSaveSuite() {
const suiteName = suiteDraft.name.trim();
if (!suiteName) {
@@ -584,7 +790,129 @@ export default function EvalsTab() {
}
}
async function handleRunAllSuites() {
const suitesToRun = filteredSuites.filter(
(suite) => (suite.cases?.length || suite.caseCount || 0) > 0
);
if (suitesToRun.length === 0) {
notify.warning(t("notifyNoTestCases"));
return;
}
if (compareTargetKey && compareTargetKey === selectedTargetKey) {
notify.warning(t("notifySelectDifferentCompareTarget"));
return;
}
let completed = 0;
let failedSuites = 0;
let totalPassed = 0;
let totalFailed = 0;
setRunningAll(true);
setRunProgress({
current: 0,
total: suitesToRun.length,
suiteName: "",
completed: 0,
failedSuites: 0,
});
try {
for (const [index, suite] of suitesToRun.entries()) {
setRunning(suite.id);
setRunProgress({
current: index + 1,
total: suitesToRun.length,
suiteName: suite.name || suite.id,
completed,
failedSuites,
});
try {
const response = await fetch("/api/evals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
suiteId: suite.id,
target: parseTargetKey(selectedTargetKey),
...(compareTargetKey ? { compareTarget: parseTargetKey(compareTargetKey) } : {}),
...(selectedApiKeyId ? { apiKeyId: selectedApiKeyId } : {}),
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ||
payload?.error ||
payload?.message ||
t("notifyEvalRunFailed")
);
}
const runs = Array.isArray(payload.runs) ? (payload.runs as EvalRun[]) : [];
const comparisonScorecard = (payload.scorecard || null) as EvalScorecard | null;
setSuiteRuns((prev) => ({
...prev,
[suite.id]: {
runs,
scorecard: comparisonScorecard,
},
}));
if (Array.isArray(payload.recentRuns)) {
setRecentRuns(payload.recentRuns as EvalRun[]);
}
if (payload.historyScorecard) {
setScorecard(payload.historyScorecard as EvalScorecard);
}
const primaryRun = runs[0];
totalPassed += primaryRun?.summary.passed || 0;
totalFailed += primaryRun?.summary.failed || 0;
completed += 1;
} catch (error: unknown) {
failedSuites += 1;
console.error("[Evals] Run all failed for suite", suite.id, error);
} finally {
setRunProgress({
current: index + 1,
total: suitesToRun.length,
suiteName: suite.name || suite.id,
completed,
failedSuites,
});
}
}
await refreshDashboard();
if (failedSuites > 0) {
notify.warning(
t("runAllCompletedWithFailures", { completed, failedSuites }),
t("notifyEvalTitle", { name: t("runAllSuites") })
);
} else {
notify.success(
t("runAllCompleted", {
suites: completed,
passed: totalPassed,
failed: totalFailed,
}),
t("notifyEvalTitle", { name: t("runAllSuites") })
);
}
} finally {
setRunning(null);
setRunningAll(false);
setRunProgress(null);
}
}
async function handleRunEval(suite: EvalSuite) {
if (runningAll) return;
const cases = suite.cases || [];
if (cases.length === 0) {
notify.warning(t("notifyNoTestCases"));
@@ -986,11 +1314,75 @@ export default function EvalsTab() {
<p className="text-xs text-text-muted">{t("evalSuitesHint")}</p>
</div>
</div>
<Button icon="add" onClick={openNewSuiteBuilder}>
{t("suiteBuilderNewSuite")}
</Button>
<div className="flex flex-wrap items-center gap-2">
<label
className={`inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg border border-black/10 bg-white px-4 text-sm font-medium text-text-main shadow-sm transition-all duration-200 hover:bg-black/5 dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/5 ${
running !== null || runningAll ? "pointer-events-none opacity-50" : ""
}`}
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">
upload_file
</span>
{t("importSuite")}
<input
type="file"
accept="application/json,.json"
className="hidden"
disabled={running !== null || runningAll}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) {
void handleImportSuite(file);
}
event.currentTarget.value = "";
}}
/>
</label>
<Button
icon="play_arrow"
variant="secondary"
disabled={running !== null || runningAll}
loading={runningAll}
onClick={() => void handleRunAllSuites()}
>
{runningAll ? t("runAllRunning") : t("runAllSuites")}
</Button>
<Button
icon="add"
onClick={openNewSuiteBuilder}
disabled={running !== null || runningAll}
>
{t("suiteBuilderNewSuite")}
</Button>
</div>
</div>
{runProgress && (
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-medium text-text-main">
{t("runAllProgress", {
current: runProgress.current,
total: runProgress.total,
name: runProgress.suiteName || t("runAllSuites"),
})}
</span>
<span className="text-xs font-semibold text-primary">{runAllPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-black/10 dark:bg-white/10">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${runAllPercent}%` }}
/>
</div>
{runProgress.failedSuites > 0 && (
<p className="mt-2 text-xs text-amber-400">
{t("runAllFailedSuites", { count: runProgress.failedSuites })}
</p>
)}
</div>
)}
<FilterBar
searchValue={search}
onSearchChange={setSearch}
@@ -1087,13 +1479,37 @@ export default function EvalsTab() {
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="content_copy"
disabled={running !== null || runningAll}
onClick={(event) => {
event.stopPropagation();
handleCloneSuite(suite);
}}
>
{t("clone")}
</Button>
<Button
size="sm"
variant="ghost"
icon="download"
disabled={running !== null || runningAll}
onClick={(event) => {
event.stopPropagation();
handleExportSuite(suite);
}}
>
{t("exportSuite")}
</Button>
{suite.source === "custom" && (
<>
<Button
size="sm"
variant="secondary"
icon="edit"
disabled={running !== null || deletingSuiteId === suite.id}
disabled={running !== null || runningAll || deletingSuiteId === suite.id}
onClick={(event) => {
event.stopPropagation();
openEditSuiteBuilder(suite);
@@ -1105,7 +1521,7 @@ export default function EvalsTab() {
size="sm"
variant="ghost"
icon="delete"
disabled={running !== null || deletingSuiteId === suite.id}
disabled={running !== null || runningAll || deletingSuiteId === suite.id}
loading={deletingSuiteId === suite.id}
onClick={(event) => {
event.stopPropagation();
@@ -1120,7 +1536,7 @@ export default function EvalsTab() {
size="sm"
variant="primary"
loading={isRunning}
disabled={running !== null}
disabled={running !== null || runningAll}
onClick={(event) => {
event.stopPropagation();
void handleRunEval(suite);
@@ -1208,56 +1624,101 @@ export default function EvalsTab() {
</div>
</div>
<DataTable
columns={RESULT_COLUMNS.map((column) => ({
key: column.key,
label: t(column.labelKey),
}))}
data={run.results.map((result, index) => ({
...result,
id: result.caseId || index,
}))}
renderCell={(row, column) => {
if (column.key === "status") {
return row.passed ? (
<span className="text-emerald-400">{t("passedIconLabel")}</span>
) : (
<div className="flex items-center gap-2">
<span className="text-red-400">{t("failedIconLabel")}</span>
{row.error ? (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-red-500/10 text-red-400">
{t("errorBadge")}
{run.results.length > 0 ? (
<div className="flex max-h-[420px] flex-col gap-2 overflow-auto pr-1">
{run.results.map((result, index) => {
const resultKey = `${run.id}:${result.caseId || index}`;
const isResultExpanded = expandedResults.has(resultKey);
const actualOutput = getResultActualValue(
result,
run.outputs?.[result.caseId]
);
const expectedOutput = getResultExpectedValue(result);
return (
<div
key={resultKey}
className="overflow-hidden rounded-lg border border-border/20 bg-surface/20"
>
<button
type="button"
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-surface/30"
aria-expanded={isResultExpanded}
aria-label={
isResultExpanded ? t("collapseResult") : t("expandResult")
}
onClick={() => toggleResultExpansion(resultKey)}
>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{isResultExpanded ? "expand_less" : "expand_more"}
</span>
) : null}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-text-main">
{result.caseName || result.caseId || "—"}
</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
result.passed
? "bg-emerald-500/10 text-emerald-400"
: "bg-red-500/10 text-red-400"
}`}
>
{result.passed
? t("resultPassed")
: t("resultFailed")}
</span>
{result.error ? (
<span className="rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-semibold text-red-400">
{t("errorBadge")}
</span>
) : null}
</div>
<p className="mt-1 truncate text-xs text-text-muted">
{getResultDetails(result, t)}
</p>
</div>
<span className="text-xs font-mono text-text-muted">
{result.durationMs != null
? `${result.durationMs}ms`
: "—"}
</span>
</button>
{isResultExpanded && (
<div className="grid grid-cols-1 gap-3 border-t border-border/20 p-3 lg:grid-cols-2">
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{t("expectedOutputLabel")}
</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border/20 bg-black/5 p-3 text-xs text-text-main dark:bg-white/5">
{expectedOutput}
</pre>
</div>
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{t("actualOutputLabel")}
</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border/20 bg-black/5 p-3 text-xs text-text-main dark:bg-white/5">
{actualOutput}
</pre>
{result.error ? (
<p className="mt-2 text-xs text-red-400">
{result.error}
</p>
) : null}
</div>
</div>
)}
</div>
);
}
if (column.key === "durationMs") {
return (
<span className="text-text-muted text-xs font-mono">
{row.durationMs != null ? `${row.durationMs}ms` : "—"}
</span>
);
}
if (column.key === "details") {
return (
<span className="text-text-muted text-xs truncate max-w-[320px] block">
{getResultDetails(row as EvalResult, t)}
</span>
);
}
return (
<span className="text-sm text-text-main">
{String(row[column.key] || "—")}
</span>
);
}}
maxHeight="360px"
emptyMessage={t("noResultsYet")}
/>
})}
</div>
) : (
<div className="rounded-lg border border-border/20 px-4 py-8 text-center text-sm text-text-muted">
{t("noResultsYet")}
</div>
)}
</Card>
))}
</div>
@@ -1481,6 +1942,35 @@ function SuiteBuilderModal({
});
}
function duplicateCase(caseId: string) {
const source = draft.cases.find((entry) => entry.id === caseId);
if (!source) return;
const sourceIndex = draft.cases.findIndex((entry) => entry.id === caseId);
const duplicate = {
...source,
id: createDraftId(),
name: source.name ? `${source.name} ${t("suiteBuilderCloneSuffix")}`.trim() : "",
};
const nextCases = [...draft.cases];
nextCases.splice(sourceIndex + 1, 0, duplicate);
onChange({
...draft,
cases: nextCases,
});
}
function getExpectedPlaceholder(strategy: BuilderStrategy) {
if (strategy === "exact") return t("suiteBuilderCaseExpectedPlaceholderExact");
if (strategy === "regex") return t("suiteBuilderCaseExpectedPlaceholderRegex");
return t("suiteBuilderCaseExpectedPlaceholderContains");
}
function getExpectedHint(strategy: BuilderStrategy) {
if (strategy === "regex") return t("suiteBuilderCaseExpectedHintRegex");
return undefined;
}
return (
<Modal
isOpen={isOpen}
@@ -1517,93 +2007,131 @@ function SuiteBuilderModal({
</div>
</div>
{draft.cases.map((draftCase, index) => (
<Card key={draftCase.id} className="p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-text-main">
{t("suiteBuilderCaseCardTitle", { index: index + 1 })}
</h4>
<p className="text-xs text-text-muted">
{t("suiteBuilderCaseCardHint", { index: index + 1 })}
</p>
{draft.cases.map((draftCase, index) => {
const selectedStrategy = editableStrategies.find(
(strategy) => strategy.name === draftCase.strategy
);
return (
<Card key={draftCase.id} className="p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-text-main">
{t("suiteBuilderCaseCardTitle", { index: index + 1 })}
</h4>
<p className="text-xs text-text-muted">
{t("suiteBuilderCaseCardHint", { index: index + 1 })}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="content_copy"
onClick={() => duplicateCase(draftCase.id)}
>
{t("suiteBuilderDuplicateCase")}
</Button>
<Button
size="sm"
variant="ghost"
icon="delete"
onClick={() => removeCase(draftCase.id)}
>
{t("delete")}
</Button>
</div>
</div>
<Button variant="ghost" icon="delete" onClick={() => removeCase(draftCase.id)}>
{t("delete")}
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label={t("suiteBuilderCaseNameLabel")}
value={draftCase.name}
onChange={(event) => updateCase(draftCase.id, { name: event.target.value })}
placeholder={t("suiteBuilderCaseNamePlaceholder")}
/>
<Input
label={t("suiteBuilderCaseModelLabel")}
value={draftCase.model}
onChange={(event) => updateCase(draftCase.id, { model: event.target.value })}
placeholder={t("suiteBuilderCaseModelPlaceholder")}
/>
<Input
label={t("suiteBuilderCaseTagsLabel")}
value={draftCase.tags}
onChange={(event) => updateCase(draftCase.id, { tags: event.target.value })}
placeholder={t("suiteBuilderCaseTagsPlaceholder")}
hint={t("suiteBuilderCaseTagsHint")}
/>
<Select
label={t("suiteBuilderCaseStrategyLabel")}
value={draftCase.strategy}
onChange={(event) =>
updateCase(draftCase.id, { strategy: event.target.value as BuilderStrategy })
}
options={editableStrategies.map((strategy) => ({
value: strategy.name,
label: t(strategy.labelKey),
}))}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4">
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseSystemPromptLabel")}
</span>
<textarea
value={draftCase.systemPrompt}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label={t("suiteBuilderCaseNameLabel")}
value={draftCase.name}
onChange={(event) => updateCase(draftCase.id, { name: event.target.value })}
placeholder={t("suiteBuilderCaseNamePlaceholder")}
/>
<Input
label={t("suiteBuilderCaseModelLabel")}
value={draftCase.model}
onChange={(event) => updateCase(draftCase.id, { model: event.target.value })}
placeholder={t("suiteBuilderCaseModelPlaceholder")}
/>
<Input
label={t("suiteBuilderCaseTagsLabel")}
value={draftCase.tags}
onChange={(event) => updateCase(draftCase.id, { tags: event.target.value })}
placeholder={t("suiteBuilderCaseTagsPlaceholder")}
hint={t("suiteBuilderCaseTagsHint")}
/>
<Select
label={t("suiteBuilderCaseStrategyLabel")}
value={draftCase.strategy}
onChange={(event) =>
updateCase(draftCase.id, { systemPrompt: event.target.value })
updateCase(draftCase.id, { strategy: event.target.value as BuilderStrategy })
}
rows={3}
placeholder={t("suiteBuilderCaseSystemPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
options={editableStrategies.map((strategy) => ({
value: strategy.name,
label: t(strategy.labelKey),
}))}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseUserPromptLabel")}
</span>
<textarea
value={draftCase.userPrompt}
onChange={(event) => updateCase(draftCase.id, { userPrompt: event.target.value })}
rows={4}
placeholder={t("suiteBuilderCaseUserPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
{selectedStrategy && (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 ${selectedStrategy.bg}`}
>
<span
className={`material-symbols-outlined mt-0.5 text-[18px] ${selectedStrategy.color}`}
>
{selectedStrategy.icon}
</span>
<p className="text-xs leading-relaxed text-text-muted">
{t(selectedStrategy.descriptionKey)}
</p>
</div>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4">
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseSystemPromptLabel")}
</span>
<textarea
value={draftCase.systemPrompt}
onChange={(event) =>
updateCase(draftCase.id, { systemPrompt: event.target.value })
}
rows={3}
placeholder={t("suiteBuilderCaseSystemPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-main">
{t("suiteBuilderCaseUserPromptLabel")}
</span>
<textarea
value={draftCase.userPrompt}
onChange={(event) =>
updateCase(draftCase.id, { userPrompt: event.target.value })
}
rows={4}
placeholder={t("suiteBuilderCaseUserPromptPlaceholder")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-text-main outline-none focus:border-primary"
/>
</label>
<Input
label={t("suiteBuilderCaseExpectedLabel")}
value={draftCase.expectedValue}
onChange={(event) =>
updateCase(draftCase.id, { expectedValue: event.target.value })
}
placeholder={getExpectedPlaceholder(draftCase.strategy)}
hint={getExpectedHint(draftCase.strategy)}
/>
</label>
<Input
label={t("suiteBuilderCaseExpectedLabel")}
value={draftCase.expectedValue}
onChange={(event) =>
updateCase(draftCase.id, { expectedValue: event.target.value })
}
placeholder={t("suiteBuilderCaseExpectedPlaceholder")}
/>
</div>
</Card>
))}
</div>
</Card>
);
})}
<div className="flex gap-2">
<Button fullWidth onClick={onSave} disabled={saving}>

View File

@@ -0,0 +1,624 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, ConfirmModal, Modal } from "@/shared/components";
type WebhookItem = {
id: string;
url: string;
events: string[];
secret: string | null;
enabled: boolean;
description: string;
created_at: string;
last_triggered_at: string | null;
last_status: number | null;
failure_count: number;
};
type WebhookFormState = {
url: string;
name: string;
secret: string;
events: string[];
enabled: boolean;
};
type FeedbackState = {
type: "success" | "error";
message: string;
} | null;
const WEBHOOK_EVENTS = [
"request.completed",
"request.failed",
"provider.error",
"provider.recovered",
"quota.exceeded",
"combo.switched",
] as const;
const EMPTY_FORM: WebhookFormState = {
url: "",
name: "",
secret: "",
events: ["*"],
enabled: true,
};
function getWebhookStatus(webhook: WebhookItem): "active" | "inactive" | "errored" {
if (!webhook.enabled) return "inactive";
if (webhook.failure_count > 0 || (webhook.last_status !== null && webhook.last_status >= 400)) {
return "errored";
}
return "active";
}
export default function WebhooksPage() {
const t = useTranslations("webhooks");
const tc = useTranslations("common");
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const [form, setForm] = useState<WebhookFormState>(EMPTY_FORM);
const [formMode, setFormMode] = useState<"create" | "edit" | null>(null);
const [editingWebhook, setEditingWebhook] = useState<WebhookItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WebhookItem | null>(null);
const loadWebhooks = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/webhooks");
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || t("loadFailed"));
}
setWebhooks(Array.isArray(data.webhooks) ? data.webhooks : []);
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("loadFailed"),
});
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadWebhooks();
}, [loadWebhooks]);
const stats = useMemo(() => {
return webhooks.reduce(
(acc, webhook) => {
const status = getWebhookStatus(webhook);
acc.total += 1;
acc[status] += 1;
return acc;
},
{ total: 0, active: 0, inactive: 0, errored: 0 }
);
}, [webhooks]);
const resetForm = () => {
setForm(EMPTY_FORM);
setFormMode(null);
setEditingWebhook(null);
};
const openCreateModal = () => {
setFeedback(null);
setForm(EMPTY_FORM);
setFormMode("create");
setEditingWebhook(null);
};
const openEditModal = (webhook: WebhookItem) => {
setFeedback(null);
setFormMode("edit");
setEditingWebhook(webhook);
setForm({
url: webhook.url,
name: webhook.description || "",
secret: "",
events: webhook.events.length > 0 ? webhook.events : ["*"],
enabled: webhook.enabled,
});
};
const closeModal = () => {
if (saving) return;
resetForm();
};
const toggleEvent = (eventName: string) => {
setForm((prev) => {
if (eventName === "*") {
return { ...prev, events: ["*"] };
}
if (prev.events.includes("*")) {
return { ...prev, events: [eventName] };
}
const nextEvents = prev.events.includes(eventName)
? prev.events.filter((event) => event !== eventName)
: [...prev.events, eventName];
return { ...prev, events: nextEvents.length > 0 ? nextEvents : ["*"] };
});
};
const saveWebhook = async () => {
if (!form.url.trim()) return;
setSaving(true);
setFeedback(null);
const payload: Record<string, unknown> = {
url: form.url.trim(),
events: form.events,
description: form.name.trim(),
enabled: form.enabled,
};
if (form.secret.trim()) {
payload.secret = form.secret.trim();
}
const isEditing = formMode === "edit" && Boolean(editingWebhook?.id);
try {
const response = await fetch(
isEditing ? `/api/webhooks/${editingWebhook?.id}` : "/api/webhooks",
{
method: isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || t("saveFailed"));
}
setFeedback({ type: "success", message: t("saveSuccess") });
resetForm();
await loadWebhooks();
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("saveFailed"),
});
} finally {
setSaving(false);
}
};
const toggleEnabled = async (webhook: WebhookItem) => {
setFeedback(null);
try {
const response = await fetch(`/api/webhooks/${webhook.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !webhook.enabled }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || t("saveFailed"));
}
setWebhooks((prev) =>
prev.map((item) => (item.id === webhook.id ? { ...item, enabled: !webhook.enabled } : item))
);
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("saveFailed"),
});
}
};
const testWebhook = async (webhook: WebhookItem) => {
setTestingId(webhook.id);
setFeedback(null);
try {
const response = await fetch(`/api/webhooks/${webhook.id}/test`, { method: "POST" });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.delivered === false) {
throw new Error(data.error || t("testFailed"));
}
setFeedback({ type: "success", message: t("testSuccess") });
await loadWebhooks();
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("testFailed"),
});
} finally {
setTestingId(null);
}
};
const deleteWebhook = async () => {
if (!deleteTarget) return;
setSaving(true);
setFeedback(null);
try {
const response = await fetch(`/api/webhooks/${deleteTarget.id}`, { method: "DELETE" });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || t("deleteFailed"));
}
setWebhooks((prev) => prev.filter((webhook) => webhook.id !== deleteTarget.id));
setDeleteTarget(null);
setFeedback({ type: "success", message: t("deleteSuccess") });
} catch (error) {
setFeedback({
type: "error",
message: error instanceof Error ? error.message : t("deleteFailed"),
});
} finally {
setSaving(false);
}
};
const modalTitle = formMode === "edit" ? t("editWebhook") : t("addWebhook");
const isModalOpen = formMode !== null;
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[24px] text-primary">webhook</span>
<h1 className="text-3xl font-bold tracking-tight text-text-main">{t("title")}</h1>
</div>
<p className="mt-1 text-sm text-text-muted">{t("description")}</p>
</div>
<button
onClick={openCreateModal}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
<span className="material-symbols-outlined text-[18px]">add</span>
{t("addWebhook")}
</button>
</div>
{feedback && (
<div
className={`rounded-lg border px-4 py-3 text-sm ${
feedback.type === "success"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"
: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-300"
}`}
>
{feedback.message}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[
{ label: t("total"), value: stats.total, icon: "webhook", tone: "text-primary" },
{
label: t("active"),
value: stats.active,
icon: "check_circle",
tone: "text-emerald-500",
},
{
label: t("inactive"),
value: stats.inactive,
icon: "pause_circle",
tone: "text-text-muted",
},
{ label: t("errored"), value: stats.errored, icon: "error", tone: "text-red-500" },
].map((stat) => (
<Card key={stat.label} className="p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-text-muted">
{stat.label}
</p>
<p className="mt-1 text-2xl font-semibold text-text-main">{stat.value}</p>
</div>
<span className={`material-symbols-outlined text-[24px] ${stat.tone}`}>
{stat.icon}
</span>
</div>
</Card>
))}
</div>
<Card className="overflow-hidden">
<div className="border-b border-border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-text-main">{t("configuredWebhooks")}</h2>
<p className="mt-1 text-xs text-text-muted">{t("configuredWebhooksDesc")}</p>
</div>
<button
onClick={() => void loadWebhooks()}
disabled={loading}
title={t("refresh")}
className="rounded-lg border border-border p-2 text-text-muted transition-colors hover:bg-surface/60 hover:text-text-main disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[18px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-sm text-text-muted">{t("loading")}</div>
) : webhooks.length === 0 ? (
<div className="p-10 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted">webhook</span>
<p className="mt-3 text-sm text-text-muted">{t("noWebhooks")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] text-left text-sm">
<thead className="border-b border-border bg-sidebar/40 text-xs uppercase tracking-wider text-text-muted">
<tr>
<th className="px-4 py-3 font-medium">{t("name")}</th>
<th className="px-4 py-3 font-medium">{t("url")}</th>
<th className="px-4 py-3 font-medium">{t("events")}</th>
<th className="px-4 py-3 font-medium">{t("status")}</th>
<th className="px-4 py-3 font-medium">{t("lastTriggered")}</th>
<th className="px-4 py-3 text-right font-medium">{t("actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{webhooks.map((webhook) => {
const status = getWebhookStatus(webhook);
return (
<tr key={webhook.id} className="transition-colors hover:bg-sidebar/30">
<td className="px-4 py-3">
<div className="font-medium text-text-main">
{webhook.description || t("unnamedWebhook")}
</div>
<div className="text-xs text-text-muted">
{t("failureCount", { count: webhook.failure_count })}
</div>
</td>
<td className="max-w-[320px] px-4 py-3">
<code className="block truncate rounded bg-sidebar px-2 py-1 text-xs text-text-main">
{webhook.url}
</code>
</td>
<td className="px-4 py-3">
<div className="flex max-w-[260px] flex-wrap gap-1">
{webhook.events.map((eventName) => (
<span
key={eventName}
className="rounded-full border border-border bg-surface px-2 py-0.5 text-xs text-text-muted"
>
{eventName === "*" ? t("allEvents") : eventName}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium ${
status === "active"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"
: status === "errored"
? "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-300"
: "border-border bg-sidebar text-text-muted"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{status === "active"
? "check_circle"
: status === "errored"
? "error"
: "pause_circle"}
</span>
{t(status)}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-muted">
{webhook.last_triggered_at
? new Date(webhook.last_triggered_at).toLocaleString()
: t("never")}
{webhook.last_status ? (
<span className="ml-1 font-mono text-xs">({webhook.last_status})</span>
) : null}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button
onClick={() => void testWebhook(webhook)}
disabled={testingId === webhook.id}
title={t("testWebhook")}
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-primary/10 hover:text-primary disabled:opacity-40"
>
<span
className={`material-symbols-outlined text-[18px] ${
testingId === webhook.id ? "animate-spin" : ""
}`}
>
{testingId === webhook.id ? "sync" : "send"}
</span>
</button>
<button
onClick={() => void toggleEnabled(webhook)}
title={webhook.enabled ? t("disable") : t("enable")}
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface/60 hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">
{webhook.enabled ? "toggle_on" : "toggle_off"}
</span>
</button>
<button
onClick={() => openEditModal(webhook)}
title={t("edit")}
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface/60 hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
<button
onClick={() => setDeleteTarget(webhook)}
title={t("delete")}
className="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-500/10"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<Card className="p-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-[20px] text-amber-500">vpn_key</span>
<div className="space-y-2">
<h2 className="text-sm font-semibold text-text-main">{t("signatureTitle")}</h2>
<p className="text-sm text-text-muted">{t("signatureDescription")}</p>
<code className="block whitespace-pre-wrap rounded-lg bg-sidebar p-3 text-xs text-text-main">
{`const sig = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");`}
</code>
</div>
</div>
</Card>
<Modal
isOpen={isModalOpen}
onClose={closeModal}
title={modalTitle}
size="lg"
footer={
<>
<button
onClick={closeModal}
disabled={saving}
className="rounded-lg px-4 py-2 text-sm font-medium text-text-muted transition-colors hover:bg-sidebar hover:text-text-main disabled:opacity-40"
>
{tc("cancel")}
</button>
<button
onClick={() => void saveWebhook()}
disabled={saving || !form.url.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{saving && (
<span className="material-symbols-outlined animate-spin text-[16px]">sync</span>
)}
{tc("save")}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("name")}
</label>
<input
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t("namePlaceholder")}
className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<div>
<label className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("url")}
</label>
<input
value={form.url}
onChange={(event) => setForm((prev) => ({ ...prev, url: event.target.value }))}
placeholder="https://example.com/webhook"
className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<div>
<label className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("secret")}
</label>
<input
value={form.secret}
onChange={(event) => setForm((prev) => ({ ...prev, secret: event.target.value }))}
placeholder={
formMode === "edit" ? t("secretEditPlaceholder") : t("secretPlaceholder")
}
className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<div>
<label className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("events")}
</label>
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggleEvent("*")}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
form.events.includes("*")
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-surface text-text-muted hover:text-text-main"
}`}
>
{t("allEvents")}
</button>
{WEBHOOK_EVENTS.map((eventName) => (
<button
key={eventName}
type="button"
onClick={() => toggleEvent(eventName)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
form.events.includes("*") || form.events.includes(eventName)
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-surface text-text-muted hover:text-text-main"
}`}
>
{eventName}
</button>
))}
</div>
</div>
<label className="flex items-center gap-3 rounded-lg border border-border p-3">
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => setForm((prev) => ({ ...prev, enabled: event.target.checked }))}
className="size-4 accent-primary"
/>
<span>
<span className="block text-sm font-medium text-text-main">{t("enabled")}</span>
<span className="block text-xs text-text-muted">{t("enabledDesc")}</span>
</span>
</label>
</div>
</Modal>
<ConfirmModal
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={deleteWebhook}
title={t("delete")}
message={t("deleteConfirm")}
confirmText={t("delete")}
cancelText={tc("cancel")}
loading={saving}
/>
</div>
);
}

View File

@@ -63,30 +63,43 @@ export async function GET() {
],
},
{
id: "intelligent-routing",
name: "Intelligent Model Combos",
id: "provider-discovery",
name: "Provider Discovery",
description:
"Self-healing model chains with auto and LKGP routing. " +
"Adapts to provider health, quota, latency, and cost using " +
"the unified combos dashboard intelligent routing controls.",
tags: ["combo", "intelligent-routing", "self-healing", "adaptive"],
"Discovers providers that can handle a requested capability " +
"such as chat, images, audio, search, embeddings, rerank, or video. " +
"Reports availability, health, configuration status, and a recommended provider.",
tags: ["providers", "discovery", "capabilities", "health"],
examples: [
"Create an auto-managed combo for coding tasks",
"Switch to cost-saver mode",
"Show the intelligent routing scoring breakdown",
"Which providers can handle image generation?",
"Find healthy providers for embeddings",
"What local providers are configured?",
],
},
{
id: "format-translation",
name: "Format Translation",
id: "cost-analysis",
name: "Cost Analysis",
description:
"Transparently translates between OpenAI, Claude (Anthropic), " +
"Gemini (Google), and Responses API formats. Supports streaming " +
"translation for all format pairs.",
tags: ["translation", "openai", "claude", "gemini", "responses"],
"Analyzes usage costs by provider and model, compares recent periods, " +
"and returns cost-saving opportunities for agents to act on.",
tags: ["cost", "usage", "analytics", "optimization"],
examples: [
"Send an OpenAI-format request to Claude",
"Translate this Gemini response to OpenAI format",
"How much did we spend this week?",
"Which provider is costing the most?",
"Suggest cost-saving opportunities for the last 30 days",
],
},
{
id: "health-report",
name: "Health Report",
description:
"Aggregates provider health, circuit breaker states, rate limit queues, " +
"lockouts, and telemetry into a structured report for orchestration.",
tags: ["health", "monitoring", "resilience", "telemetry"],
examples: [
"Is everything healthy?",
"Report degraded providers and retry timing",
"Summarize active rate limits and lockouts",
],
},
],

View File

@@ -12,18 +12,10 @@
import { NextRequest, NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { executeSmartRouting } from "@/lib/a2a/skills/smartRouting";
import { executeQuotaManagement } from "@/lib/a2a/skills/quotaManagement";
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
// ============ Skill Registry ============
const SKILL_HANDLERS: Record<string, (task: any) => Promise<any>> = {
"smart-routing": executeSmartRouting,
"quota-management": executeQuotaManagement,
};
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
type A2AMessage = { role: string; content: string };
@@ -95,6 +87,22 @@ function jsonRpcResult(id: string | number | null, result: unknown) {
return NextResponse.json({ jsonrpc: "2.0", id, result });
}
async function rejectIfA2ADisabled(id: string | number | null) {
const settings = await getSettings();
if (settings.a2aEnabled === true) return null;
return NextResponse.json(
{
jsonrpc: "2.0",
id,
error: {
code: -32000,
message: "A2A endpoint is disabled. Enable it from the Endpoints page.",
},
},
{ status: 503 }
);
}
// ============ Route Handler ============
export async function POST(req: NextRequest) {
@@ -116,6 +124,9 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id || null, -32600, "Invalid request: missing jsonrpc or method");
}
const disabledResponse = await rejectIfA2ADisabled(id ?? null);
if (disabledResponse) return disabledResponse;
const tm = getTaskManager();
switch (method) {
@@ -131,7 +142,7 @@ export async function POST(req: NextRequest) {
);
}
const handler = SKILL_HANDLERS[skill];
const handler = A2A_SKILL_HANDLERS[skill];
if (!handler) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
@@ -144,18 +155,22 @@ export async function POST(req: NextRequest) {
// Log routing decision
if (skill === "smart-routing" && result.metadata) {
const smartMetadata = result.metadata as {
routing_explanation?: string;
cost_envelope?: { actual?: number };
};
logRoutingDecision({
taskType: (params?.metadata?.role as string) || "general",
comboId: (params?.metadata?.combo as string) || "default",
providerSelected:
result.metadata?.routing_explanation?.match(/"([^"]+)"/)?.[1] || "unknown",
smartMetadata.routing_explanation?.match(/"([^"]+)"/)?.[1] || "unknown",
modelUsed: (params?.metadata?.model as string) || "auto",
score: 1,
factors: [],
fallbacksTriggered: [],
success: true,
latencyMs: 0,
cost: result.metadata?.cost_envelope?.actual || 0,
cost: smartMetadata.cost_envelope?.actual || 0,
});
}
@@ -183,7 +198,7 @@ export async function POST(req: NextRequest) {
);
}
const handler = SKILL_HANDLERS[skill];
const handler = A2A_SKILL_HANDLERS[skill];
if (!handler) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}

View File

@@ -1,22 +1,30 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { getSettings } from "@/lib/db/settings";
export async function GET() {
try {
const tm = getTaskManager();
const stats = tm.getStats();
const [settings, stats] = await Promise.all([
getSettings(),
Promise.resolve(getTaskManager().getStats()),
]);
const enabled = settings.a2aEnabled === true;
let agentCard: any = null;
try {
const agentModule = await import("@/app/.well-known/agent.json/route");
const cardResponse = await agentModule.GET();
agentCard = await cardResponse.json();
} catch {
agentCard = null;
if (enabled) {
try {
const agentModule = await import("@/app/.well-known/agent.json/route");
const cardResponse = await agentModule.GET();
agentCard = await cardResponse.json();
} catch {
agentCard = null;
}
}
return NextResponse.json({
status: "ok",
status: enabled ? "ok" : "disabled",
online: enabled,
enabled,
tasks: stats,
agent: agentCard
? {

View File

@@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { getEvalScorecard, listEvalRuns } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const url = new URL(request.url);
const suiteId = url.searchParams.get("suiteId")?.trim() || undefined;
const limitValue = Number.parseInt(url.searchParams.get("limit") || "", 10);
const limit = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(limitValue, 100) : 50;
return NextResponse.json({
scorecard: getEvalScorecard({ suiteId, limit }),
runs: listEvalRuns({ suiteId, limit }),
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -4,14 +4,14 @@ import {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import {
LOCAL_PROVIDERS,
UPSTREAM_PROXY_PROVIDERS,
WEB_COOKIE_PROVIDERS,
SEARCH_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
} from "@/shared/constants/config";
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { testSingleConnection } from "../[id]/test/route";
import { providersBatchTestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -24,6 +24,8 @@ function getAuthGroup(providerId) {
if (WEB_COOKIE_PROVIDERS[providerId]) return "web-cookie";
if (SEARCH_PROVIDERS[providerId]) return "search";
if (AUDIO_ONLY_PROVIDERS[providerId]) return "audio";
if (LOCAL_PROVIDERS[providerId]) return "local";
if (UPSTREAM_PROXY_PROVIDERS[providerId]) return "upstream-proxy";
if (APIKEY_PROVIDERS[providerId]) return "apikey";
if (
typeof providerId === "string" &&
@@ -91,6 +93,12 @@ export async function POST(request) {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "search");
} else if (mode === "audio") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "audio");
} else if (mode === "local") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "local");
} else if (mode === "upstream-proxy") {
connectionsToTest = allConnections.filter(
(c) => getAuthGroup(c.provider) === "upstream-proxy"
);
} else if (mode === "compatible") {
connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider));
} else if (mode === "all") {
@@ -99,7 +107,7 @@ export async function POST(request) {
return NextResponse.json(
{
error:
"Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio",
"Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy",
},
{ status: 400 }
);

View File

@@ -0,0 +1,274 @@
export const runtime = "nodejs";
import fs from "fs";
import path from "path";
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { KIRO_MITM_PROFILE } from "@/mitm/targets/kiro";
type MitmTargetRoute = {
id: string;
name: string;
targetHost: string;
targetPort: number;
localPort: number;
endpoints: string[];
enabled: boolean;
};
type MitmStats = {
startedAt: string | null;
totalRequests: number;
interceptedRequests: number;
activeConnections: number;
lastRequestAt: string | null;
lastInterceptAt: string | null;
};
type MitmConfig = {
port: number;
targets: MitmTargetRoute[];
};
const DEFAULT_PORT = 443;
const MITM_PORT_ERROR =
"Transparent MITM interception currently requires port 443 because DNS override does not redirect destination ports.";
const updateMitmSchema = z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
keyId: z.string().optional(),
sudoPassword: z.string().optional(),
port: z.coerce.number().int().min(1).max(65535).optional(),
});
const regenerateSchema = z.object({
action: z.literal("regenerate-cert").optional(),
});
function getMitmDir() {
return path.join(resolveMitmDataDir(), "mitm");
}
function getConfigPath() {
return path.join(getMitmDir(), "settings.json");
}
function getStatsPath() {
return path.join(getMitmDir(), "stats.json");
}
function getCertPath() {
return path.join(getMitmDir(), "server.crt");
}
function getKeyPath() {
return path.join(getMitmDir(), "server.key");
}
function defaultTargets(port = DEFAULT_PORT): MitmTargetRoute[] {
return [
{
id: "antigravity",
name: "Antigravity",
targetHost: "daily-cloudcode-pa.googleapis.com",
targetPort: 443,
localPort: port,
endpoints: [":generateContent", ":streamGenerateContent"],
enabled: true,
},
{
id: KIRO_MITM_PROFILE.id,
name: KIRO_MITM_PROFILE.name,
targetHost: KIRO_MITM_PROFILE.targetHost,
targetPort: KIRO_MITM_PROFILE.targetPort,
localPort: KIRO_MITM_PROFILE.localPort,
endpoints: KIRO_MITM_PROFILE.apiEndpoints,
enabled: false,
},
];
}
function readConfig(): MitmConfig {
try {
JSON.parse(fs.readFileSync(getConfigPath(), "utf8"));
return {
port: DEFAULT_PORT,
targets: defaultTargets(DEFAULT_PORT),
};
} catch {
return {
port: DEFAULT_PORT,
targets: defaultTargets(DEFAULT_PORT),
};
}
}
function writeConfig() {
const mitmDir = getMitmDir();
fs.mkdirSync(mitmDir, { recursive: true });
fs.writeFileSync(getConfigPath(), JSON.stringify({ port: DEFAULT_PORT }, null, 2));
}
function readStats(): MitmStats {
try {
const raw = JSON.parse(fs.readFileSync(getStatsPath(), "utf8"));
return {
startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null,
totalRequests: Number(raw.totalRequests || 0),
interceptedRequests: Number(raw.interceptedRequests || 0),
activeConnections: Number(raw.activeConnections || 0),
lastRequestAt: typeof raw.lastRequestAt === "string" ? raw.lastRequestAt : null,
lastInterceptAt: typeof raw.lastInterceptAt === "string" ? raw.lastInterceptAt : null,
};
} catch {
return {
startedAt: null,
totalRequests: 0,
interceptedRequests: 0,
activeConnections: 0,
lastRequestAt: null,
lastInterceptAt: null,
};
}
}
async function buildMitmResponse() {
const { getMitmStatus, getCachedPassword } = await import("@/mitm/manager");
const status = await getMitmStatus();
const config = readConfig();
const stats = readStats();
return {
running: status.running,
pid: status.pid || null,
dnsConfigured: status.dnsConfigured || false,
certExists: status.certExists || fs.existsSync(getCertPath()),
hasCachedPassword: !!getCachedPassword(),
port: config.port,
targets: config.targets,
stats,
};
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
if (searchParams.get("download") === "cert") {
const certPath = getCertPath();
if (!fs.existsSync(certPath)) {
return NextResponse.json({ error: "MITM certificate not found" }, { status: 404 });
}
return new NextResponse(fs.readFileSync(certPath), {
headers: {
"Content-Type": "application/x-pem-file",
"Content-Disposition": 'attachment; filename="omniroute-mitm-ca.crt"',
},
});
}
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MITM settings";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json().catch(() => ({}));
const parsed = updateMitmSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const config = readConfig();
if (parsed.data.port !== undefined && parsed.data.port !== DEFAULT_PORT) {
return NextResponse.json({ error: MITM_PORT_ERROR }, { status: 400 });
}
if (parsed.data.port !== undefined) {
config.port = DEFAULT_PORT;
config.targets = defaultTargets(config.port);
writeConfig();
}
if (typeof parsed.data.enabled === "boolean") {
const { getCachedPassword, setCachedPassword, startMitm, stopMitm } =
await import("@/mitm/manager");
const isWin = process.platform === "win32";
const sudoPassword = parsed.data.sudoPassword || getCachedPassword() || "";
if (parsed.data.enabled) {
const apiKey = await resolveApiKey(parsed.data.keyId || null, parsed.data.apiKey || null);
if (!apiKey || (!isWin && !sudoPassword)) {
return NextResponse.json(
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" },
{ status: 400 }
);
}
await startMitm(apiKey, sudoPassword, { port: config.port });
if (!isWin) setCachedPassword(sudoPassword);
} else {
if (!isWin && !sudoPassword) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
}
await stopMitm(sudoPassword);
if (!isWin && parsed.data.sudoPassword) setCachedPassword(parsed.data.sudoPassword);
}
}
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update MITM settings";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const rawBody = await request.json().catch(() => ({}));
const parsed = regenerateSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { getMitmStatus } = await import("@/mitm/manager");
const status = await getMitmStatus();
if (status.running) {
return NextResponse.json(
{ error: "Stop the MITM proxy before regenerating certificates" },
{ status: 409 }
);
}
for (const filePath of [getCertPath(), getKeyPath()]) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
const { generateCert } = await import("@/mitm/cert/generate");
await generateCert();
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to regenerate MITM certificate";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -9,12 +9,22 @@ export async function GET(request) {
const summary = getTelemetrySummary(windowMs);
const { getQuotaMonitorSummary } = await import("@omniroute/open-sse/services/quotaMonitor.ts");
const { getActiveSessions } = await import("@omniroute/open-sse/services/sessionManager.ts");
const quotaMonitorSummary = getQuotaMonitorSummary();
const activeSessions = getActiveSessions();
const payload = buildTelemetryPayload({
summary,
quotaMonitorSummary: getQuotaMonitorSummary(),
activeSessions: getActiveSessions(),
quotaMonitorSummary,
activeSessions,
});
const totalRequests = payload.totalRequests || 0;
return NextResponse.json({
...payload,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
activeConnections: activeSessions.length,
errorRate:
totalRequests > 0 ? (quotaMonitorSummary.errors / Math.max(totalRequests, 1)) * 100 : 0,
});
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}

View File

@@ -1,44 +0,0 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const file = searchParams.get("file");
if (!file) {
return NextResponse.json(
{ success: false, error: "File parameter required" },
{ status: 400 }
);
}
// Security: only allow specific filenames
const allowedFiles = [
"1_req_client.json",
"3_req_openai.json",
"4_req_target.json",
"5_res_provider.txt",
];
if (!allowedFiles.includes(file)) {
return NextResponse.json({ success: false, error: "Invalid file name" }, { status: 400 });
}
const logsDir = path.join(process.cwd(), "logs", "translator");
const filePath = path.join(logsDir, file);
// Check if file exists
if (!fs.existsSync(filePath)) {
return NextResponse.json({ success: false, error: "File not found" }, { status: 404 });
}
const content = fs.readFileSync(filePath, "utf-8");
return NextResponse.json({ success: true, content });
} catch (error) {
console.error("Error loading file:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@@ -1,63 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { translatorSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
success: false,
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(translatorSaveSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
const { file, content } = validation.data;
// Security: only allow specific filenames
const allowedFiles = [
"1_req_client.json",
"3_req_openai.json",
"4_req_target.json",
"5_res_provider.txt",
];
if (!allowedFiles.includes(file)) {
return NextResponse.json({ success: false, error: "Invalid file name" }, { status: 400 });
}
const logsDir = path.join(process.cwd(), "logs", "translator");
// Create directory if not exists
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const filePath = path.join(logsDir, file);
fs.writeFileSync(filePath, content, "utf-8");
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error saving file:", error);
return NextResponse.json({ success: false, error: "Failed to save file" }, { status: 500 });
}
}

View File

@@ -1,6 +1,4 @@
import { NextResponse } from "next/server";
import { getUsageDb } from "@/lib/usageDb";
import { computeAnalytics } from "@/lib/usageAnalytics";
import { getDbInstance } from "@/lib/db/core";
function getRangeStartIso(range: string): string | null {
@@ -32,83 +30,652 @@ function getRangeStartIso(range: string): string | null {
return start.toISOString();
}
function shortModelName(model: string | null): string {
if (!model) return "-";
const parts = model.split(/[/:-]/);
return parts[parts.length - 1] || model;
}
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
type PricingByProvider = Record<string, Record<string, Record<string, unknown>>>;
type ComputeCostFromPricing = (
pricing: Record<string, unknown> | null | undefined,
tokens: Record<string, number | undefined> | null | undefined
) => number;
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toStringValue(value: unknown, fallback = ""): string {
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
}
function roundCost(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function appendWhereCondition(whereClause: string, condition: string): string {
return whereClause ? `${whereClause} AND (${condition})` : `WHERE (${condition})`;
}
function resolveModelPricing(
pricingByProvider: PricingByProvider,
provider: string,
model: string,
normalizeModelName: (model: string) => string
): Record<string, unknown> | null {
const providerPricing = pricingByProvider[provider];
if (!providerPricing) return null;
const normalizedModel = normalizeModelName(model);
const shortModel = shortModelName(model);
return (
providerPricing[model] ||
providerPricing[normalizedModel] ||
providerPricing[shortModel] ||
null
);
}
function computeUsageRowCost(
row: Record<string, unknown>,
pricingByProvider: PricingByProvider,
normalizeModelName: (model: string) => string,
computeCostFromPricing: ComputeCostFromPricing
): number {
const provider = toStringValue(row.provider);
const model = toStringValue(row.model);
if (!provider || !model) return 0;
const pricing = resolveModelPricing(pricingByProvider, provider, model, normalizeModelName);
if (!pricing) return 0;
return computeCostFromPricing(pricing, {
input: toNumber(row.promptTokens),
output: toNumber(row.completionTokens),
cacheRead: toNumber(row.cacheReadTokens),
cacheCreation: toNumber(row.cacheCreationTokens),
reasoning: toNumber(row.reasoningTokens),
});
}
function formatUtcDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function computeActivityStreak(activityMap: Record<string, number>): number {
const cursor = new Date();
let streak = 0;
while ((activityMap[formatUtcDate(cursor)] || 0) > 0) {
streak += 1;
cursor.setUTCDate(cursor.getUTCDate() - 1);
}
return streak;
}
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const range = searchParams.get("range") || "30d";
const sinceIso = getRangeStartIso(range);
const presetsParam = searchParams.get("presets");
// Cap history load to last 365 days — the heatmap never looks beyond that,
// and all named ranges (1d/7d/30d/90d/ytd) fall within this window.
const heatmapSince = new Date();
heatmapSince.setDate(heatmapSince.getDate() - 365);
const db = await getUsageDb(heatmapSince.toISOString());
const history = db.data.history || [];
const db = getDbInstance();
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
const params = sinceIso ? { since: sinceIso } : {};
// Build connection map for account names
const { getProviderConnections } = await import("@/lib/localDb");
const connectionMap: Record<string, string> = {};
try {
const connections = await getProviderConnections();
for (const connRaw of connections as unknown[]) {
const conn =
connRaw && typeof connRaw === "object" && !Array.isArray(connRaw)
? (connRaw as Record<string, unknown>)
: {};
const connectionId =
typeof conn.id === "string" && conn.id.trim().length > 0 ? conn.id : null;
if (!connectionId) continue;
// Fetch pricing data for cost calculation (no rows loaded)
const { getPricing } = await import("@/lib/db/settings");
const pricingByProvider = (await getPricing()) as PricingByProvider;
const { computeCostFromPricing, normalizeModelName } =
await import("@/lib/usage/costCalculator");
const name =
(typeof conn.name === "string" && conn.name.trim()) ||
(typeof conn.email === "string" && conn.email.trim()) ||
connectionId;
connectionMap[connectionId] = name;
}
} catch {
/* ignore */
const summaryRow = db
.prepare(
`
SELECT
COUNT(*) as totalRequests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COUNT(DISTINCT model) as uniqueModels,
COUNT(DISTINCT connection_id) as uniqueAccounts,
COUNT(DISTINCT api_key_id) as uniqueApiKeys,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MIN(timestamp), '') as firstRequest,
COALESCE(MAX(timestamp), '') as lastRequest
FROM usage_history
${whereClause}
`
)
.get(params) as Record<string, unknown>;
const dailyRows = db
.prepare(
`
SELECT
DATE(timestamp) as date,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY DATE(timestamp)
ORDER BY date ASC
`
)
.all(params) as Array<Record<string, unknown>>;
const dailyCostRows = db
.prepare(
`
SELECT
DATE(timestamp) as date,
provider,
model,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
FROM usage_history
${whereClause}
GROUP BY DATE(timestamp), provider, model
ORDER BY date ASC
`
)
.all(params) as Array<Record<string, unknown>>;
const heatmapStart = new Date();
heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364);
const heatmapRows = db
.prepare(
`
SELECT
DATE(timestamp) as date,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
WHERE timestamp >= @heatmapStart
GROUP BY DATE(timestamp)
ORDER BY date ASC
`
)
.all({ heatmapStart: heatmapStart.toISOString() }) as Array<Record<string, unknown>>;
const modelRows = db
.prepare(
`
SELECT
model,
provider,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(MAX(timestamp), '') as lastUsed
FROM usage_history
${whereClause}
GROUP BY model, provider
ORDER BY requests DESC
LIMIT 50
`
)
.all(params) as Array<Record<string, unknown>>;
const providerCostRows = db
.prepare(
`
SELECT
provider,
model,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
FROM usage_history
${whereClause}
GROUP BY provider, model
`
)
.all(params) as Array<Record<string, unknown>>;
const providerRows = db
.prepare(
`
SELECT
provider,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests
FROM usage_history
${whereClause}
GROUP BY provider
ORDER BY requests DESC
`
)
.all(params) as Array<Record<string, unknown>>;
const accountCostRows = db
.prepare(
`
SELECT
connection_id as account,
provider,
model,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
FROM usage_history
${whereClause}
GROUP BY connection_id, provider, model
`
)
.all(params) as Array<Record<string, unknown>>;
const accountRows = db
.prepare(
`
SELECT
connection_id as account,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MAX(timestamp), '') as lastUsed
FROM usage_history
${whereClause}
GROUP BY connection_id
ORDER BY requests DESC
LIMIT 50
`
)
.all(params) as Array<Record<string, unknown>>;
const apiKeyWhereClause = appendWhereCondition(
whereClause,
"(api_key_id IS NOT NULL AND api_key_id != '') OR (api_key_name IS NOT NULL AND api_key_name != '')"
);
const apiKeyRows = db
.prepare(
`
SELECT
api_key_id as apiKeyId,
COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName,
provider,
model,
COUNT(*) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${apiKeyWhereClause}
GROUP BY api_key_id, api_key_name, provider, model
`
)
.all(params) as Array<Record<string, unknown>>;
const weeklyRows = db
.prepare(
`
SELECT
dayOfWeek,
COUNT(*) as days,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(totalTokens), 0) as totalTokens
FROM (
SELECT
DATE(timestamp) as date,
strftime('%w', timestamp) as dayOfWeek,
COUNT(*) as requests,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${whereClause}
GROUP BY DATE(timestamp), strftime('%w', timestamp)
)
GROUP BY dayOfWeek
ORDER BY dayOfWeek ASC
`
)
.all(params) as Array<Record<string, unknown>>;
const fallbackRow = db
.prepare(
`
SELECT
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
${whereClause}
`
)
.get(params) as Record<string, unknown>;
const summary = {
totalRequests: Number(summaryRow?.totalRequests || 0),
promptTokens: Number(summaryRow?.promptTokens || 0),
completionTokens: Number(summaryRow?.completionTokens || 0),
totalTokens: Number(summaryRow?.totalTokens || 0),
uniqueModels: Number(summaryRow?.uniqueModels || 0),
uniqueAccounts: Number(summaryRow?.uniqueAccounts || 0),
uniqueApiKeys: Number(summaryRow?.uniqueApiKeys || 0),
successfulRequests: Number(summaryRow?.successfulRequests || 0),
successRatePct:
Number(summaryRow?.totalRequests || 0) > 0
? Number(
(
(Number(summaryRow?.successfulRequests || 0) /
Number(summaryRow?.totalRequests || 1)) *
100
).toFixed(2)
)
: 0,
avgLatencyMs: Math.round(Number(summaryRow?.avgLatencyMs || 0)),
// Compute totalCost by summing cost from byModel (calculated later)
totalCost: 0, // Will be updated after byModel is processed
firstRequest: summaryRow?.firstRequest || "",
lastRequest: summaryRow?.lastRequest || "",
fallbackCount: Number(fallbackRow?.fallbacks || 0),
fallbackRatePct:
Number(fallbackRow?.with_requested || 0) > 0
? Number(
(
(Number(fallbackRow?.fallbacks || 0) / Number(fallbackRow?.with_requested || 1)) *
100
).toFixed(2)
)
: 0,
requestedModelCoveragePct:
Number(fallbackRow?.total || 0) > 0
? Number(
(
(Number(fallbackRow?.with_requested || 0) / Number(fallbackRow?.total || 1)) *
100
).toFixed(2)
)
: 0,
streak: 0,
};
const dailyCostByDate = new Map<string, number>();
for (const row of dailyCostRows) {
const date = toStringValue(row.date);
if (!date) continue;
const cost = computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
dailyCostByDate.set(date, (dailyCostByDate.get(date) || 0) + cost);
}
const analytics: any = await computeAnalytics(history, range, connectionMap);
const dailyTrend = dailyRows.map((row) => ({
date: row.date,
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
cost: roundCost(dailyCostByDate.get(toStringValue(row.date)) || 0),
}));
// T01: fallback transparency metrics from call_logs (requested_model vs routed model).
try {
const db = getDbInstance();
const sinceIso = getRangeStartIso(range);
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
const row = db
.prepare(
const activityMap: Record<string, number> = {};
for (const row of heatmapRows) {
activityMap[row.date as string] = Number(row.totalTokens);
}
summary.streak = computeActivityStreak(activityMap);
const byModel = modelRows.map((row) => {
const model = row.model as string;
const provider = row.provider as string;
const short = shortModelName(model);
const tokens = {
input: Number(row.promptTokens) || 0,
output: Number(row.completionTokens) || 0,
};
const cost = computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
return {
model: short,
provider,
rawModel: model,
requests: Number(row.requests),
promptTokens: tokens.input,
completionTokens: tokens.output,
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
successRatePct:
Number(row.requests) > 0
? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2)
: 0,
lastUsed: row.lastUsed,
cost: roundCost(cost),
};
});
const totalCost = Array.from(dailyCostByDate.values()).reduce((sum, cost) => sum + cost, 0);
summary.totalCost = roundCost(totalCost);
const providerCostByProvider = new Map<string, number>();
for (const row of providerCostRows) {
const provider = toStringValue(row.provider);
if (!provider) continue;
const cost = computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
providerCostByProvider.set(provider, (providerCostByProvider.get(provider) || 0) + cost);
}
const byProvider = providerRows.map((row) => ({
provider: row.provider,
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
successRatePct:
Number(row.requests) > 0
? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2)
: 0,
cost: roundCost(providerCostByProvider.get(toStringValue(row.provider)) || 0),
}));
const accountCostByAccount = new Map<string, number>();
for (const row of accountCostRows) {
const account = toStringValue(row.account, "unknown");
const cost = computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
accountCostByAccount.set(account, (accountCostByAccount.get(account) || 0) + cost);
}
const byAccount = accountRows.map((row) => ({
account: toStringValue(row.account, "unknown"),
requests: Number(row.requests),
promptTokens: Number(row.promptTokens),
completionTokens: Number(row.completionTokens),
totalTokens: Number(row.totalTokens),
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
lastUsed: row.lastUsed,
cost: roundCost(accountCostByAccount.get(toStringValue(row.account, "unknown")) || 0),
}));
const apiKeyMap = new Map<
string,
{
apiKey: string;
apiKeyId: string | null;
apiKeyName: string;
requests: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cost: number;
}
>();
for (const row of apiKeyRows) {
const apiKeyId = toStringValue(row.apiKeyId);
const apiKeyName = toStringValue(row.apiKeyName, apiKeyId || "Unknown API key");
const key = `${apiKeyId || "unknown"}::${apiKeyName}`;
const existing = apiKeyMap.get(key) || {
apiKey: apiKeyId && apiKeyName !== apiKeyId ? `${apiKeyName} (${apiKeyId})` : apiKeyName,
apiKeyId: apiKeyId || null,
apiKeyName,
requests: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cost: 0,
};
existing.requests += Number(row.requests);
existing.promptTokens += Number(row.promptTokens);
existing.completionTokens += Number(row.completionTokens);
existing.totalTokens += Number(row.totalTokens);
existing.cost += computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
apiKeyMap.set(key, existing);
}
const byApiKey = Array.from(apiKeyMap.values())
.map((row) => ({ ...row, cost: roundCost(row.cost) }))
.sort((left, right) => right.cost - left.cost);
const weeklyTokens = [0, 0, 0, 0, 0, 0, 0];
const weeklyCounts = [0, 0, 0, 0, 0, 0, 0];
const weeklyPattern = WEEKDAY_LABELS.map((day) => ({
day,
avgTokens: 0,
totalTokens: 0,
}));
for (const row of weeklyRows) {
const dayIdx = Number(row.dayOfWeek);
if (dayIdx >= 0 && dayIdx <= 6) {
const totalTokens = Number(row.totalTokens);
const days = Number(row.days);
weeklyTokens[dayIdx] = totalTokens;
weeklyCounts[dayIdx] = Number(row.requests);
weeklyPattern[dayIdx] = {
day: WEEKDAY_LABELS[dayIdx],
avgTokens: days > 0 ? Math.round(totalTokens / days) : 0,
totalTokens,
};
}
}
const analytics = {
summary,
dailyTrend,
activityMap,
byModel,
byProvider,
byApiKey,
byAccount,
weeklyPattern,
weeklyTokens,
weeklyCounts,
range,
};
if (presetsParam) {
const allowedRanges = new Set(["1d", "7d", "30d", "90d", "ytd", "all"]);
const presetRanges = presetsParam
.split(",")
.map((preset) => preset.trim())
.filter((preset) => allowedRanges.has(preset));
const presetSummaries: Record<string, { totalCost: number }> = {};
for (const presetRange of presetRanges) {
if (presetRange === range) {
presetSummaries[presetRange] = {
totalCost: Number(analytics.summary?.totalCost || 0),
};
continue;
}
const presetSinceIso = getRangeStartIso(presetRange);
const presetWhere = presetSinceIso ? "WHERE timestamp >= @presetSince" : "";
const presetParams = presetSinceIso ? { presetSince: presetSinceIso } : {};
const presetModelRows = db
.prepare(
`
SELECT
model,
provider,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
FROM usage_history
${presetWhere}
GROUP BY model, provider
`
SELECT
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
${whereClause}
`
)
.get(sinceIso ? { since: sinceIso } : {}) as
| { total?: number; with_requested?: number; fallbacks?: number }
| undefined;
)
.all(presetParams) as Array<Record<string, unknown>>;
const total = Number(row?.total || 0);
const withRequested = Number(row?.with_requested || 0);
const fallbackCount = Number(row?.fallbacks || 0);
let presetTotalCost = 0;
for (const row of presetModelRows) {
presetTotalCost += computeUsageRowCost(
row,
pricingByProvider,
normalizeModelName,
computeCostFromPricing
);
}
analytics.summary.fallbackCount = fallbackCount;
analytics.summary.fallbackRatePct =
withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0;
analytics.summary.requestedModelCoveragePct =
total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0;
} catch {
analytics.summary.fallbackCount = 0;
analytics.summary.fallbackRatePct = 0;
analytics.summary.requestedModelCoveragePct = 0;
presetSummaries[presetRange] = {
totalCost: roundCost(presetTotalCost),
};
}
analytics.presetSummaries = presetSummaries;
}
return NextResponse.json(analytics);

View File

@@ -84,6 +84,15 @@ export const DOCS_USE_CASE_ITEMS = [
{ titleKey: "useCaseUsageVisibilityTitle", textKey: "useCaseUsageVisibilityText" },
] as const;
export const DOCS_DEPLOYMENT_GUIDES = [
{
icon: "android",
titleKey: "deployTermuxTitle",
textKey: "deployTermuxText",
href: "https://github.com/diegosouzapw/OmniRoute/blob/main/docs/TERMUX_GUIDE.md",
},
] as const;
export const DOCS_TROUBLESHOOTING_KEYS = [
"troubleshootingModelRouting",
"troubleshootingAmbiguousModels",
@@ -95,6 +104,7 @@ export const DOCS_TROUBLESHOOTING_KEYS = [
export const DOCS_TOC_ITEMS = [
{ href: "#quick-start", labelKey: "quickStart" },
{ href: "#deployment", labelKey: "deploymentGuides" },
{ href: "#features", labelKey: "features" },
{ href: "#supported-providers", labelKey: "supportedProvidersToc" },
{ href: "#use-cases", labelKey: "commonUseCases" },

View File

@@ -3,6 +3,7 @@ import { useTranslations } from "next-intl";
import { APP_CONFIG } from "@/shared/constants/config";
import { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
import {
DOCS_DEPLOYMENT_GUIDES,
DOCS_ENDPOINT_ROWS,
DOCS_FEATURE_ITEMS,
DOCS_MANAGEMENT_ENDPOINT_ROWS,
@@ -82,6 +83,11 @@ export default function DocsPage() {
title: t(item.titleKey),
text: t(item.textKey),
}));
const deploymentGuides = DOCS_DEPLOYMENT_GUIDES.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const troubleshootingItems = DOCS_TROUBLESHOOTING_KEYS.map((key) => t(key));
const tocItems = DOCS_TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
@@ -204,6 +210,29 @@ export default function DocsPage() {
</ol>
</section>
<section id="deployment" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("deploymentGuides")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{deploymentGuides.map((item) => (
<a
key={item.titleKey}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-border p-4 bg-bg flex gap-3 transition-colors hover:border-primary/40 hover:bg-bg-subtle"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
{item.icon}
</span>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.text}</p>
</div>
</a>
))}
</div>
</section>
<section id="features" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("features")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "مرحبًا",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "الإعدادات",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "بالنسبة لنماذج عائلة GitHub Codex، احتفظ بالنموذج كـ gh/codex-model; يقوم جهاز التوجيه بتحديد/الاستجابات تلقائيًا.",
"troubleshootingTestConnection": "استخدم لوحة المعلومات > الموفرون > اختبار الاتصال قبل الاختبار من بيئات تطوير متكاملة أو عملاء خارجيين.",
"troubleshootingCircuitBreaker": "إذا أظهر مقدم الخدمة أن قاطع الدائرة مفتوح، فانتظر حتى فترة التهدئة أو راجع صفحة الصحة للحصول على التفاصيل.",
"troubleshootingOAuth": "بالنسبة لموفري OAuth، قم بإعادة المصادقة إذا انتهت صلاحية الرموز المميزة. تحقق من مؤشر حالة بطاقة المزود."
"troubleshootingOAuth": "بالنسبة لموفري OAuth، قم بإعادة المصادقة إذا انتهت صلاحية الرموز المميزة. تحقق من مؤشر حالة بطاقة المزود.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "سياسة الخصوصية",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Добре дошли",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Настройки",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "За модели от семейството на GitHub Codex, запазете модела като gh/codex-model; рутерът избира /отговаря автоматично.",
"troubleshootingTestConnection": "Използвайте Табло > Доставчици > Тестване на връзката, преди да тествате от IDE или външни клиенти.",
"troubleshootingCircuitBreaker": "Ако доставчикът покаже отворен прекъсвач, изчакайте охлаждането или проверете страницата Health за подробности.",
"troubleshootingOAuth": "За доставчици на OAuth, повторно удостоверяване, ако токените изтекат. Проверете индикатора за състояние на картата на доставчика."
"troubleshootingOAuth": "За доставчици на OAuth, повторно удостоверяване, ако токените изтекат. Проверете индикатора за състояние на картата на доставчика.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Политика за поверителност",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2323,7 +2323,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welcome",
@@ -2844,7 +2855,9 @@
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing"
"zedImporting": "Zed Importing",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Settings",
@@ -4191,7 +4204,9 @@
"protocolAcpDesc": "Register and manage agents via the ACP registry for inter-agent communication and tool sharing.",
"protocolAcpStep1": "Navigate to Dashboard → Agents to view registered ACP agents.",
"protocolAcpStep2": "Register new agents with capabilities and endpoint configuration.",
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels."
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacy Policy",
@@ -4687,7 +4702,20 @@
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}"
},
"endpointOptions": {
"speech": "Speech",
@@ -4708,5 +4736,42 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Vítejte",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Nastavení",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Pro modely z rodiny Codex ponechte model jako gh/codex-model; router automaticky vybere /responses.",
"troubleshootingTestConnection": "Před testováním z IDE nebo externích klientů použijte Nstěnka > Poskytovatelé > Test Připojení.",
"troubleshootingCircuitBreaker": "Pokud poskytovatel zobrazuje vypnutý jistič, počkejte na ochlazení nebo si pro podrobnosti prohlédněte stránku Stav.",
"troubleshootingOAuth": "U poskytovatelů OAuth proveďte opětovné ověření, pokud platnost tokenů vyprší. Zkontrolujte indikátor stavu karty poskytovatele."
"troubleshootingOAuth": "U poskytovatelů OAuth proveďte opětovné ověření, pokud platnost tokenů vyprší. Zkontrolujte indikátor stavu karty poskytovatele.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Zásady ochrany osobních údajů",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Velkommen",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Indstillinger",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "For GitHub Codex-familiemodeller skal du beholde modellen som gh/codex-model; routeren vælger /svar automatisk.",
"troubleshootingTestConnection": "Brug Dashboard > Udbydere > Test forbindelse, før du tester fra IDE'er eller eksterne klienter.",
"troubleshootingCircuitBreaker": "Hvis en udbyder viser en afbryder åben, skal du vente på nedkøling eller tjekke Health-siden for detaljer.",
"troubleshootingOAuth": "For OAuth-udbydere skal du godkende igen, hvis tokens udløber. Tjek udbyderkortets statusindikator."
"troubleshootingOAuth": "For OAuth-udbydere skal du godkende igen, hvis tokens udløber. Tjek udbyderkortets statusindikator.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privatlivspolitik",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Willkommen",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Einstellungen",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Behalten Sie für Modelle der GitHub Codex-Familie das Modell bei gh/codex-model; Der Router wählt /responses automatisch aus.",
"troubleshootingTestConnection": "Verwenden Sie Dashboard > Anbieter > Verbindung testen, bevor Sie Tests mit IDEs oder externen Clients durchführen.",
"troubleshootingCircuitBreaker": "Wenn ein Anbieter anzeigt, dass der Leistungsschalter geöffnet ist, warten Sie auf die Abklingzeit oder schauen Sie auf der Seite „Zustand“ nach, um Einzelheiten zu erfahren.",
"troubleshootingOAuth": "Führen Sie bei OAuth-Anbietern eine erneute Authentifizierung durch, wenn die Token ablaufen. Überprüfen Sie die Statusanzeige der Anbieterkarte."
"troubleshootingOAuth": "Führen Sie bei OAuth-Anbietern eine erneute Authentifizierung durch, wenn die Token ablaufen. Überprüfen Sie die Statusanzeige der Anbieterkarte.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Datenschutzrichtlinie",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -674,6 +674,7 @@
"endpoints": "Endpoints",
"apiManager": "API Manager",
"logs": "Logs",
"webhooks": "Webhooks",
"auditLog": "Audit Log",
"shutdown": "Shutdown",
"restart": "Restart",
@@ -737,6 +738,103 @@
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"webhooks": {
"title": "Webhooks",
"description": "Configure HTTP callbacks for system events.",
"configuredWebhooks": "Configured Webhooks",
"configuredWebhooksDesc": "Manage delivery endpoints, subscribed events, status, and test sends.",
"addWebhook": "Add Webhook",
"editWebhook": "Edit Webhook",
"name": "Name",
"namePlaceholder": "Production monitoring",
"unnamedWebhook": "Unnamed webhook",
"url": "Endpoint URL",
"events": "Events",
"allEvents": "All events",
"secret": "Secret",
"secretPlaceholder": "Leave blank to auto-generate a secret",
"secretEditPlaceholder": "Leave blank to keep the current secret",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"errored": "Errored",
"total": "Total",
"lastTriggered": "Last Triggered",
"actions": "Actions",
"enabled": "Enabled",
"enabledDesc": "Disabled webhooks remain saved but do not receive deliveries.",
"refresh": "Refresh",
"loading": "Loading webhooks...",
"never": "Never",
"failureCount": "{count, plural, =0 {no failures} one {# failure} other {# failures}}",
"testWebhook": "Send Test",
"testSuccess": "Test webhook sent successfully.",
"testFailed": "Test webhook failed.",
"saveSuccess": "Webhook saved successfully.",
"saveFailed": "Failed to save webhook.",
"loadFailed": "Failed to load webhooks.",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this webhook?",
"deleteSuccess": "Webhook deleted successfully.",
"deleteFailed": "Failed to delete webhook.",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"noWebhooks": "No webhooks configured yet.",
"signatureTitle": "Webhook Signatures",
"signatureDescription": "Each delivery includes an X-Webhook-Signature header signed with HMAC-SHA256 using the webhook secret. Verify the signature before trusting the payload."
},
"compliance": {
"auditTitle": "Audit",
"auditDescription": "Review compliance events and MCP tool calls from one operational view.",
"complianceTab": "Compliance",
"mcpTab": "MCP Audit",
"title": "Compliance Audit",
"description": "Policy, access, provider, and security events recorded by the compliance audit log.",
"eventType": "Event Type",
"eventTypePlaceholder": "Filter by action or event type",
"severity": "Severity",
"allSeverities": "All severities",
"info": "Info",
"warning": "Warning",
"critical": "Critical",
"sourceIp": "Source IP",
"userOrKey": "User / Key",
"action": "Action",
"result": "Result",
"details": "Details",
"timestamp": "Timestamp",
"from": "From",
"to": "To",
"refresh": "Refresh",
"export": "Export",
"clearFilters": "Clear filters",
"loading": "Loading audit events...",
"showing": "Showing {count} of {total} events",
"policyViolation": "Policy Violation",
"accessDenied": "Access Denied",
"injectionBlocked": "Injection Blocked",
"noEvents": "No compliance events recorded.",
"failedFetch": "Failed to fetch compliance audit log.",
"viewDetails": "View details",
"closeDetails": "Close details",
"notAvailable": "—",
"system": "system",
"previous": "Previous",
"next": "Next",
"mcpAudit": "MCP Audit",
"mcpAuditDesc": "Tool call audit entries recorded by the MCP server.",
"failedFetchMcpAudit": "Failed to fetch MCP audit log.",
"tool": "Tool",
"toolPlaceholder": "Filter by tool name",
"duration": "Duration",
"apiKey": "API Key",
"output": "Output",
"allResults": "All results",
"success": "Success",
"failure": "Failure",
"noMcpEvents": "No MCP audit events recorded."
},
"themesPage": {
"title": "Themes",
"description": "Choose a preset theme or create your own with a single color",
@@ -1814,7 +1912,39 @@
"costTrend": "Cost Trend",
"noCostDataTitle": "No cost data yet",
"topModels": "Top Models",
"requestsInWindow": "Requests in Window"
"requestsInWindow": "Requests in Window",
"tokenUsage": "Token Usage",
"totalTokens": "Total Tokens",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"inputOutputRatio": "Input/Output Ratio",
"tokens": "tokens",
"routingEfficiency": "Routing Efficiency",
"fallbackCount": "Fallback Requests",
"fallbackRate": "Fallback Rate",
"modelCoverage": "Model Coverage",
"modelCoverageDesc": "% of requests with explicit model",
"outOfRequests": "out of {total} requests",
"costByApiKey": "Cost by API Key",
"costByAccount": "Cost by Account",
"apiKeyName": "API Key",
"account": "Account",
"requests": "Requests",
"cost": "Cost",
"dayStreak": "day streak",
"weeklyUsagePattern": "Weekly Usage Pattern",
"activityHeatmap": "Activity (365 days)",
"less": "Less",
"more": "More",
"monthlyForecast": "Monthly Forecast",
"forecastBasis": "Based on last {days} days",
"avgDailyCost": "Avg. daily cost",
"daysRemaining": "{days} days remaining",
"periodComparison": "Period Comparison",
"previousPeriod": "Previous Half",
"currentPeriod": "Current Half",
"exportCSV": "Export as CSV",
"exportJSON": "Export as JSON"
},
"endpoint": {
"title": "API Endpoint",
@@ -2276,6 +2406,72 @@
"throttleStatus": "Throttle: {value}",
"lastHeaderUpdate": "Header update: {age}"
},
"telemetry": {
"title": "System Telemetry",
"description": "Rolling request, runtime, session, and memory signals from this OmniRoute process.",
"uptime": "Uptime",
"totalRequests": "Total Requests",
"avgLatency": "Avg Latency",
"errorRate": "Error Rate",
"activeConnections": "Active Connections",
"memoryUsage": "Memory Usage",
"latencyTrend": "Latency trend",
"throughputTrend": "Throughput trend",
"memoryTrend": "Memory trend",
"refresh": "Refresh",
"updatedAt": "Updated {time}",
"loadFailed": "Failed to load telemetry.",
"partialData": "Telemetry is partially available: {error}"
},
"mitm": {
"title": "MITM Proxy",
"description": "Transparent proxy for intercepting and routing client requests.",
"enable": "Enable MITM Proxy",
"enableDesc": "Start or stop the local interception process and DNS override.",
"status": "Status",
"running": "Running",
"stopped": "Stopped",
"start": "Start",
"stop": "Stop",
"refresh": "Refresh",
"port": "Proxy Port",
"apiKey": "Router API Key",
"apiKeyPlaceholder": "Optional; falls back to a local key",
"sudoPassword": "Sudo Password",
"cachedPassword": "Cached for this process",
"saveSettings": "Save Settings",
"settingsSaved": "MITM settings saved.",
"startedSuccess": "MITM proxy started.",
"stoppedSuccess": "MITM proxy stopped.",
"saveFailed": "Failed to update MITM settings.",
"loadFailed": "Failed to load MITM settings.",
"invalidPort": "Transparent MITM interception currently requires port 443.",
"certificate": "CA Certificate",
"certificateReady": "Certificate is available for client trust installation.",
"certificateMissing": "Certificate has not been generated yet.",
"available": "Available",
"missing": "Missing",
"downloadCert": "Download CA Certificate",
"regenerateCert": "Regenerate Certificate",
"regenerateConfirm": "This will invalidate existing client trust. Continue?",
"regenerateSuccess": "MITM certificate regenerated.",
"regenerateFailed": "Failed to regenerate MITM certificate.",
"targetRoutes": "Target Routes",
"interceptedRequests": "Intercepted Requests",
"activeConnections": "Active Connections",
"dnsConfigured": "DNS Configured",
"pid": "PID",
"lastIntercept": "Last Intercept",
"target": "Target",
"host": "Host",
"localPort": "Local Port",
"endpoints": "Endpoints",
"enabled": "Enabled",
"configured": "Configured",
"yes": "Yes",
"no": "No",
"noTargets": "No target routes configured."
},
"limits": {
"title": "Limits & Quotas",
"rateLimit": "Rate Limit",
@@ -2341,7 +2537,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
}
},
"onboarding": {
"welcome": "Welcome",
@@ -2422,6 +2629,12 @@
"errorCount": "{count} Error ({code})",
"errorCountNoCode": "{count} Error",
"noConnections": "No connections",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon",
"freeTier": "Free Tier",
"freeTierAvailable": "Free tier available",
"deprecated": "Deprecated",
"deprecatedProvider": "This provider has been deprecated",
"disabled": "Disabled",
"enableProvider": "Enable provider",
"disableProvider": "Disable provider",
@@ -2725,6 +2938,7 @@
"addAnotherApiKey": "Add Another Api Key",
"addCcCompatible": "Add CC Compatible",
"aggregatorsGateways": "Aggregators Gateways",
"enterpriseCloud": "Enterprise & Cloud",
"apiFormatLabel": "Api Format Label",
"apiKeyOptionalHint": "Api Key Optional Hint",
"apiKeyOptionalLabel": "Api Key Optional Label",
@@ -2791,6 +3005,8 @@
"herokuBaseUrlHint": "Heroku Base Url Hint",
"hideEmail": "Hide Email",
"imageProviders": "Image Providers",
"videoProviders": "Video Generation",
"embeddingRerankProviders": "Embeddings & Rerank",
"imagesShortLabel": "Images Short Label",
"llmProviders": "Llm Providers",
"localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint",
@@ -2875,6 +3091,7 @@
"systemPrompt": "System Prompt",
"thinkingBudget": "Thinking Budget",
"proxy": "Proxy",
"mitmProxy": "MITM Proxy",
"pricing": "Pricing",
"storage": "Storage",
"policies": "Policies",
@@ -2996,6 +3213,14 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"endpointTunnelVisibility": "Endpoint tunnel visibility",
"endpointTunnelVisibilityDesc": "Hide tunnel controls from the Endpoint page without changing tunnel state.",
"showCloudflareTunnel": "Cloudflare Quick Tunnel",
"showCloudflareTunnelDesc": "Show Cloudflare Quick Tunnel controls on the Endpoint page.",
"showTailscaleFunnel": "Tailscale Funnel",
"showTailscaleFunnelDesc": "Show Tailscale Funnel controls on the Endpoint page.",
"showNgrokTunnel": "ngrok Tunnel",
"showNgrokTunnelDesc": "Show ngrok Tunnel controls on the Endpoint page.",
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",
@@ -3466,6 +3691,28 @@
"errorShort": "ERR",
"formatConverter": "Format Converter",
"formatConverterDescription": "Paste or type a JSON request body. The translator will auto-detect the source format and convert it to the target format. Use this to debug how OmniRoute translates requests between formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API).",
"translationPathHubSpoke": "{source} → OpenAI (intermediate) → {target}",
"translationPathDirect": "{source} → {target} (direct translator)",
"translationPathPassthrough": "Same format — no translation needed",
"openaiIntermediatePanel": "OpenAI Intermediate",
"autoFeaturesTitle": "What OmniRoute does automatically",
"autoFeaturesCount": "8 features",
"featureReasoningCache": "Reasoning Cache",
"featureReasoningCacheDesc": "Re-injects cached reasoning_content for thinking-mode models (DeepSeek V4, Kimi K2, Qwen) when clients omit it from conversation history.",
"featureSchemaCoercion": "Schema Coercion",
"featureSchemaCoercionDesc": "Fixes broken tool schemas: adds missing additionalProperties, sanitizes long descriptions, normalizes nested objects.",
"featureRoleNormalization": "Role Normalization",
"featureRoleNormalizationDesc": "Maps developer→system for non-OpenAI targets. Maps system→user for models that don't support system role.",
"featureToolCallIds": "Tool Call ID Normalization",
"featureToolCallIdsDesc": "Generates unique tool_call IDs when missing. Normalizes to 9-char format for providers like Mistral.",
"featureMissingToolResponse": "Tool Response Injection",
"featureMissingToolResponseDesc": "Injects empty tool_result messages when clients send tool_calls without corresponding responses.",
"featureThinkingBudget": "Thinking Budget",
"featureThinkingBudgetDesc": "Automatically manages thinking config. Removes thinking parameters when the last message is not from the user.",
"featureDirectPaths": "Direct Translation Paths",
"featureDirectPathsDesc": "Some format pairs (Claude→Gemini) have direct translators that bypass the OpenAI hub, producing more accurate output.",
"featureImageMapping": "Image Size Mapping",
"featureImageMappingDesc": "Translates image dimension conventions between API formats (e.g., OpenAI detail levels → Gemini dimensions).",
"input": "Input",
"output": "Output",
"auto": "Auto",
@@ -3574,6 +3821,11 @@
"errorMessage": "Error: {message}",
"requestFailed": "Request failed",
"noTextExtracted": "(No text extracted)",
"liveMonitorMemoryNote": "Events are stored in memory and lost on restart.",
"liveMonitorMemoryCapNote": "Max 200 events retained.",
"eventSourcesLabel": "Event sources:",
"eventSourceTranslatorPage": "• Translator page (Chat Tester, Test Bench)",
"eventSourceMainPipeline": "• Main request pipeline (CLI/IDE/API traffic)",
"liveMonitorDescriptionPrefix": "Shows translation events as API calls flow through OmniRoute. Events come from the in-memory buffer (resets on restart). Use",
"liveMonitorDescriptionSuffix": ", or external API calls to generate events."
},
@@ -3649,14 +3901,25 @@
"passSuffix": "pass",
"casesCount": "{count, plural, one {# case} other {# cases}}",
"runEval": "Run Eval",
"runAllSuites": "Run All",
"runAllRunning": "Running all...",
"runAllProgress": "Running {current}/{total}: {name}",
"runAllFailedSuites": "{count, plural, one {# suite failed} other {# suites failed}}",
"runAllCompleted": "Ran {suites} suites — {passed} passed, {failed} failed",
"runAllCompletedWithFailures": "Ran {completed} suites; {failedSuites} failed to complete",
"runningProgress": "Running {current}/{total}...",
"passRate": "pass rate",
"summaryBreakdown": "{passed} passed · {failed} failed · {total} total",
"passedIconLabel": "✅ Passed",
"failedIconLabel": "❌ Failed",
"resultPassed": "Passed",
"resultFailed": "Failed",
"expandResult": "Expand result details",
"collapseResult": "Collapse result details",
"detailsContains": "Contains: \"{term}\"",
"detailsRegex": "Regex: {pattern}",
"detailsExpected": "Expected: \"{expected}\"",
"expectedOutputLabel": "Expected Output",
"noResultsYet": "No results yet",
"testCasesCount": "Test Cases ({count})",
"noTestCasesDefined": "No test cases defined",
@@ -3724,6 +3987,16 @@
"tierFree": "Free",
"tierUnknown": "Unknown",
"suiteBuilderSaveFailed": "Failed to save suite",
"clone": "Clone",
"exportSuite": "Export",
"importSuite": "Import",
"suiteExported": "Suite exported",
"suiteExportFailed": "Failed to export suite",
"suiteImportReady": "Suite import loaded for review",
"suiteImportFailed": "Failed to import suite",
"suiteImportInvalid": "Invalid eval suite JSON",
"suiteBuilderCloneSuffix": "copy",
"suiteBuilderImportedSuite": "Imported Suite",
"scorecardTitle": "Scorecard",
"evalApiKey": "API Key",
"scorecardPassRate": "Pass Rate",
@@ -3766,6 +4039,10 @@
"recentRunsTitle": "Recent Runs",
"suiteBuilderCaseSystemPromptLabel": "System Prompt",
"suiteBuilderCaseExpectedPlaceholder": "e.g. def fibonacci",
"suiteBuilderCaseExpectedPlaceholderContains": "e.g. def fibonacci",
"suiteBuilderCaseExpectedPlaceholderExact": "Paste the exact expected response",
"suiteBuilderCaseExpectedPlaceholderRegex": "e.g. ^\\\\s*\\\\{.*\\\\}\\\\s*$",
"suiteBuilderCaseExpectedHintRegex": "Use a JavaScript regular expression without wrapping slashes.",
"suiteBuilderNamePlaceholder": "e.g. Coding Quality Suite",
"suiteBuilderAddCase": "Add Case",
"cancel": "Cancel",
@@ -3779,6 +4056,7 @@
"resultErrorLabel": "Error",
"suiteBuilderBuiltInBadge": "Built-in",
"suiteBuilderCaseCardTitle": "Test Case",
"suiteBuilderDuplicateCase": "Duplicate",
"weeklyLimitPlaceholder": "e.g. 50.00",
"suiteBuilderCaseTagsHint": "Comma-separated tags for organizing test cases.",
"notifyEvalRunFailedWithReason": "Evaluation failed: {reason}",
@@ -4032,6 +4310,7 @@
"docs": {
"title": "Documentation",
"quickStart": "Quick Start",
"deploymentGuides": "Deployment Guides",
"features": "Features",
"supportedProviders": "Supported Providers",
"supportedProvidersToc": "Providers",
@@ -4068,6 +4347,8 @@
"quickStartStep4Title": "4. Set client base URL",
"quickStartStep4Prefix": "Point your IDE or API client to",
"quickStartStep4Suffix": "Use provider prefix, for example",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser.",
"featureRoutingTitle": "Multi-Provider Routing",
"featureRoutingText": "Route requests to 30+ AI providers through a single OpenAI-compatible endpoint. Supports chat, responses, audio, and image APIs.",
"featureCombosTitle": "Combos and Balancing",
@@ -4280,8 +4561,8 @@
"termsSection6Text": "OmniRoute is open-source software. You are free to inspect, modify, and distribute it under the terms of its license."
},
"agents": {
"title": "CLI Agents",
"description": "Discover installed CLI agents on your system. Add custom agents for auto-detection.",
"title": "CLI Agent Targets",
"description": "Discover installed CLI tools that OmniRoute can spawn as execution backends. Requests flow INTO these binaries — the reverse direction from CLI Tools.",
"refresh": "Refresh",
"installed": "Installed",
"notFound": "Not Found",
@@ -4309,19 +4590,38 @@
"setupGuideCustomAgentDesc": "Use Add Custom Agent when your CLI is not in the built-in list. Provide binary name and version command.",
"setupGuideCommandMissingTitle": "Fix 'command not found'",
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh.",
"cliToolsRedirectTitle": "Cli Tools Redirect Title",
"cliToolsRedirectDesc": "Cli Tools Redirect Desc",
"spawnArgsPlaceholder": "Spawn Args Placeholder",
"binaryNamePlaceholder": "Binary Name Placeholder",
"versionCommandPlaceholder": "Version Command Placeholder",
"architectureTitle": "Architecture Title",
"flowLocalBinary": "Flow Local Binary",
"flowOmniRoute": "Flow Omni Route",
"agentNamePlaceholder": "Agent Name Placeholder",
"architectureDescription": "Architecture Description",
"flowExecute": "Flow Execute",
"flowSpawn": "Flow Spawn",
"cliToolsRedirectCta": "Cli Tools Redirect Cta"
"cliToolsRedirectTitle": "Looking to configure your IDE?",
"cliToolsRedirectDesc": "If you want to point Claude Code, Cursor, Codex, or any IDE at OmniRoute as a proxy, go to CLI Tools instead. This page is for the reverse — using local CLI binaries as execution targets that OmniRoute can spawn and route requests through.",
"spawnArgsPlaceholder": "e.g., --quiet, --json, --no-auto-commits",
"binaryNamePlaceholder": "e.g., my-agent or /usr/local/bin/llm-cli",
"versionCommandPlaceholder": "e.g., my-agent --version",
"architectureTitle": "How Agent Targets Work",
"flowLocalBinary": "3 · CLI process with its own auth",
"flowOmniRoute": "1 · Request arrives at OmniRoute",
"agentNamePlaceholder": "e.g., My Custom Agent",
"architectureDescription": "When a request arrives, OmniRoute can spawn a local CLI binary (e.g., claude, codex, goose) and pipe the request through it. The CLI processes the request using its own credentials and returns the result back through OmniRoute.",
"flowExecute": "4 · Response returned to client",
"flowSpawn": "2 · OmniRoute spawns local binary",
"cliToolsRedirectCta": "Go to CLI Tools →",
"comparisonTitle": "CLI Tools vs Agent Targets — What's the Difference?",
"comparisonCliToolsLabel": "CLI Tools page",
"comparisonCliToolsTitle": "Your IDE sends requests through OmniRoute",
"comparisonCliToolsDesc": "Configure Claude Code, Codex, Cursor, and other IDEs to use OmniRoute as their API base URL. OmniRoute acts as a proxy, routing requests to your configured providers.",
"comparisonAgentsLabel": "This page (Agent Targets)",
"comparisonAgentsTitle": "OmniRoute sends requests into local CLI tools",
"comparisonAgentsDesc": "OmniRoute can spawn local CLI binaries (claude, codex, goose) as execution backends. The CLI tool processes the request using its own authentication and returns the result.",
"comparisonSummary": "In short: CLI Tools = you configure tools to point at OmniRoute. Agent Targets = OmniRoute uses tools as its endpoints.",
"agentUseCaseHint": "Can be used as an execution target via ACP protocol",
"flowDiagramClient": "Client App",
"flowDiagramClientDesc": "SDK, API, or upstream service",
"flowDiagramOmniRoute": "OmniRoute",
"flowDiagramOmniRouteDesc": "Receives request and selects target",
"flowDiagramSpawn": "Spawn Process",
"flowDiagramSpawnDesc": "Launches CLI binary via stdio",
"flowDiagramCli": "CLI Agent",
"flowDiagramCliDesc": "Processes with own auth/model",
"fingerprintSettingsHint": "CLI Fingerprint matching (disguise requests as specific CLI tools) can be configured in",
"openSettings": "Settings"
},
"templateNames": {
"simple-chat": "Simple Chat",
@@ -4712,6 +5012,8 @@
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
@@ -4733,9 +5035,20 @@
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Error",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
@@ -4759,6 +5072,43 @@
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
},
"endpointOptions": {
"speech": "Speech",
"search": "Search",

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Bienvenido",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Configuración",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Para los modelos de la familia GitHub Codex, mantenga el modelo como gh/codex-model; El enrutador selecciona/respuestas automáticamente.",
"troubleshootingTestConnection": "Utilice Panel > Proveedores > Probar conexión antes de realizar pruebas desde IDE o clientes externos.",
"troubleshootingCircuitBreaker": "Si un proveedor muestra el disyuntor abierto, espere el tiempo de reutilización o consulte la página de Salud para obtener más detalles.",
"troubleshootingOAuth": "Para los proveedores de OAuth, vuelva a autenticarse si los tokens caducan. Verifique el indicador de estado de la tarjeta del proveedor."
"troubleshootingOAuth": "Para los proveedores de OAuth, vuelva a autenticarse si los tokens caducan. Verifique el indicador de estado de la tarjeta del proveedor.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Política de privacidad",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2323,7 +2323,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welcome",
@@ -2844,7 +2855,9 @@
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing"
"zedImporting": "Zed Importing",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Settings",
@@ -4191,7 +4204,9 @@
"protocolAcpDesc": "Register and manage agents via the ACP registry for inter-agent communication and tool sharing.",
"protocolAcpStep1": "Navigate to Dashboard → Agents to view registered ACP agents.",
"protocolAcpStep2": "Register new agents with capabilities and endpoint configuration.",
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels."
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacy Policy",
@@ -4687,7 +4702,20 @@
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}"
},
"endpointOptions": {
"speech": "Speech",
@@ -4708,5 +4736,42 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Tervetuloa",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Asetukset",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Säilytä GitHub Codex -perheen mallien mallina muodossa gh/codex-model; reititin valitsee / vastaa automaattisesti.",
"troubleshootingTestConnection": "Käytä Dashboard > Providers > Test Connection ennen testaamista IDE:istä tai ulkoisista asiakkaista.",
"troubleshootingCircuitBreaker": "Jos palveluntarjoaja näyttää katkaisijan auki, odota jäähtymistä tai katso lisätietoja Terveys-sivulta.",
"troubleshootingOAuth": "OAuth-palveluntarjoajat todenna uudelleen, jos tunnukset vanhenevat. Tarkista palveluntarjoajan kortin tilailmaisin."
"troubleshootingOAuth": "OAuth-palveluntarjoajat todenna uudelleen, jos tunnukset vanhenevat. Tarkista palveluntarjoajan kortin tilailmaisin.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Tietosuojakäytäntö",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Bienvenue",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Paramètres",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Pour les modèles de la famille GitHub Codex, conservez le modèle sous la forme gh/codex-model ; Le routeur sélectionne /réponses automatiquement.",
"troubleshootingTestConnection": "Utilisez Tableau de bord > Fournisseurs > Tester la connexion avant de tester à partir d'IDE ou de clients externes.",
"troubleshootingCircuitBreaker": "Si un fournisseur indique que le disjoncteur est ouvert, attendez le temps de recharge ou consultez la page Santé pour plus de détails.",
"troubleshootingOAuth": "Pour les fournisseurs OAuth, réauthentifiez-vous si les jetons expirent. Vérifiez l'indicateur d'état de la carte du fournisseur."
"troubleshootingOAuth": "Pour les fournisseurs OAuth, réauthentifiez-vous si les jetons expirent. Vérifiez l'indicateur d'état de la carte du fournisseur.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Politique de confidentialité",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2323,7 +2323,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welcome",
@@ -2844,7 +2855,9 @@
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing"
"zedImporting": "Zed Importing",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Settings",
@@ -4191,7 +4204,9 @@
"protocolAcpDesc": "Register and manage agents via the ACP registry for inter-agent communication and tool sharing.",
"protocolAcpStep1": "Navigate to Dashboard → Agents to view registered ACP agents.",
"protocolAcpStep2": "Register new agents with capabilities and endpoint configuration.",
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels."
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacy Policy",
@@ -4687,7 +4702,20 @@
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}"
},
"endpointOptions": {
"speech": "Speech",
@@ -4708,5 +4736,42 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "ברוך הבא",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "הגדרות",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "עבור מודלים של משפחת GitHub Codex, שמור את הדגם בתור gh/codex-model; הנתב בוחר / תגובות באופן אוטומטי.",
"troubleshootingTestConnection": "השתמש בלוח מחוונים > ספקים > בדוק חיבור לפני בדיקה מ-IDEs או לקוחות חיצוניים.",
"troubleshootingCircuitBreaker": "אם ספק מציג מפסק פתוח, המתן להתקררות או בדוק את דף הבריאות לפרטים.",
"troubleshootingOAuth": "עבור ספקי OAuth, בצע אימות מחדש אם פג תוקפם של אסימונים. בדוק את מחוון מצב כרטיס הספק."
"troubleshootingOAuth": "עבור ספקי OAuth, בצע אימות מחדש אם פג תוקפם של אסימונים. בדוק את מחוון מצב כרטיס הספק.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "מדיניות פרטיות",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "स्वागत है",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "सेटिंग्स",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "GitHub कोडेक्स-फ़ैमिली मॉडल के लिए, मॉडल को gh/codex-model के रूप में रखें; राउटर स्वचालित रूप से/प्रतिक्रियाओं का चयन करता है।",
"troubleshootingTestConnection": "आईडीई या बाहरी क्लाइंट से परीक्षण करने से पहले डैशबोर्ड > प्रदाता > परीक्षण कनेक्शन का उपयोग करें।",
"troubleshootingCircuitBreaker": "यदि कोई प्रदाता सर्किट ब्रेकर खुला दिखाता है, तो कूलडाउन की प्रतीक्षा करें या विवरण के लिए स्वास्थ्य पृष्ठ देखें।",
"troubleshootingOAuth": "OAuth प्रदाताओं के लिए, यदि टोकन समाप्त हो जाते हैं तो पुनः प्रमाणित करें। प्रदाता कार्ड स्थिति संकेतक की जाँच करें।"
"troubleshootingOAuth": "OAuth प्रदाताओं के लिए, यदि टोकन समाप्त हो जाते हैं तो पुनः प्रमाणित करें। प्रदाता कार्ड स्थिति संकेतक की जाँच करें।",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "गोपनीयता नीति",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Üdvözöljük",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Beállítások elemre",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "A GitHub Codex-családhoz tartozó modelleknél tartsa a modellt gh/codex-model; a router automatikusan kiválasztja a /válaszokat.",
"troubleshootingTestConnection": "Az IDE-kből vagy külső kliensekből történő tesztelés előtt használja az Irányítópult > Szolgáltatók > Kapcsolat tesztelése menüpontot.",
"troubleshootingCircuitBreaker": "Ha a szolgáltató azt mutatja, hogy az áramkör megszakítója nyitva van, várja meg a lehűlést, vagy nézze meg az Egészség oldalt a részletekért.",
"troubleshootingOAuth": "OAuth-szolgáltatók esetén hitelesítse újra, ha a tokenek lejárnak. Ellenőrizze a szolgáltatói kártya állapotjelzőjét."
"troubleshootingOAuth": "OAuth-szolgáltatók esetén hitelesítse újra, ha a tokenek lejárnak. Ellenőrizze a szolgáltatói kártya állapotjelzőjét.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Adatvédelmi szabályzat",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Selamat datang",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Pengaturan",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Untuk model keluarga GitHub Codex, pertahankan model sebagai gh/codex-model; router memilih / merespons secara otomatis.",
"troubleshootingTestConnection": "Gunakan Dasbor > Penyedia > Uji Koneksi sebelum menguji dari IDE atau klien eksternal.",
"troubleshootingCircuitBreaker": "Jika penyedia menunjukkan pemutus sirkuit terbuka, tunggu hingga cooldown atau periksa halaman Kesehatan untuk detailnya.",
"troubleshootingOAuth": "Untuk penyedia OAuth, autentikasi ulang jika masa berlaku token habis. Periksa indikator status kartu penyedia."
"troubleshootingOAuth": "Untuk penyedia OAuth, autentikasi ulang jika masa berlaku token habis. Periksa indikator status kartu penyedia.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Kebijakan Privasi",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2323,7 +2323,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welcome",
@@ -2844,7 +2855,9 @@
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing"
"zedImporting": "Zed Importing",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Settings",
@@ -4191,7 +4204,9 @@
"protocolAcpDesc": "Register and manage agents via the ACP registry for inter-agent communication and tool sharing.",
"protocolAcpStep1": "Navigate to Dashboard → Agents to view registered ACP agents.",
"protocolAcpStep2": "Register new agents with capabilities and endpoint configuration.",
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels."
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacy Policy",
@@ -4687,7 +4702,20 @@
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}"
},
"endpointOptions": {
"speech": "Speech",
@@ -4708,5 +4736,42 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Benvenuto",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Impostazioni",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Per i modelli della famiglia GitHub Codex, mantieni il modello come gh/codex-model; il router seleziona/risponde automaticamente.",
"troubleshootingTestConnection": "Utilizza Dashboard > Provider > Verifica connessione prima di effettuare test da IDE o client esterni.",
"troubleshootingCircuitBreaker": "Se un fornitore mostra l'interruttore aperto, attendi il raffreddamento o controlla la pagina Salute per i dettagli.",
"troubleshootingOAuth": "Per i provider OAuth, eseguire nuovamente l'autenticazione se i token scadono. Controlla l'indicatore di stato della carta del fornitore."
"troubleshootingOAuth": "Per i provider OAuth, eseguire nuovamente l'autenticazione se i token scadono. Controlla l'indicatore di stato della carta del fornitore.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "politica sulla riservatezza",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "ようこそ",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "設定",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "GitHub Codex ファミリ モデルの場合、モデルを gh/codex-model として保持します。ルーターは自動的に選択/応答します。",
"troubleshootingTestConnection": "IDE または外部クライアントからテストする前に、[ダッシュボード] > [プロバイダー] > [接続のテスト] を使用します。",
"troubleshootingCircuitBreaker": "プロバイダーがサーキット ブレーカーが開いていることを示している場合は、クールダウンするまで待つか、詳細について [ヘルス] ページを確認してください。",
"troubleshootingOAuth": "OAuth プロバイダーの場合、トークンの有効期限が切れた場合は再認証します。プロバイダー カードのステータス インジケーターを確認します。"
"troubleshootingOAuth": "OAuth プロバイダーの場合、トークンの有効期限が切れた場合は再認証します。プロバイダー カードのステータス インジケーターを確認します。",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "プライバシーポリシー",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "환영합니다",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "설정",
@@ -3969,7 +3982,9 @@
"troubleshootingCodexFamily": "GitHub Codex 계열 모델의 경우 모델을 gh/codex-model로 유지합니다. 라우터는 /responses를 자동으로 선택합니다.",
"troubleshootingTestConnection": "IDE 또는 외부 클라이언트에서 테스트하기 전에 대시보드 > 공급자 > 연결 테스트를 사용하세요.",
"troubleshootingCircuitBreaker": "공급자가 회로 차단기를 열었다고 표시하는 경우 대기 시간을 기다리거나 상태 페이지에서 자세한 내용을 확인하세요.",
"troubleshootingOAuth": "OAuth 공급자의 경우 토큰이 만료되면 다시 인증하세요. 공급자 카드 상태 표시기를 확인하십시오."
"troubleshootingOAuth": "OAuth 공급자의 경우 토큰이 만료되면 다시 인증하세요. 공급자 카드 상태 표시기를 확인하십시오.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "개인 정보 보호 정책",
@@ -4360,5 +4375,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2323,7 +2323,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welcome",
@@ -2844,7 +2855,9 @@
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing"
"zedImporting": "Zed Importing",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Settings",
@@ -4191,7 +4204,9 @@
"protocolAcpDesc": "Register and manage agents via the ACP registry for inter-agent communication and tool sharing.",
"protocolAcpStep1": "Navigate to Dashboard → Agents to view registered ACP agents.",
"protocolAcpStep2": "Register new agents with capabilities and endpoint configuration.",
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels."
"protocolAcpStep3": "Use CLI Tools to configure agent communication channels.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacy Policy",
@@ -4687,7 +4702,20 @@
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}.",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}"
},
"endpointOptions": {
"speech": "Speech",
@@ -4708,5 +4736,42 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Selamat datang",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "tetapan",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Untuk model keluarga Codex GitHub, kekalkan model sebagai gh/codex-model; penghala memilih /membalas secara automatik.",
"troubleshootingTestConnection": "Gunakan Papan Pemuka > Pembekal > Uji Sambungan sebelum menguji daripada IDE atau pelanggan luaran.",
"troubleshootingCircuitBreaker": "Jika pembekal menunjukkan pemutus litar terbuka, tunggu masa bertenang atau semak halaman Kesihatan untuk mendapatkan butiran.",
"troubleshootingOAuth": "Untuk pembekal OAuth, sahkan semula jika token tamat tempoh. Semak penunjuk status kad pembekal."
"troubleshootingOAuth": "Untuk pembekal OAuth, sahkan semula jika token tamat tempoh. Semak penunjuk status kad pembekal.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Dasar Privasi",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Welkom",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Instellingen",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Voor modellen uit de GitHub Codex-familie behoudt u het model als gh/codex-model; router selecteert /responses automatisch.",
"troubleshootingTestConnection": "Gebruik Dashboard > Providers > Verbinding testen voordat u gaat testen vanaf IDE's of externe clients.",
"troubleshootingCircuitBreaker": "Als een provider aangeeft dat de stroomonderbreker open is, wacht dan op de cooldown of kijk op de Gezondheidspagina voor meer informatie.",
"troubleshootingOAuth": "Voor OAuth-providers geldt dat u opnieuw moet verifiëren als tokens verlopen. Controleer de statusindicator van de providerkaart."
"troubleshootingOAuth": "Voor OAuth-providers geldt dat u opnieuw moet verifiëren als tokens verlopen. Controleer de statusindicator van de providerkaart.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Privacybeleid",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Velkommen",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Innstillinger",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "For GitHub Codex-familiemodeller, behold modellen som gh/codex-model; ruteren velger /responser automatisk.",
"troubleshootingTestConnection": "Bruk Dashboard > Leverandører > Test tilkobling før du tester fra IDE-er eller eksterne klienter.",
"troubleshootingCircuitBreaker": "Hvis en leverandør viser at strømbryteren er åpen, vent på nedkjøling eller sjekk helsesiden for detaljer.",
"troubleshootingOAuth": "For OAuth-leverandører, autentiser på nytt hvis tokens utløper. Sjekk leverandørkortets statusindikator."
"troubleshootingOAuth": "For OAuth-leverandører, autentiser på nytt hvis tokens utløper. Sjekk leverandørkortets statusindikator.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Personvernerklæring",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Maligayang pagdating",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Mga setting",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "Para sa mga modelo ng pamilya ng GitHub Codex, panatilihing gh/codex-model ang modelo; awtomatikong pinipili /tugon ng router.",
"troubleshootingTestConnection": "Gamitin ang Dashboard > Mga Provider > Subukan ang Koneksyon bago subukan mula sa mga IDE o external na kliyente.",
"troubleshootingCircuitBreaker": "Kung ipinapakita ng provider na bukas ang circuit breaker, hintayin ang cooldown o tingnan ang page ng Health para sa mga detalye.",
"troubleshootingOAuth": "Para sa mga provider ng OAuth, muling i-authenticate kung mag-e-expire ang mga token. Suriin ang tagapagpahiwatig ng katayuan ng provider card."
"troubleshootingOAuth": "Para sa mga provider ng OAuth, muling i-authenticate kung mag-e-expire ang mga token. Suriin ang tagapagpahiwatig ng katayuan ng provider card.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Patakaran sa Privacy",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2163,7 +2163,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Witamy",
@@ -2683,7 +2694,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Ustawienia",
@@ -3967,7 +3980,9 @@
"troubleshootingCodexFamily": "W przypadku modeli z rodziny GitHub Codex zachowaj model jako gh/codex-model; router wybiera opcję /odpowiada automatycznie.",
"troubleshootingTestConnection": "Użyj Panelu sterowania > Dostawcy > Testuj połączenie przed testowaniem z IDE lub klientów zewnętrznych.",
"troubleshootingCircuitBreaker": "Jeśli dostawca pokazuje, że wyłącznik jest otwarty, poczekaj na ochłodzenie lub sprawdź stronę Zdrowie, aby uzyskać szczegółowe informacje.",
"troubleshootingOAuth": "W przypadku dostawców OAuth należy ponownie uwierzytelnić, jeśli tokeny wygasną. Sprawdź wskaźnik stanu karty dostawcy."
"troubleshootingOAuth": "W przypadku dostawców OAuth należy ponownie uwierzytelnić, jeśli tokeny wygasną. Sprawdź wskaźnik stanu karty dostawcy.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Polityka prywatności",
@@ -4358,5 +4373,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2263,7 +2263,18 @@
"a": "A",
"offset": "Offset",
"limit": "Limit",
"tab": "Tab"
"tab": "Tab",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Bem-vindo",
@@ -2785,7 +2796,9 @@
"testingModel": "Testing Model",
"unhideModel": "Unhide Model",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Configurações",
@@ -4176,7 +4189,9 @@
"troubleshootingCodexFamily": "Para modelos da família GitHub Codex, mantenha o modelo como gh/codex-model; o roteador seleciona /responses automaticamente.",
"troubleshootingTestConnection": "Use Painel > Provedores > Testar Conexão antes de testar por IDEs ou clientes externos.",
"troubleshootingCircuitBreaker": "Se um provedor mostrar circuit breaker aberto, aguarde o cooldown ou verifique a página Health para detalhes.",
"troubleshootingOAuth": "Para provedores OAuth, autentique novamente se os tokens expirarem. Verifique o indicador de status no card do provedor."
"troubleshootingOAuth": "Para provedores OAuth, autentique novamente se os tokens expirarem. Verifique o indicador de status no card do provedor.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Política de Privacidade",
@@ -4507,5 +4522,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

View File

@@ -2235,7 +2235,18 @@
"clientPayload": "Client Request Payload",
"upstreamPayload": "Upstream Provider Payload",
"upstreamNotSentYet": "Not sent to upstream yet",
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}"
"runningRequestDetailMeta": "Account: {account} — Elapsed: {elapsed}",
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
},
"export": "Export",
"exporting": "Exporting...",
"exportFailed": "Export failed",
"timeRange": "Time Range",
"lastNHours": "Last {hours}",
"defaultRange": "default"
},
"onboarding": {
"welcome": "Bem vindo",
@@ -2755,7 +2766,9 @@
"zedImportSuccess": "Zed Import Success",
"zedImporting": "Zed Importing",
"perModelQuotaDescription": "Per Model Quota Description",
"perModelQuotaLabel": "Per Model Quota Label"
"perModelQuotaLabel": "Per Model Quota Label",
"expiredBadge": "Expired",
"expiringSoonBadge": "Expiring Soon"
},
"settings": {
"title": "Configurações",
@@ -4039,7 +4052,9 @@
"troubleshootingCodexFamily": "Para modelos da família GitHub Codex, mantenha o modelo como gh/codex-model; roteador seleciona /respostas automaticamente.",
"troubleshootingTestConnection": "Use Painel > Provedores > Testar conexão antes de testar em IDEs ou clientes externos.",
"troubleshootingCircuitBreaker": "Se um provedor mostrar o disjuntor aberto, aguarde o resfriamento ou verifique a página de integridade para obter detalhes.",
"troubleshootingOAuth": "Para provedores OAuth, autentique novamente se os tokens expirarem. Verifique o indicador de status do cartão do provedor."
"troubleshootingOAuth": "Para provedores OAuth, autentique novamente se os tokens expirarem. Verifique o indicador de status do cartão do provedor.",
"deployTermuxTitle": "Termux (Android)",
"deployTermuxText": "Run OmniRoute headless on Android via Termux. Access the dashboard from your mobile browser."
},
"legal": {
"privacyPolicy": "Política de Privacidade",
@@ -4375,5 +4390,108 @@
"codex": "Codex",
"claude": "Claude",
"kilo": "Kilo"
},
"requestLogger": {
"recording": "Recording",
"paused": "Paused",
"pipelineLogsOn": "Pipeline logs on",
"pipelineLogsOff": "Pipeline logs off",
"updatingPipelineLogs": "Updating pipeline logs...",
"updatePipelineFailed": "Failed to update pipeline logging",
"capturePipeline": "Capture pipeline payloads for new requests",
"searchPlaceholder": "Search models, providers, accounts, API keys, combos...",
"allProviders": "All providers",
"allModels": "All models",
"allAccounts": "All accounts",
"allApiKeys": "All API keys",
"total": "Total",
"ok": "Success",
"err": "Error",
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
"sortTokensAsc": "Tokens ↑",
"sortDurationDesc": "Duration ↓",
"sortDurationAsc": "Duration ↑",
"sortStatusDesc": "Status ↓",
"sortStatusAsc": "Status ↑",
"sortModelAsc": "Model A-Z",
"sortModelDesc": "Model Z-A",
"sortLogs": "Sort logs",
"refresh": "Refresh",
"columnsLabel": "Columns",
"cacheSem": "SEM",
"cacheUp": "UP",
"semantic": "Semantic",
"upstream": "Upstream",
"semanticCacheHit": "Semantic cache hit (served by OmniRoute)",
"upstreamResponse": "Upstream provider response",
"noApiKey": "No API key",
"requestedRoutedTitle": "Requested {requested}, routed as {routed}",
"statusFilters": {
"all": "All",
"error": "Errors",
"success": "Success",
"combo": "Combo"
},
"columns": {
"status": "Status",
"cacheSource": "Cache Source",
"model": "Model",
"requested": "Requested",
"provider": "Provider",
"protocol": "Request Protocol",
"account": "Account",
"apiKey": "API Key",
"combo": "Combo",
"tokens": "Tokens",
"tps": "TPS",
"duration": "Duration",
"time": "Time"
},
"loadingLogs": "Loading logs...",
"noLogs": "No logs yet. Make some API calls to see them here.",
"noMatchingLogs": "No logs matching current filters.",
"callLogsInfo": "Call logs are also saved as JSON files to {dataDir} and rotated based on {retentionDays} and {maxEntries}."
},
"proxyLogger": {
"filterAll": "All",
"filterErrors": "Errors",
"filterSuccess": "Success",
"filterTimeout": "Timeout",
"colStatus": "Status",
"colProxy": "Proxy",
"colTls": "TLS",
"colType": "Type",
"colLevel": "Level",
"colProvider": "Provider",
"colTarget": "Target",
"colLatency": "Latency",
"colPublicIp": "Public IP",
"colTime": "Time",
"recording": "Recording",
"paused": "Paused",
"searchPlaceholder": "Search host, provider, target, IP...",
"allTypes": "All Types",
"allLevels": "All Levels",
"allProviders": "All Providers",
"total": "total",
"ok": "OK",
"err": "ERR",
"timeoutShort": "TMO",
"direct": "direct",
"newest": "Newest",
"oldest": "Oldest",
"latencyDesc": "Latency ↓",
"latencyAsc": "Latency ↑",
"refresh": "Refresh",
"columns": "Columns",
"loadingProxyLogs": "Loading proxy logs...",
"noProxyLogs": "No proxy logs yet. Configure proxies and make API calls to see them here.",
"noMatchingLogs": "No logs match the current filters.",
"tlsFingerprint": "Chrome 124 TLS Fingerprint"
}
}

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