From 3ec9ca11b1d52124724415cc370e136dbfc0d98b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:08:50 -0300 Subject: [PATCH] Release v3.7.6 (#1803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore(release): bump version to 3.7.6 * chore(release): v3.7.6 — merge API key rename feature and sync docs * 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 * fix: resolve security ReDoS in codex and bugs #1797 #1789 * feat(dashboard): implement remaining v3.7.6 dashboard features and fixes * fix(xiaomi-mimo): update models to V2.5, fix Token Plan validation and default region (#1823) Integrated into release/v3.7.6 * fix(dashboard): correct loadPresets ReferenceError in CostOverviewTab * fix(codex): omit compact client metadata (#1822) Integrated into release/v3.7.6 * feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821) Integrated into release/v3.7.6 * Fix endpoint visibility, A2A status, and API catalog (#1806) Integrated into release/v3.7.6 * fix(analytics): use pure SQL aggregations — no history rows loaded (#1802) Integrated into release/v3.7.6 * fix(stability): resolve codex input validation, enable combo circuit breaker, and fix broken unit tests * docs(changelog): update for stability bug fixes #1804 #1805 * fix: clear active requests and recover providers (#1824) Integrated into release/v3.7.6 * feat: inject fallback tool names to prevent upstream 400 errors (#1775) * feat: auto-restore probe-failed database to prevent data loss (#1810) * fix: safely cast inputs to strings before calling trim() to avoid crashes on numeric fields in proxy modal (#1825) * chore(release): v3.7.6 — final stability patches for production * test: update expected db probe-failure error message for auto-restore feature * chore(workflow): mandate implementation plan generation in resolve-issues * docs(changelog): rewrite v3.7.6 with complete commit-accurate entries * 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. * 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. * docs(changelog): update for analytics and mitm features --------- Co-authored-by: Andrew Munsell Co-authored-by: Antigravity Assistant Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Co-authored-by: Sergey Morozov Co-authored-by: payne Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: ipanghu --- .agents/workflows/resolve-issues.md | 3 +- .github/workflows/build-fork.yml | 6 +- .gitignore | 1 + .vscode/launch.json | 31 + CHANGELOG.md | 133 +- CLAUDE.md | 163 ++- README.md | 41 +- docs/A2A-SERVER.md | 7 + docs/API_REFERENCE.md | 2 + docs/FEATURES.md | 4 +- docs/TERMUX_GUIDE.md | 160 +++ docs/USER_GUIDE.md | 17 +- docs/openapi.yaml | 2 +- electron/package.json | 5 +- eslint.config.mjs | 11 + llm.txt | 4 +- open-sse/config/providerRegistry.ts | 24 +- open-sse/executors/base.ts | 17 +- open-sse/executors/chatgpt-web.ts | 191 +++ open-sse/executors/codex.ts | 38 +- open-sse/handlers/chatCore.ts | 63 +- open-sse/package.json | 2 +- open-sse/services/accountFallback.ts | 9 + open-sse/services/combo.ts | 7 + open-sse/services/errorClassifier.ts | 3 + .../translator/request/openai-responses.ts | 2 +- open-sse/utils/error.ts | 5 + open-sse/utils/stream.ts | 17 +- open-sse/utils/streamHandler.ts | 31 + package-lock.json | 7 +- package.json | 3 +- public/sw.js | 16 +- .../(dashboard)/dashboard/HomePageClient.tsx | 91 +- src/app/(dashboard)/dashboard/agents/page.tsx | 256 ++-- .../dashboard/audit/ComplianceTab.tsx | 385 ++++++ src/app/(dashboard)/dashboard/audit/page.tsx | 247 +++- .../dashboard/costs/CostOverviewTab.tsx | 764 +++++++++++- .../dashboard/endpoint/ApiEndpointsTab.tsx | 46 +- .../dashboard/endpoint/EndpointPageClient.tsx | 113 +- .../__tests__/ApiEndpointsTab.test.tsx | 107 ++ .../__tests__/EndpointPageClient.test.tsx | 3 + .../(dashboard)/dashboard/endpoint/page.tsx | 69 +- .../dashboard/health/TelemetryCard.tsx | 331 +++++ src/app/(dashboard)/dashboard/health/page.tsx | 50 +- src/app/(dashboard)/dashboard/logs/page.tsx | 12 +- .../dashboard/providers/[id]/page.tsx | 194 +-- .../components/AddCompatibleProviderModal.tsx | 342 +++++ .../providers/components/ProviderCard.tsx | 251 ++++ .../(dashboard)/dashboard/providers/page.tsx | 1105 +++-------------- .../settings/components/AppearanceTab.tsx | 18 + .../settings/components/MitmProxyTab.tsx | 429 +++++++ .../(dashboard)/dashboard/settings/page.tsx | 4 + .../translator/TranslatorPageClient.tsx | 133 +- .../translator/components/ChatTesterMode.tsx | 21 +- .../translator/components/LiveMonitorMode.tsx | 17 + .../translator/components/PlaygroundMode.tsx | 124 +- .../dashboard/translator/exampleTemplates.tsx | 2 - .../translator/hooks/useAvailableModels.tsx | 1 - .../dashboard/usage/components/EvalsTab.tsx | 818 +++++++++--- .../(dashboard)/dashboard/webhooks/page.tsx | 624 ++++++++++ src/app/.well-known/agent.json/route.ts | 47 +- src/app/a2a/route.ts | 43 +- src/app/api/a2a/status/route.ts | 26 +- src/app/api/evals/scorecard/route.ts | 22 - src/app/api/providers/test-batch/route.ts | 20 +- src/app/api/settings/mitm/route.ts | 274 ++++ src/app/api/telemetry/summary/route.ts | 16 +- src/app/api/translator/load/route.ts | 44 - src/app/api/translator/save/route.ts | 63 - src/app/api/usage/analytics/route.ts | 699 ++++++++++- src/app/docs/content.ts | 10 + src/app/docs/page.tsx | 29 + src/i18n/messages/ar.json | 124 +- src/i18n/messages/bg.json | 124 +- src/i18n/messages/bn.json | 73 +- src/i18n/messages/cs.json | 124 +- src/i18n/messages/da.json | 124 +- src/i18n/messages/de.json | 124 +- src/i18n/messages/en.json | 386 +++++- src/i18n/messages/es.json | 124 +- src/i18n/messages/fa.json | 73 +- src/i18n/messages/fi.json | 124 +- src/i18n/messages/fr.json | 124 +- src/i18n/messages/gu.json | 73 +- src/i18n/messages/he.json | 124 +- src/i18n/messages/hi.json | 124 +- src/i18n/messages/hu.json | 124 +- src/i18n/messages/id.json | 124 +- src/i18n/messages/in.json | 73 +- src/i18n/messages/it.json | 124 +- src/i18n/messages/ja.json | 124 +- src/i18n/messages/ko.json | 124 +- src/i18n/messages/mr.json | 73 +- src/i18n/messages/ms.json | 124 +- src/i18n/messages/nl.json | 124 +- src/i18n/messages/no.json | 124 +- src/i18n/messages/phi.json | 124 +- src/i18n/messages/pl.json | 124 +- src/i18n/messages/pt-BR.json | 124 +- src/i18n/messages/pt.json | 124 +- src/i18n/messages/ro.json | 124 +- src/i18n/messages/ru.json | 124 +- src/i18n/messages/sk.json | 124 +- src/i18n/messages/sv.json | 124 +- src/i18n/messages/sw.json | 73 +- src/i18n/messages/ta.json | 73 +- src/i18n/messages/te.json | 73 +- src/i18n/messages/th.json | 124 +- src/i18n/messages/tr.json | 124 +- src/i18n/messages/uk-UA.json | 124 +- src/i18n/messages/ur.json | 73 +- src/i18n/messages/vi.json | 124 +- src/i18n/messages/zh-CN.json | 86 +- src/lib/a2a/skills/costAnalysis.ts | 162 +++ src/lib/a2a/skills/healthReport.ts | 155 +++ src/lib/a2a/skills/providerDiscovery.ts | 202 +++ src/lib/a2a/taskExecution.ts | 37 +- src/lib/db/core.ts | 36 +- src/lib/db/migrationRunner.ts | 85 +- .../migrations/033_create_reasoning_cache.sql | 2 +- src/lib/db/settings.ts | 3 + src/lib/evals/evalRunner.ts | 352 ++++++ src/lib/evals/scheduler.ts | 271 ---- src/lib/monitoring/observability.ts | 2 + src/lib/providers/validation.ts | 27 + src/lib/usage/costCalculator.ts | 46 +- src/lib/usage/usageHistory.ts | 42 +- src/mitm/manager.stub.ts | 6 +- src/mitm/manager.ts | 11 +- src/mitm/server.cjs | 43 +- src/shared/components/ConsoleLogViewer.tsx | 9 +- src/shared/components/CursorAuthModal.tsx | 21 +- src/shared/components/Header.tsx | 13 +- src/shared/components/KiroAuthModal.tsx | 19 +- src/shared/components/KiroOAuthWrapper.tsx | 21 +- .../components/KiroSocialOAuthModal.tsx | 19 +- src/shared/components/ModelSelectModal.tsx | 35 +- src/shared/components/OAuthModal.tsx | 13 +- src/shared/components/OmniRouteLogo.tsx | 14 +- src/shared/components/ProxyConfigModal.tsx | 64 +- src/shared/components/ProxyLogger.tsx | 127 +- src/shared/components/RequestLoggerV2.tsx | 57 +- src/shared/components/Sidebar.tsx | 22 +- src/shared/components/UsageStats.tsx | 12 - src/shared/components/layouts/AuthLayout.tsx | 12 +- src/shared/constants/pricing.ts | 10 +- src/shared/constants/providers.ts | 101 ++ src/shared/constants/sidebarVisibility.ts | 4 + src/shared/utils/circuitBreaker.ts | 31 +- src/shared/utils/costEstimator.ts | 14 +- src/shared/utils/formatting.ts | 15 +- src/shared/utils/requestTelemetry.ts | 4 +- src/shared/validation/schemas.ts | 17 +- src/shared/validation/settingsSchemas.ts | 1 + src/sse/services/auth.ts | 2 +- src/types/settings.ts | 4 + tests/integration/api-routes-critical.test.ts | 45 + tests/integration/proxy-pipeline.test.ts | 33 +- tests/unit/a2a-enabled-route.test.ts | 118 ++ tests/unit/auth-terminal-status.test.ts | 17 +- tests/unit/chatgpt-web.test.ts | 263 ++++ tests/unit/db-core-init.test.ts | 54 +- tests/unit/db-migration-runner.test.ts | 119 ++ tests/unit/db-settings-crud.test.ts | 2 + tests/unit/error-classifier.test.ts | 11 + tests/unit/evals-route.test.ts | 19 +- tests/unit/executor-codex.test.ts | 27 + tests/unit/observability-fase04.test.ts | 20 + tests/unit/settings-api.test.ts | 7 +- ...settings-schema-routing-strategies.test.ts | 18 + tests/unit/sidebar-visibility.test.ts | 15 +- tests/unit/stream-handler.test.ts | 66 + tests/unit/t06-schema-hardening.test.ts | 17 - tests/unit/usage-analytics-route.test.ts | 209 ++++ tests/unit/usage-history-db.test.ts | 122 ++ tests/unit/xiaomi-mimo-provider.test.ts | 28 +- 176 files changed, 14149 insertions(+), 2928 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 docs/TERMUX_GUIDE.md create mode 100644 src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx create mode 100644 src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx create mode 100644 src/app/(dashboard)/dashboard/health/TelemetryCard.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/page.tsx delete mode 100644 src/app/api/evals/scorecard/route.ts create mode 100644 src/app/api/settings/mitm/route.ts delete mode 100644 src/app/api/translator/load/route.ts delete mode 100644 src/app/api/translator/save/route.ts create mode 100644 src/lib/a2a/skills/costAnalysis.ts create mode 100644 src/lib/a2a/skills/healthReport.ts create mode 100644 src/lib/a2a/skills/providerDiscovery.ts delete mode 100644 src/lib/evals/scheduler.ts create mode 100644 tests/unit/a2a-enabled-route.test.ts create mode 100644 tests/unit/usage-analytics-route.test.ts create mode 100644 tests/unit/usage-history-db.test.ts diff --git a/.agents/workflows/resolve-issues.md b/.agents/workflows/resolve-issues.md index aa2aa7177a..8573a18ce0 100644 --- a/.agents/workflows/resolve-issues.md +++ b/.agents/workflows/resolve-issues.md @@ -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/-.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 diff --git a/.github/workflows/build-fork.yml b/.github/workflows/build-fork.yml index a224c0f9d4..3071abdf0f 100644 --- a/.github/workflows/build-fork.yml +++ b/.github/workflows/build-fork.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3ec3d24321..8fcc4514c3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..caa4ab6868 --- /dev/null +++ b/.vscode/launch.json @@ -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_modules/**"] + }, + { + "name": "Debug Prod Server", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/scripts/run-next.mjs", + "args": ["start"], + "console": "integratedTerminal", + "skipFiles": ["/**", "node_modules/**"] + }, + { + "name": "Attach to Running Server", + "type": "node", + "request": "attach", + "port": 9229, + "skipFiles": ["/**", "node_modules/**"] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 16411f6e72..7573fa367b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4fa2daa3e3..9478629f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/README.md b/README.md index a26c25f47b..0f678178a5 100644 --- a/README.md +++ b/README.md @@ -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:
🤝 A2A Setup (Agent2Agent) +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 diff --git a/docs/A2A-SERVER.md b/docs/A2A-SERVER.md index 9d61dd1870..cb8b18dd2c 100644 --- a/docs/A2A-SERVER.md +++ b/docs/A2A-SERVER.md @@ -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 | --- diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 087d5619f3..3b4c38ec29 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 3dc803380e..d68c6a6db9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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) diff --git a/docs/TERMUX_GUIDE.md b/docs/TERMUX_GUIDE.md new file mode 100644 index 0000000000..28012906e9 --- /dev/null +++ b/docs/TERMUX_GUIDE.md @@ -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. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1ea97b1278..e1c3348a8b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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) | --- diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 40a309b584..db22d5482d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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, diff --git a/electron/package.json b/electron/package.json index cb06672579..5d5e60a832 100644 --- a/electron/package.json +++ b/electron/package.json @@ -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" ] }, { diff --git a/eslint.config.mjs b/eslint.config.mjs index a90263b07c..9266b9e696 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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) diff --git a/llm.txt b/llm.txt index 5903d37c52..62c3c154c1 100644 --- a/llm.txt +++ b/llm.txt @@ -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 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index be8a496855..6ac927c6d7 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -228,7 +228,17 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { 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 = { 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, }, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index a1137eec01..f4e036f341 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -274,11 +274,18 @@ export class BaseExecutor { const cloned = { ...body } as Record; 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; }); diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 3acc626d8c..d550a967e4 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -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 = { 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 = new Set( + Object.entries(MODEL_MAP) + .filter(([k]) => k.includes("thinking") || k === "o3") + .map(([, v]) => v) +); + // ─── Browser-like default headers ────────────────────────────────────────── function browserHeaders(): Record { @@ -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(); +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 | undefined +): "standard" | "extended" | null { + if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { + return normalizeThinkingEffort(providerSpecificData.thinkingEffort); + } + const b = (body as Record | null) ?? null; + if (!b) return null; + const top = normalizeThinkingEffort(b.reasoning_effort); + if (top) return top; + const nested = (b.reasoning as Record | 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 { + 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 = { + ...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 { export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); warmupCache.clear(); + thinkingEffortCache.clear(); deviceIdCache.clear(); __resetChatGptImageCacheForTesting(); dplCache = null; diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 49c200c8fb..8c4d36eb90 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -481,7 +481,13 @@ function stripStoredItemReferences(body: Record): 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): 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, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index a131a7cbc6..6ebc6595f5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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; } 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; + 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); diff --git a/open-sse/package.json b/open-sse/package.json index b57e8dec92..1cfca7124b 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -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", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index eec3afcadb..62b32c0d30 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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 = diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 0988c224fe..05830f16ac 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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); diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index da9014b4a6..45d7180f2a 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -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; diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index d730d830e4..aa9a981a5a 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -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; diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 95b139498f..e1b3d4a681 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -172,11 +172,16 @@ export async function parseUpstreamError(response, provider = null) { retryAfterMs = MAX_RETRY_MS; } + const responseHeaders: Record | null = response.headers + ? Object.fromEntries(response.headers.entries()) + : null; + return { statusCode: response.status, message: messageStr, retryAfterMs, responseBody, + responseHeaders, }; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index cd2b16863e..0443b0dcbd 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -58,6 +58,13 @@ export { COLORS, formatSSE }; type JsonRecord = Record; +const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared"; + +function markPendingRequestCleared(error: Error): Error { + (error as Error & Record)[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; } diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 825945fb2f..89d695c6fb 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -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; @@ -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 | 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)[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; diff --git a/package-lock.json b/package-lock.json index 17ae67cb5c..d4051d1973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index 0a2a1d4300..dfccd4c578 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index eaa1d86398..2d3e2261d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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; diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 6c3a7b18de..2011948e6e 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -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 }) { ); } - -ProviderModelsModal.propTypes = { - provider: PropTypes.object.isRequired, - models: PropTypes.array.isRequired, - onClose: PropTypes.func.isRequired, -}; diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 74dc2ff90d..b030b297c7 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -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>({}); 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")} -
- +
+ {t("flowOmniRoute")} - + + arrow_forward + + {t("flowSpawn")} - + + arrow_forward + + {t("flowLocalBinary")} - + + arrow_forward + + {t("flowExecute")}
+
+
+
+
+ + devices + +
+

{t("flowDiagramClient")}

+

{t("flowDiagramClientDesc")}

+
+
+ + arrow_forward + +
+
+
+ hub +
+

{t("flowDiagramOmniRoute")}

+

+ {t("flowDiagramOmniRouteDesc")} +

+
+
+ + arrow_forward + +
+
+
+ + launch + +
+

+ {t("flowDiagramSpawn")} +

+

{t("flowDiagramSpawnDesc")}

+
+
+ + arrow_forward + +
+
+
+ + terminal + +
+

+ {t("flowDiagramCli")} +

+

{t("flowDiagramCliDesc")}

+
+
+
{t("cliToolsRedirectTitle")}{" "} {t("cliToolsRedirectDesc")}{" "} @@ -240,6 +273,67 @@ export default function AgentsPage() {
+ +
+
+
+ +
+

{t("comparisonTitle")}

+
+ +
+
+
+ + arrow_forward + +

+ {t("comparisonCliToolsLabel")} +

+
+

+ {t("comparisonCliToolsTitle")} +

+

{t("comparisonCliToolsDesc")}

+
+ IDE + arrow_forward + OmniRoute + arrow_forward + Provider API +
+
+ +
+
+ + arrow_back + +

+ {t("comparisonAgentsLabel")} +

+
+

+ {t("comparisonAgentsTitle")} +

+

{t("comparisonAgentsDesc")}

+
+ Client + arrow_forward + OmniRoute + arrow_forward + CLI Binary +
+
+
+ +

{t("comparisonSummary")}

+
+
+ {/* Summary Cards */} {summary && (
@@ -305,69 +399,14 @@ export default function AgentsPage() {

{t("setupGuideCommandMissingDesc")}

- - - {/* CLI Fingerprint Matching */} - -
-
- -
-

{ts("cliFingerprint")}

-
-
-

{ts("cliFingerprintDesc")}

-
- {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 ( - - ); - })} -
- {normalizedCliCompatProviders.length > 0 && ( -

- verified - {ts("cliFingerprintEnabled", { - count: normalizedCliCompatProviders.length, - })} -

- )} +
+ fingerprint +

+ {t("fingerprintSettingsHint")}{" "} + + {t("openSettings")} + +

@@ -419,9 +458,14 @@ export default function AgentsPage() {
- - {agent.protocol} - +
+ + {agent.protocol} + + {agent.installed && ( +

{t("agentUseCaseHint")}

+ )} +
{agent.isCustom && ( + +
+ +
+ + +
+ + + + +
+ +
+
+
+ + {error && ( +
+ {error} +
+ )} + + + {loading ? ( +
{t("loading")}
+ ) : visibleEntries.length === 0 ? ( +
+ policy +

{t("noEvents")}

+
+ ) : ( +
+ + + + + + + + + + + + + + + {visibleEntries.map((entry) => { + const entrySeverity = getSeverity(entry); + return ( + + + + + + + + + + + ); + })} + +
{t("timestamp")}{t("eventType")}{t("severity")}{t("sourceIp")}{t("userOrKey")}{t("action")}{t("result")}{t("details")}
+ {formatLocalDate(entry.timestamp)} + + + {entry.action} + + + + {t(entrySeverity)} + + + {entry.ip_address || entry.ip || t("notAvailable")} + {entry.actor || t("system")} + {entry.target || entry.resourceType || t("notAvailable")} + + {entry.status || t("notAvailable")} + + +
+
+ )} +
+ +
+ + +
+ + {selectedEntry && ( +
+ +
+
+              {formatJson(selectedEntry)}
+            
+ + + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx index 4cc14f489f..0755c9b957 100644 --- a/src/app/(dashboard)/dashboard/audit/page.tsx +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -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({ + 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 ( +
+ +
+
+

{t("mcpAudit")}

+

{t("mcpAuditDesc")}

+

+ {t("showing", { count: data.entries.length, total: data.total })} +

+
+ +
+
+ + +
+ + +
+ +
+
+
+ + + {loading ? ( +
{t("loading")}
+ ) : data.entries.length === 0 ? ( +
+ terminal +

{t("noMcpEvents")}

+
+ ) : ( +
+ + + + + + + + + + + + + {data.entries.map((entry) => ( + + + + + + + + + ))} + +
{t("timestamp")}{t("tool")}{t("duration")}{t("result")}{t("apiKey")}{t("output")}
+ {new Date(entry.createdAt).toLocaleString()} + {entry.toolName}{entry.durationMs}ms + + {entry.success ? t("success") : entry.errorCode || t("failure")} + + + {entry.apiKeyId || t("notAvailable")} + + {entry.outputSummary || t("notAvailable")} +
+
+ )} +
+ +
+ + +
+
+ ); +} + +export default function AuditPage() { + const t = useTranslations("compliance"); + const [activeTab, setActiveTab] = useState("compliance"); + + return ( +
+
+
+ policy +

{t("auditTitle")}

+
+

{t("auditDescription")}

+
+ + + + {activeTab === "compliance" ? : } +
+ ); } diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index 496d38c67f..a0a983ce44 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -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; + presetSummaries?: Record; } 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(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 ; @@ -202,14 +341,57 @@ export default function CostOverviewTab() {

{t("overviewTitle")}

{t("overviewDescription")}

- ({ - value: option.value, - label: t(option.labelKey), - }))} - value={range} - onChange={(value) => setRange(value as CostRange)} - /> +
+ {summary.streak > 0 && ( +
+ + local_fire_department + + {summary.streak} + {t("dayStreak")} +
+ )} + {analytics && summary.totalCost > 0 && ( +
+ + +
+ )} + ({ + value: option.value, + label: t(option.labelKey), + }))} + value={range} + onChange={(value) => setRange(value as CostRange)} + /> +
@@ -261,6 +443,184 @@ export default function CostOverviewTab() { + +

+ {t("tokenUsage")} +

+
+ + + + 0 + ? `${(summary.promptTokens / summary.completionTokens).toFixed(1)}:1` + : "-" + } + /> +
+
+ + {summary.totalRequests > 0 && ( + +

+ {t("routingEfficiency")} +

+
+
+

+ {t("fallbackCount")} +

+

+ {new Intl.NumberFormat(locale).format(summary.fallbackCount || 0)} +

+

+ {t("outOfRequests", { + total: new Intl.NumberFormat(locale).format(summary.totalRequests), + })} +

+
+
+

+ {t("fallbackRate")} +

+
+

10 + ? "text-red-400" + : (summary.fallbackRatePct || 0) > 5 + ? "text-amber-400" + : "text-emerald-400" + }`} + > + {(summary.fallbackRatePct || 0).toFixed(1)}% +

+ 10 + ? "#f87171" + : (summary.fallbackRatePct || 0) > 5 + ? "#fbbf24" + : "#34d399", + }} + > + {(summary.fallbackRatePct || 0) > 5 ? "warning" : "check_circle"} + +
+
+
+

+ {t("modelCoverage")} +

+

+ {(summary.requestedModelCoveragePct || 0).toFixed(1)}% +

+

{t("modelCoverageDesc")}

+
+
+
+ )} + + {summary.totalCost > 0 && ( +
+ +
+ trending_up +

+ {t("monthlyForecast")} +

+
+
+

+ {currencyFormatter.format(projectedMonthEnd)} +

+

+ {t("forecastBasis", { days: recentDays.length })} +

+
+
+ {t("avgDailyCost")}: + {currencyFormatter.format(avgDailyCost)} + / + {t("daysRemaining", { days: daysRemainingInMonth })} +
+
+ + +
+ + compare_arrows + +

+ {t("periodComparison")} +

+
+
+

0 + ? "text-red-400" + : costChangePct < 0 + ? "text-emerald-400" + : "text-text-main" + }`} + > + {costChangePct > 0 ? "+" : ""} + {costChangePct.toFixed(1)}% +

+ 0 + ? "text-red-400" + : costChangePct < 0 + ? "text-emerald-400" + : "text-text-muted" + }`} + > + {costChangePct > 0 + ? "arrow_upward" + : costChangePct < 0 + ? "arrow_downward" + : "remove"} + +
+
+
+

{t("previousPeriod")}

+

+ {currencyFormatter.format(firstHalfCost)} +

+
+
+

{t("currentPeriod")}

+

+ {currencyFormatter.format(secondHalfCost)} +

+
+
+
+
+ )} + {summary.totalCost <= 0 ? ( @@ -292,10 +654,70 @@ export default function CostOverviewTab() { title={t("topModels")} nameKey="model" valueKey="cost" + secondaryKey="totalTokens" + secondaryLabel={t("tokens")} rows={modelsByCost} locale={locale} /> + + {(apiKeysByCost.length > 0 || accountsByCost.length > 0) && ( +
+ {apiKeysByCost.length > 0 && ( + + )} + {accountsByCost.length > 0 && ( + + )} +
+ )} + + {summary.totalRequests > 0 && ( +
+ + +
+ )} )} @@ -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 ( + +

+ {title} +

+
+ + + + + new Intl.NumberFormat(locale, { notation: "compact" }).format(Number(value || 0)) + } + width={40} + /> + + `${new Intl.NumberFormat(locale).format(value || 0)} tokens` + } + contentStyle={{ + background: "var(--surface)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "12px", + }} + /> + + + +
+
+ ); +} + +function ActivityHeatmap({ + title, + activityMap, + lessLabel, + moreLabel, + locale, +}: { + title: string; + activityMap: Record; + 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> = []; + for (let index = 0; index < days.length; index += 7) { + weeks.push(days.slice(index, index + 7)); + } + + return ( + +

+ {title} +

+
+
+ {weeks.map((week) => ( +
+ {week.map((day) => ( +
0 + ? `${new Intl.NumberFormat(locale).format(day.value)} tokens` + : "No activity" + }`} + /> + ))} +
+ ))} +
+
+
+ {lessLabel} +
+
+
+
+
+
+
+ {moreLabel} +
+ + ); +} + function TopListCard({ title, rows, nameKey, valueKey, + secondaryKey, + secondaryLabel, locale, }: { title: string; rows: Array>; 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" > {String(row[nameKey])} - - {currencyFormatter.format(Number(row[valueKey] || 0))} - +
+ {secondaryKey ? ( + + {new Intl.NumberFormat(locale, { notation: "compact" }).format( + Number(row[secondaryKey] || 0) + )}{" "} + {secondaryLabel} + + ) : null} + + {currencyFormatter.format(Number(row[valueKey] || 0))} + +
))}
); } + +interface ColumnDef { + key: string; + label: string; + align: "left" | "right"; + format?: "number" | "compact" | "currency"; +} + +function CostBreakdownTable({ + title, + rows, + columns, + locale, +}: { + title: string; + rows: Array>; + 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 ( + +

+ {title} +

+
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {formatValue(row[column.key], column.format)} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 3a82e76f0e..1bfaee3f81 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -66,6 +66,7 @@ const WEBHOOK_EVENTS = [ /* ─── Main Component ─────────────────────────────────── */ export default function ApiEndpointsTab() { const [catalog, setCatalog] = useState(null); + const [catalogError, setCatalogError] = useState(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() {
{/* ═══ API CATALOG ═══ */} + {section === "catalog" && !catalog && ( + +
+
+ error +
+
+

API catalog unavailable

+

+ {catalogError || "The OpenAPI specification could not be loaded."} +

+ + open_in_new + Open JSON response + +
+
+
+ )} + {section === "catalog" && catalog && ( <> {/* Search & filter */} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 3d12cb818f..10f3e25412 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -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; + 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) { @@ -104,7 +128,7 @@ function runEndpointBackgroundTask(taskName: string, task: () => Promise 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({
); } - -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, -}; diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx new file mode 100644 index 0000000000..ce993e3663 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -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(); + }); + + 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"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx index 6b54fcf62f..3c92edfe9a 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx @@ -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; diff --git a/src/app/(dashboard)/dashboard/endpoint/page.tsx b/src/app/(dashboard)/dashboard/endpoint/page.tsx index fb97242642..4d5d159e3e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/page.tsx @@ -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 (
- {status.loading ? "..." : status.online ? "Online" : "Offline"} + {loading ? "..." : online ? "Online" : "Offline"}
); diff --git a/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx b/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx new file mode 100644 index 0000000000..bafd447d83 --- /dev/null +++ b/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx @@ -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
; + } + + 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 ( + + ); +} + +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(null); + const [health, setHealth] = useState(null); + const [samples, setSamples] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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; + }), + fetch("/api/monitoring/health").then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }), + ]); + + 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 ( + +
+
+

+ monitoring + {t("title")} +

+

{t("description")}

+ {lastUpdated && ( +

+ {t("updatedAt", { time: lastUpdated.toLocaleTimeString() })} +

+ )} +
+ +
+ + {error && ( +
+ {t("partialData", { error })} +
+ )} + +
+ {metricCards.map((metric) => ( +
+
+
+

+ {metric.label} +

+

{metric.value}

+
+ + {metric.icon} + +
+
+ ))} +
+ +
+
+
+ {t("latencyTrend")} + {formatMs(values.p95Latency)} p95 +
+ +
+
+
+ {t("throughputTrend")} + {values.totalRequests.toLocaleString()} +
+ +
+
+
+ {t("memoryTrend")} + {formatBytes(values.memoryUsage.heapUsed ?? 0)} +
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index d4af48798e..da02692bc1 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -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() {
+ +
@@ -617,38 +617,8 @@ export default function HealthPage() { )} - {/* Telemetry Cards — Latency & Prompt Cache */} -
- {/* Latency Card */} - -

- speed - {t("latency")} -

- {telemetry ? ( -
-
- {t("latencyP50")} - {fmtMs(telemetry.p50)} -
-
- {t("latencyP95")} - {fmtMs(telemetry.p95)} -
-
- {t("latencyP99")} - {fmtMs(telemetry.p99)} -
-
- {t("totalRequests")} - {telemetry.totalRequests ?? 0} -
-
- ) : ( -

{t("noDataYet")}

- )} -
- + {/* Cache Cards */} +
{/* Prompt Cache Card */}

diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 8c7c64eeeb..48e677fabc 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -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" /> - {exporting ? "Exporting..." : "Export"} + {exporting ? t("exporting") : t("export")} {showExport && ( @@ -121,7 +121,7 @@ export default function LogsPage() { shadow-xl overflow-hidden animate-in fade-in" >
- Time Range + {t("timeRange")}
{TIME_RANGES.map((range) => ( ))} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 5cefea5c4d..34c9cc37cc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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 ⏱ {remaining}; } -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 = { "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({ ); } - -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, -}; diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx new file mode 100644 index 0000000000..8b0b10a8cd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx @@ -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; + +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(() => 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 = { + 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 = { + 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 ( + +
+ {defaults.hasWarning && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} + + setFormData({ ...formData, name: e.target.value })} + placeholder={namePlaceholder} + hint={nameHint} + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={prefixPlaceholder} + hint={prefixHint} + /> + {defaults.hasApiType && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={baseUrlPlaceholder} + hint={baseUrlHint} + /> + + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={chatPathPlaceholder} + hint={chatPathHint} + /> + {defaults.hasModelsPath && ( + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> + )} +
+ )} + +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? t("valid") : t("invalid")} + + )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx new file mode 100644 index 0000000000..b977a3a363 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -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 = { + 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 +) { + const parts: ReactNode[] = []; + if (connected > 0) { + parts.push( + + {t("connected", { count: connected })} + + ); + } + if (error > 0) { + const errText = errorCode + ? t("errorCount", { count: error, code: errorCode }) + : t("errorCountNoCode", { count: error }); + parts.push( + + {errText} + + ); + } + if (parts.length === 0) { + return {t("noConnections")}; + } + 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 = { + 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) => { + event.preventDefault(); + event.stopPropagation(); + onToggle(allDisabled); + }; + + return ( + + +
+
+
+ {staticIconPath ? ( + {provider.name} + ) : ( + + )} +
+
+

+ + {provider.name} + + {provider.deprecated && ( + + + block + {t("deprecated")} + + + )} + +

+
+ {allDisabled ? ( + + + pause_circle + {t("disabled")} + + + ) : ( + <> + {getStatusDisplay(connected, error, stats.errorCode, t)} + {(authType === "free" || provider.hasFree === true) && ( + + + redeem + {t("freeTier")} + + + )} + {stats.expiryStatus === "expired" && ( + + {t("expiredBadge")} + + )} + {stats.expiryStatus === "expiring_soon" && ( + + {t("expiringSoonBadge")} + + )} + {isCompatible && ( + + {provider.apiType === "responses" ? t("responses") : t("chat")} + + )} + {isCcCompatible && ( + + CC + + )} + {isAnthropicCompatible && ( + + {t("messages")} + + )} + {stats.errorTime && ( + * {stats.errorTime} + )} + + )} +
+
+
+
+ {Number(stats.total || 0) > 0 && ( +
+ {}} + title={allDisabled ? t("enableProvider") : t("disableProvider")} + /> +
+ )} + + chevron_right + +
+
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 4249b8ced2..69ff5d7a2f 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,27 +1,17 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import Image from "next/image"; -import ProviderIcon from "@/shared/components/ProviderIcon"; -import PropTypes from "prop-types"; -import { - Card, - CardSkeleton, - Badge, - Button, - Input, - Modal, - Select, - Toggle, -} from "@/shared/components"; +import { CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components"; import { FREE_PROVIDERS, OAUTH_PROVIDERS, - isAnthropicCompatibleProvider, + AGGREGATOR_PROVIDER_IDS, + EMBEDDING_RERANK_PROVIDER_IDS, + ENTERPRISE_CLOUD_PROVIDER_IDS, + IMAGE_ONLY_PROVIDER_IDS, + VIDEO_PROVIDER_IDS, isClaudeCodeCompatibleProvider, - isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; @@ -35,29 +25,10 @@ import { } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; +import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal"; +import ProviderCard from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; -const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; -const IMAGE_ONLY_PROVIDER_IDS = new Set([ - "nanobanana", - "fal-ai", - "stability-ai", - "black-forest-labs", - "recraft", - "topaz", -]); -const AGGREGATOR_PROVIDER_IDS = new Set([ - "openrouter", - "synthetic", - "kilo-gateway", - "aimlapi", - "novita", - "piapi", - "getgoapi", - "laozhang", - "vercel-ai-gateway", -]); - function countConfigured(entries: ProviderEntry[]) { return { configured: entries.filter((entry) => Number(entry.stats?.total || 0) > 0).length, @@ -65,31 +36,25 @@ function countConfigured(entries: ProviderEntry[]) { }; } -// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard -function getStatusDisplay(connected, error, errorCode, t) { - const parts = []; - if (connected > 0) { - parts.push( - - {t("connected", { count: connected })} - - ); - } - if (error > 0) { - const errText = errorCode - ? t("errorCount", { count: error, code: errorCode }) - : t("errorCountNoCode", { count: error }); - parts.push( - - {errText} - - ); - } - if (parts.length === 0) { - return {t("noConnections")}; - } - return parts; -} +type ProviderBatchTestResult = { + connectionId?: string; + connectionName?: string; + provider?: string; + valid?: boolean; + latencyMs?: number; + diagnosis?: { type?: string }; +}; + +type ProviderBatchTestResults = { + mode?: string; + results?: ProviderBatchTestResult[]; + summary?: { + total?: number; + passed?: number; + failed?: number; + }; + error?: string | { message?: string }; +}; function getConnectionErrorTag(connection) { if (!connection) return null; @@ -437,7 +402,10 @@ export default function ProvidersPage() { apiKeyProviderEntriesAll.filter( (entry) => !IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) && - !AGGREGATOR_PROVIDER_IDS.has(entry.providerId) + !AGGREGATOR_PROVIDER_IDS.has(entry.providerId) && + !ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) && + !VIDEO_PROVIDER_IDS.has(entry.providerId) && + !EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) ), showConfiguredOnly, searchQuery @@ -452,6 +420,21 @@ export default function ProvidersPage() { showConfiguredOnly, searchQuery ); + const enterpriseProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); + const videoProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); + const embeddingRerankProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); const webCookieProviderEntries = filterConfiguredProviderEntries( @@ -703,7 +686,7 @@ export default function ProvidersPage() {
{llmProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {aggregatorProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+

+ )} + + {enterpriseProviderEntries.length > 0 && ( +
+

+ {t("enterpriseCloud")} +

+
+ {enterpriseProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + {imageProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {videoProviderEntries.length > 0 && ( +
+

+ {t("videoProviders")} +

+
+ {videoProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {embeddingRerankProviderEntries.length > 0 && ( +
+

+ {t("embeddingRerankProviders")} +

+
+ {embeddingRerankProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + {webCookieProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {searchProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {audioProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - +
{localProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - +
{upstreamProxyEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {compatibleProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - )}
- setShowAddCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1031,8 +1111,9 @@ export default function ProvidersPage() { router.push(`/dashboard/providers/${node.id}`); }} /> - setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1041,9 +1122,10 @@ export default function ProvidersPage() { }} /> {ccCompatibleProviderEnabled && ( - setShowAddCcCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1083,865 +1165,9 @@ export default function ProvidersPage() { ); } -function ProviderCard({ providerId, provider, stats, authType, onToggle }) { - const t = useTranslations("providers"); - const tc = useTranslations("common"); - const { connected, error, errorCode, errorTime, allDisabled } = stats; - - // (#529) Icon state replaced by ProviderIcon component (Lobehub + PNG + generic fallback) - - const dotColors = { - free: "bg-green-500", - oauth: "bg-blue-500", - apikey: "bg-amber-500", - compatible: "bg-orange-500", - }; - const dotLabels = { - free: tc("free"), - oauth: t("oauthLabel"), - apikey: t("apiKeyLabel"), - compatible: t("compatibleLabel"), - }; - - return ( - - -
-
-
- {/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */} - -
-
-

- {provider.name} - -

-
- {allDisabled ? ( - - - pause_circle - {t("disabled")} - - - ) : ( - <> - {getStatusDisplay(connected, error, errorCode, t)} - {stats.expiryStatus === "expired" && ( - - Expired - - )} - {stats.expiryStatus === "expiring_soon" && ( - - Expiring Soon - - )} - {errorTime && • {errorTime}} - - )} -
-
-
-
- {stats.total > 0 && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onToggle(!allDisabled ? false : true); - }} - className="" - > - {}} - title={allDisabled ? t("enableProvider") : t("disableProvider")} - /> -
- )} - - chevron_right - -
-
-
- - ); -} - -ProviderCard.propTypes = { - providerId: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - }).isRequired, - stats: PropTypes.shape({ - connected: PropTypes.number, - error: PropTypes.number, - errorCode: PropTypes.string, - errorTime: PropTypes.string, - }).isRequired, - authType: PropTypes.string, -}; - -// API Key providers - use image with textIcon fallback (same as OAuth providers) -function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) { - const t = useTranslations("providers"); - const tc = useTranslations("common"); - const { connected, error, errorCode, errorTime, allDisabled } = stats; - const isCompatible = isOpenAICompatibleProvider(providerId); - const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); - const isAnthropicCompatible = - isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId); - - const dotColors = { - free: "bg-green-500", - oauth: "bg-blue-500", - apikey: "bg-amber-500", - compatible: "bg-orange-500", - }; - const dotLabels = { - free: tc("free"), - oauth: t("oauthLabel"), - apikey: t("apiKeyLabel"), - compatible: t("compatibleLabel"), - }; - - // (#529) Icon state replaced by ProviderIcon component - // For compatible/anthropic providers, continue using static PNGs via the icon path - 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; // ProviderIcon will handle it - })(); - - return ( - - -
-
-
- {/* (#529) ProviderIcon with static override for compatible providers */} - {staticIconPath ? ( - {provider.name} - ) : ( - - )} -
-
-

- {provider.name} - -

-
- {allDisabled ? ( - - - pause_circle - {t("disabled")} - - - ) : ( - <> - {getStatusDisplay(connected, error, errorCode, t)} - {stats.expiryStatus === "expired" && ( - - Expired - - )} - {stats.expiryStatus === "expiring_soon" && ( - - Expiring Soon - - )} - {isCompatible && ( - - {provider.apiType === "responses" ? t("responses") : t("chat")} - - )} - {isCcCompatible && ( - - CC - - )} - {isAnthropicCompatible && ( - - {t("messages")} - - )} - {errorTime && • {errorTime}} - - )} -
-
-
-
- {stats.total > 0 && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onToggle(!allDisabled ? false : true); - }} - className="" - > - {}} - title={allDisabled ? t("enableProvider") : t("disableProvider")} - /> -
- )} - - chevron_right - -
-
-
- - ); -} - -ApiKeyProviderCard.propTypes = { - providerId: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - apiType: PropTypes.string, - }).isRequired, - stats: PropTypes.shape({ - connected: PropTypes.number, - error: PropTypes.number, - errorCode: PropTypes.string, - errorTime: PropTypes.string, - }).isRequired, - authType: PropTypes.string, -}; - -function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - chatPath: "", - modelsPath: "", - }); - 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 = [ - { 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") }, - ]; - - useEffect(() => { - const defaultBaseUrl = "https://api.openai.com/v1"; - setFormData((prev) => ({ - ...prev, - baseUrl: defaultBaseUrl, - })); - }, [formData.apiType]); - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - apiType: formData.apiType, - baseUrl: formData.baseUrl, - type: "openai-compatible", - chatPath: formData.chatPath || "", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - chatPath: "", - modelsPath: "", - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating OpenAI Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "openai-compatible", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("compatibleProdPlaceholder", { type: t("openai") })} - hint={t("nameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("openaiPrefixPlaceholder")} - hint={t("prefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("openaiBaseUrlPlaceholder")} - hint={t("compatibleBaseUrlHint", { type: t("openai") })} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder={t("chatPathPlaceholder")} - hint={t("chatPathHint")} - /> - setFormData({ ...formData, modelsPath: e.target.value })} - placeholder={t("modelsPathPlaceholder")} - hint={t("modelsPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddOpenAICompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - -function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - chatPath: "", - modelsPath: "", - }); - 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); - - useEffect(() => { - // Reset validation when modal opens - if (isOpen) { - setValidationResult(null); - setCheckKey(""); - } - }, [isOpen]); - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - type: "anthropic-compatible", - chatPath: formData.chatPath || "", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - chatPath: "", - modelsPath: "", - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating Anthropic Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("compatibleProdPlaceholder", { type: t("anthropic") })} - hint={t("nameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("anthropicPrefixPlaceholder")} - hint={t("prefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("anthropicBaseUrlPlaceholder")} - hint={t("compatibleBaseUrlHint", { type: t("anthropic") })} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder="/messages" - hint={t("chatPathHint")} - /> - setFormData({ ...formData, modelsPath: e.target.value })} - placeholder={t("modelsPathPlaceholder")} - hint={t("modelsPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddAnthropicCompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - -function AddCcCompatibleModal({ isOpen, addLabel, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - baseUrl: "", - chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }); - 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 hasRequiredFields = Boolean( - formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim() - ); - const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim()); - - useEffect(() => { - if (isOpen) { - setValidationResult(null); - setCheckKey(""); - } - }, [isOpen]); - - const handleSubmit = async () => { - if (!hasRequiredFields) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - type: "anthropic-compatible", - compatMode: "cc", - chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "", - chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating CC Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible", - compatMode: "cc", - chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
-
-
- - warning - -

{t("ccCompatibleValidationHint")}

-
-
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("ccCompatibleNamePlaceholder")} - hint={t("ccCompatibleNameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("ccCompatiblePrefixPlaceholder")} - hint={t("ccCompatiblePrefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("ccCompatibleBaseUrlPlaceholder")} - hint={t("ccCompatibleBaseUrlHint")} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH} - hint={t("ccCompatibleChatPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddCcCompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - addLabel: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - // ─── Provider Test Results View (mirrors combo TestResultsView) ────────────── -function ProviderTestResultsView({ results }) { +function ProviderTestResultsView({ results }: { results: ProviderBatchTestResults }) { const t = useTranslations("providers"); const tc = useTranslations("common"); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); @@ -2040,16 +1266,3 @@ function ProviderTestResultsView({ results }) {
); } - -ProviderTestResultsView.propTypes = { - results: PropTypes.shape({ - mode: PropTypes.string, - results: PropTypes.array, - summary: PropTypes.shape({ - total: PropTypes.number, - passed: PropTypes.number, - failed: PropTypes.number, - }), - error: PropTypes.string, - }).isRequired, -}; diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index 50b6d102cb..ce24000c43 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -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} />
+ +
+
+

{getSettingsLabel("showNgrokTunnel", "ngrok Tunnel")}

+

+ {getSettingsLabel( + "showNgrokTunnelDesc", + "Show ngrok Tunnel controls on the Endpoint page." + )} +

+
+ updateSetting("hideEndpointNgrokTunnel", !checked)} + disabled={loading} + /> +
diff --git a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx new file mode 100644 index 0000000000..29f2d44c24 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx @@ -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(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, 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 ( + +
+
+

+ lan + {t("title")} +

+

{t("description")}

+
+
+ + + {status.running ? "play_circle" : "pause_circle"} + + {status.running ? t("running") : t("stopped")} + + +
+
+ + {feedback && ( +
+ {feedback.message} +
+ )} + +
+
+
+
+
+

{t("enable")}

+

{t("enableDesc")}

+
+ +
+ +
+ + + +
+ +
+ +
+
+
+

{t("certificate")}

+

+ {status.certExists ? t("certificateReady") : t("certificateMissing")} +

+
+ + {status.certExists ? t("available") : t("missing")} + +
+
+ + download + {t("downloadCert")} + + +
+
+
+ +
+ {[ + { + 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) => ( +
+
+
+

+ {item.label} +

+

{item.value}

+
+ + {item.icon} + +
+
+ ))} +
+

+ {t("lastIntercept")} +

+

+ {formatDate(status.stats.lastInterceptAt)} +

+
+
+
+ +
+
+

{t("targetRoutes")}

+
+ {status.targets.length === 0 ? ( +
{t("noTargets")}
+ ) : ( +
+ + + + + + + + + + + + {status.targets.map((target) => ( + + + + + + + + ))} + +
{t("target")}{t("host")}{t("localPort")}{t("endpoints")}{t("status")}
{target.name} + {target.targetHost}:{target.targetPort} + + {target.localPort} + +
+ {target.endpoints.map((endpoint) => ( + + {endpoint} + + ))} +
+
+ + {target.enabled ? t("enabled") : t("configured")} + +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index cbec0d9576..e7511ddb51 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -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" && } + {activeTab === "mitm" && } + {activeTab === "advanced" && (
diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx index 1d359d4fce..3d57ac02ad 100644 --- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx @@ -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() {
+ + + + {showFeatures && ( +
+ + + + + + + + +
+ )} +
+ {/* Mode Content */} {mode === "playground" && } {mode === "chat-tester" && } @@ -103,3 +177,60 @@ export default function TranslatorPageClient() {
); } + +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 ( +
+
+ {icon} +

{title}

+
+

{description}

+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx index 07aaed5b04..3d1e29a0a3 100644 --- a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx @@ -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() { { + const file = event.currentTarget.files?.[0]; + if (file) { + void handleImportSuite(file); + } + event.currentTarget.value = ""; + }} + /> + + + + + {runProgress && ( +
+
+ + {t("runAllProgress", { + current: runProgress.current, + total: runProgress.total, + name: runProgress.suiteName || t("runAllSuites"), + })} + + {runAllPercent}% +
+
+
+
+ {runProgress.failedSuites > 0 && ( +

+ {t("runAllFailedSuites", { count: runProgress.failedSuites })} +

+ )} +
+ )} +
+ + {suite.source === "custom" && ( <>
- ({ - 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 ? ( - {t("passedIconLabel")} - ) : ( -
- {t("failedIconLabel")} - {row.error ? ( - - {t("errorBadge")} + {run.results.length > 0 ? ( +
+ {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 ( +
+ + + {isResultExpanded && ( +
+
+

+ {t("expectedOutputLabel")} +

+
+                                              {expectedOutput}
+                                            
+
+
+

+ {t("actualOutputLabel")} +

+
+                                              {actualOutput}
+                                            
+ {result.error ? ( +

+ {result.error} +

+ ) : null} +
+
+ )}
); - } - - if (column.key === "durationMs") { - return ( - - {row.durationMs != null ? `${row.durationMs}ms` : "—"} - - ); - } - - if (column.key === "details") { - return ( - - {getResultDetails(row as EvalResult, t)} - - ); - } - - return ( - - {String(row[column.key] || "—")} - - ); - }} - maxHeight="360px" - emptyMessage={t("noResultsYet")} - /> + })} +
+ ) : ( +
+ {t("noResultsYet")} +
+ )} ))}
@@ -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 ( - {draft.cases.map((draftCase, index) => ( - -
-
-

- {t("suiteBuilderCaseCardTitle", { index: index + 1 })} -

-

- {t("suiteBuilderCaseCardHint", { index: index + 1 })} -

+ {draft.cases.map((draftCase, index) => { + const selectedStrategy = editableStrategies.find( + (strategy) => strategy.name === draftCase.strategy + ); + + return ( + +
+
+

+ {t("suiteBuilderCaseCardTitle", { index: index + 1 })} +

+

+ {t("suiteBuilderCaseCardHint", { index: index + 1 })} +

+
+
+ + +
- -
-
- updateCase(draftCase.id, { name: event.target.value })} - placeholder={t("suiteBuilderCaseNamePlaceholder")} - /> - updateCase(draftCase.id, { model: event.target.value })} - placeholder={t("suiteBuilderCaseModelPlaceholder")} - /> - updateCase(draftCase.id, { tags: event.target.value })} - placeholder={t("suiteBuilderCaseTagsPlaceholder")} - hint={t("suiteBuilderCaseTagsHint")} - /> -