Compare commits

..

13 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza 515674b6cf chore(release): v3.6.1 — OAuth env repair + i18n fix (#1117)
Build Electron Desktop App / Validate version (push) Failing after 41s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
* chore: bump to v3.6.1

* fix(i18n): add missing provider messages across locales (#1111)

Integrated into release/v3.6.1 — adds missing filterModels, modelsActive, showModel, hideModel i18n keys across all 32 locales

* fix: add Repair env action for OAuth providers (#1116)

Integrated into release/v3.6.1 — adds OAuth env repair feature with full 33-language i18n support and backupPath security fix

* chore(release): v3.6.1 — OAuth env repair + i18n fix

* fix: add targetFormat openai-responses to gpt-5.4 and gpt-5.4-mini (#1114)

* fix: add targetFormat openai-responses to gpt-5.4 and gpt-5.4-mini (#1114)

* chore: force CI trigger

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Ilham Ramadhan <28677129+rilham97@users.noreply.github.com>
Co-authored-by: Artёm <470045+yart@users.noreply.github.com>
2026-04-10 12:19:15 -03:00
Diego Rodrigues de Sa e Souza 37cc63e493 Release v3.6.0 (#1109)
Build Electron Desktop App / Validate version (push) Failing after 31s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
* chore: create release/v3.6.0 branch

* fix: add row count limits to prevent DB bloat and handle HTML error responses (#1104)

Integrated into release/v3.6.0

* fix combo smoke test payload for thinking models (#1105)

Integrated into release/v3.6.0

* fix: improve Android/Termux ARM64 support for better-sqlite3 (#1107)

Integrated into release/v3.6.0

* chore: finalize CHANGELOG and sync versions for v3.6.0

* fix(tests): align CI tests with v3.6.0 changes

- compliance: match new cleanupExpiredLogs return shape (trimmed/maxRows)
- model-sync: accept masked email in account field
- e2e: allow 401/403/307 for auth-protected /api/providers endpoint

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Suhayli <73960279+Suhay1i@users.noreply.github.com>
2026-04-10 09:56:42 -03:00
diegosouzapw 4cd43f9c93 fix(test): make token layout test locale-agnostic
Build Electron Desktop App / Validate version (push) Failing after 28s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-04-09 22:28:01 -03:00
Diego Rodrigues de Sa e Souza 691d83e596 Merge pull request #1102 from diegosouzapw/release/v3.5.9
Release v3.5.9 merged to main
2026-04-09 22:22:28 -03:00
diegosouzapw 4bca25d783 chore(release): v3.5.9 — combo ordering, stream failures, Docker EXDEV, token layout 2026-04-09 22:22:02 -03:00
Randi 74a5bcb3a9 Add persistent combo ordering and reorder sidebar items (#1095)
Integrated into release/v3.5.9 with openapi.yaml version fix
2026-04-09 21:16:42 -03:00
Randi 9f55159bd5 Fix request log detail token layout (#1096)
Integrated into release/v3.5.9
2026-04-09 21:15:14 -03:00
Randi f118082f6b fix(docker): handle EXDEV in isolated build (#1097)
Integrated into release/v3.5.9
2026-04-09 21:15:12 -03:00
Artёm 586e9f328f fix(stream): surface Responses failures and preserve upstream model (#1098)
Integrated into release/v3.5.9
2026-04-09 21:15:09 -03:00
diegosouzapw fb9d52a19b chore: create release/v3.5.9 branch 2026-04-09 21:10:06 -03:00
diegosouzapw 815a7b6e1d chore(release): verify v3.5.8 sync (changelog, docs)
Build Electron Desktop App / Validate version (push) Failing after 33s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-04-09 19:12:01 -03:00
Diego Rodrigues de Sa e Souza 336d889034 Release v3.5.8 (#1092)
* chore: bump to v3.5.8-dev

* fix(combo): quarantine degraded provider-model pairs adaptively (#1090)

Integrated into release/v3.5.8

* fix(healthcheck): keep active accounts routable after refresh failure (#1085)

Integrated into release/v3.5.8

* chore: squash PR 1089

* Merge PR 1088: docs(deps): bump axios from 1.14.0 to 1.15.0

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Wellington Fonseca <wf.tecnologia@hotmail.com>
Co-authored-by: xiaoge1688 <129356184+xiaoge1688@users.noreply.github.com>
2026-04-09 18:18:45 -03:00
diegosouzapw bba7479688 fix(build): force webpack in Next.js 16 and prevent app/ routing conflicts 2026-04-09 18:03:42 -03:00
102 changed files with 3108 additions and 409 deletions
+182
View File
@@ -0,0 +1,182 @@
# Fix CI Workflow
Look up the latest GitHub Actions CI run for the current release branch, diagnose all failures, fix them locally, push, and wait for the new CI run to go green before notifying the user.
---
## Phase 1: Identify the Failing CI Run
### 1. Determine the current release version and branch
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
VERSION=$(node -p "require('./package.json').version")
BRANCH=$(git branch --show-current)
echo "Version: $VERSION"
echo "Branch: $BRANCH"
```
### 2. Find the latest CI run for the release PR
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
# Find the PR number for the release branch
PR_NUMBER=$(gh pr list --repo diegosouzapw/OmniRoute --head "$BRANCH" --json number --jq '.[0].number')
echo "PR: #$PR_NUMBER"
# Get the latest CI run
RUN_ID=$(gh run list --repo diegosouzapw/OmniRoute --branch "$BRANCH" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Latest CI Run: $RUN_ID"
echo "URL: https://github.com/diegosouzapw/OmniRoute/actions/runs/$RUN_ID"
```
---
## Phase 2: Diagnose Failures
### 3. List all failing jobs
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
RUN_ID=$(gh run list --repo diegosouzapw/OmniRoute --branch "$(git branch --show-current)" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run view "$RUN_ID" --repo diegosouzapw/OmniRoute --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name: .name, conclusion: .conclusion, steps: [.steps[] | select(.conclusion == "failure") | .name]}'
```
### 4. Download and analyze failure logs
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
RUN_ID=$(gh run list --repo diegosouzapw/OmniRoute --branch "$(git branch --show-current)" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run view "$RUN_ID" --repo diegosouzapw/OmniRoute --log-failed 2>&1 | grep -aE "not ok|FAIL|Error:|error:|AssertionError|expected|actual" | grep -v "node_modules\|runner\|git version" | head -50
```
### 5. Classify each failure
For each failing job, determine the root cause category:
| Category | Pattern | Fix Strategy |
| ------------------ | ---------------------------------- | ------------------------------------------ |
| **docs-sync** | OpenAPI/CHANGELOG version mismatch | Run `/version-bump` step 7-8 |
| **Test assertion** | `not ok` + `AssertionError` | Update test expectations to match new code |
| **E2E flaky** | Auth-related 401/403/307 | Make tests tolerate auth states |
| **Coverage gate** | `below threshold` | Add more tests or adjust threshold |
| **Lint** | ESLint errors | Fix code or update rules |
| **Build** | Compilation errors | Fix TypeScript issues |
---
## Phase 3: Apply Fixes
### 6. Fix each failure
For each classified failure:
1. **Read the failing test file** to understand the assertion
2. **Read the production source** to understand the new behavior
3. **Update the test** to match the current behavior
4. **Run the test locally** to verify the fix
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
# Run the specific failing test file(s) to confirm fixes
# Example: node --import tsx/esm --test tests/unit/FAILING_FILE.test.mjs
```
### 7. Run the full local test suite
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
npm test
```
### 8. Run docs-sync check
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
npm run check:docs-sync
```
---
## Phase 4: Push and Monitor
### 9. Commit and push fixes
// turbo-all
```bash
cd /home/diegosouzapw/dev/proxys/9router
git add -A
git commit -m "fix(tests): align CI tests with release changes"
git push origin "$(git branch --show-current)"
```
### 10. Wait for CI to trigger and find the new run
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
sleep 15
BRANCH=$(git branch --show-current)
NEW_RUN_ID=$(gh run list --repo diegosouzapw/OmniRoute --branch "$BRANCH" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')
echo "New CI Run: $NEW_RUN_ID"
echo "URL: https://github.com/diegosouzapw/OmniRoute/actions/runs/$NEW_RUN_ID"
```
### 11. Monitor the CI run
// turbo
```bash
cd /home/diegosouzapw/dev/proxys/9router
BRANCH=$(git branch --show-current)
NEW_RUN_ID=$(gh run list --repo diegosouzapw/OmniRoute --branch "$BRANCH" --workflow ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$NEW_RUN_ID" --repo diegosouzapw/OmniRoute --exit-status
```
If `gh run watch` exits with 0, the CI is green. If it exits with non-zero, go back to Phase 2 and repeat.
### 12. 🛑 STOP — Notify User
Present a summary to the user:
- **Previous CI run**: URL, list of failures
- **Root causes**: What broke and why
- **Fixes applied**: What tests were changed
- **New CI run**: URL, all-green status
- **PR status**: Ready for review/merge
---
## Common CI Failure Patterns
| Failure | Root Cause | Fix |
| ------------------------------------------ | -------------------------------------- | ----------------------------- |
| `docs-sync FAIL - OpenAPI version differs` | Version not synced after bump | `sed -i` openapi.yaml |
| `docs-sync FAIL - CHANGELOG first section` | Missing `## [Unreleased]` header | Add unreleased section |
| `not ok - cleanupExpiredLogs` | Return shape changed (new fields) | Update `assert.deepEqual` |
| `not ok - email masking` | Email now masked in call logs | Assert masked pattern instead |
| `E2E /api/providers` returns non-200 | Auth enabled in CI, endpoint protected | Accept 401/403 as valid |
| `coverage below 60%` | New untested code | Add unit tests |
## Notes
- This workflow is **iterative**: if the first fix attempt doesn't clear all failures, repeat Phases 2-4.
- Always run tests **locally** before pushing to avoid wasting CI minutes.
- The CI is triggered automatically on push to branches with open PRs to `main`.
- Use `gh run watch` to monitor in real-time instead of polling.
+62
View File
@@ -4,6 +4,68 @@
---
## [3.6.1] — 2026-04-10
### ✨ New Features
- **OAuth Env Repair Action:** Added a "Repair env" button to the OAuth Providers dashboard that detects and restores missing OAuth client IDs from `.env.example` — with timestamped backup and append-only safety. Includes full 33-language i18n support and sanitized API responses (#1116, by @yart)
### 🐛 Bug Fixes
- **i18n: Missing Provider Keys:** Added missing `filterModels`, `modelsActive`, `showModel`, `hideModel` keys across all 32 locale files, fixing runtime `MISSING_MESSAGE` errors in the providers UI. Also cleaned up duplicate keys in `en.json` (#1111, by @rilham97)
- **GPT-5.4 Routing:** Added missing `targetFormat: "openai-responses"` to `gpt-5.4` and `gpt-5.4-mini` models in both the Codex and GitHub Copilot providers, fixing `[400]: model not accessible via /chat/completions` errors (#1114, by @ask33r)
---
## [3.6.0] — 2026-04-10
### ✨ New Features & Analytics
- **Combo Smoke Test:** Raised the default token budget to 2048 to prevent truncation of thinking models during preflight checks, and fully randomized the arithmetic probe prompt to bypass deterministic caching from upstream relays (#1105)
### 🐛 Bug Fixes & Compliance
- **DB Bloat / Row Limits:** Added `CALL_LOGS_TABLE_MAX_ROWS` and `PROXY_LOGS_TABLE_MAX_ROWS` (default: 100,000) to the backend DB compliance cleaner to prevent runaway SQLite growth. Limits are enforced automatically on the TTL cycle (#1104, fixes #1101)
- **HTML Error Handling:** The router now correctly identifies unexpected HTML responses (e.g. `<!DOCTYPE html>`) sent by upstream providers (like Azure/Copilot) instead of throwing obscure `Unexpected token '<'` JSON parse errors, bubbling up a clean 502 Bad Gateway (#1104, fixes #1066)
- **Android/Termux SQLite Native Support:** `better-sqlite3` is now correctly built from source with cross-compilation flags in ARM64 local Termux deployments without failing on missing prebuilt binaries (#1107)
---
## [3.5.9] — 2026-04-09
### ✨ New Features
- **Persistent Combo Ordering:** Drag combo cards by handle to reorder them in the dashboard; order is persisted to SQLite via a new `sort_order` column and `POST /api/combos/reorder` endpoint. Includes DB migration `020_combo_sort_order.sql` and JSON import preservation (#1095)
- **Sidebar Group Reorder:** Moved "Logs" before "Health" in the System section and "Limits & Quotas" after "Cache" in the Primary section for a more logical navigation flow (#1095)
### 🐛 Bug Fixes
- **Stream Failure Surfacing:** Upstream `response.failed` events (e.g. Codex rate-limit errors) are now properly surfaced as non-200 errors instead of being silently swallowed as empty 200 OK streams. Rate-limit failures return HTTP 429 (#1098, closes #1093)
- **Upstream Model Preservation:** The Responses-to-OpenAI stream translator now preserves the actual upstream model (e.g. `gpt-5.4`) instead of hardcoding a `gpt-4` fallback (#1098, closes #1094)
- **Docker EXDEV Fix:** `build-next-isolated.mjs` now falls back from `fs.rename()` to `cp/rm` when Docker buildx raises `EXDEV` (cross-device link), unblocking the Docker image publish workflow (#1097)
- **macOS CLI Path Resolution:** `cliRuntime.ts` resolves symlink parents with `fs.realpath()` to handle macOS `/var``/private/var` chains, preventing false `symlink_escape` rejections (#1097)
- **Request Log Token Layout:** Split token badges into separate Input (Total In, Cache Read, Cache Write) and Output (Total Out, Reasoning) groups for clearer readability; renamed "Time" label to "Completed Time" (#1096)
---
## [3.5.8] — 2026-04-09
### ✨ New Features & Analytics
- **Analytics Layout Redesign:** Replaced flat metrics with a responsive `CompactStatGrid`, grouping data visually across sections (#1089)
### 🐛 Bug Fixes
- **Build Core:** Force Turbopack cleanup via Prepbulish script to prevent Next.js 16 app/ routing conflicts on runtime.
- **Provider Quarantine:** Introduces model/provider circuit-breakers with adaptive TTL exponential backoff for recurring upstream errors (#1090)
- **Oauth Keep-Alive:** Safely protects authenticated active accounts against spontaneous dropping from router due to transient token refresh failures (#1085)
### 🔒 Security & Maintenance
- **Dependabot:** bumped axios from 1.14.0 to 1.15.0 addressing SSRF flags (#1088)
---
## [3.5.7] — 2026-04-09
### 🐛 Bug Fixes & Security
+1
View File
@@ -486,6 +486,7 @@ Developers who want all responses in a specific language, with a specific tone,
- **9 Routing Strategies** — Global strategies that determine how requests are distributed
- **Wildcard Router**`provider/*` patterns route dynamically to any provider
- **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard
- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite
- **Provider Toggle** — Enable/disable all connections for a provider with one click
- **Blocked Providers** — Exclude specific providers from `/v1/models` listing
+1 -1
View File
@@ -73,7 +73,7 @@ Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/limits` — quota/rate controls
+2
View File
@@ -221,6 +221,8 @@ Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5
## 🎨 Combos
You can reorder combo cards directly in **Dashboard → Combos** by dragging the handle on each card. The order is stored in SQLite and restored on reload.
### Example 1: Maximize Subscription → Cheap Backup
```
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.5.6
version: 3.6.1
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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.5.7",
"version": "3.6.1",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
+2 -2
View File
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.5.6
**Current version:** 3.6.0
## 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.5.6)
## Key Features (v3.6.0)
### Core Proxy
- **60+ AI providers** with automatic format translation
+2 -2
View File
@@ -250,8 +250,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
tokenUrl: "https://auth.openai.com/oauth/token",
},
models: [
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
+13 -3
View File
@@ -2017,21 +2017,31 @@ export async function handleChatCore({
try {
responseBody = rawBody ? JSON.parse(rawBody) : {};
} catch {
const isHtmlResponse =
rawBody && typeof rawBody === "string" && /^\s*(?:\s|<!|<[a-zA-Z]|<\?xml)/.test(rawBody);
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
const invalidJsonMessage = "Invalid JSON response from provider";
const invalidJsonMessage = isHtmlResponse
? "Provider returned HTML error page instead of JSON - check credentials and endpoint"
: "Invalid JSON response from provider";
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
error: invalidJsonMessage,
providerRequest: finalBody || translatedBody,
providerResponse: normalizedProviderPayload,
providerResponse: isHtmlResponse
? { _raw: rawBody?.slice(0, 500) }
: normalizedProviderPayload,
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage),
});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
persistFailureUsage(
HTTP_STATUS.BAD_GATEWAY,
isHtmlResponse ? "html_error_response" : "invalid_json_payload"
);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.5.7",
"version": "3.6.1",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
@@ -425,6 +425,31 @@ function flushEvents(state) {
return events;
}
function normalizeUpstreamFailure(data, fallbackType = "server_error") {
const response = data?.response && typeof data.response === "object" ? data.response : null;
const error =
response?.error && typeof response.error === "object"
? response.error
: data?.error && typeof data.error === "object"
? data.error
: null;
const code = typeof error?.code === "string" ? error.code : "";
const message =
typeof error?.message === "string"
? error.message
: typeof data?.message === "string"
? data.message
: "Upstream failure";
return {
status: code === "rate_limit_exceeded" ? 429 : 502,
type: code === "rate_limit_exceeded" ? "rate_limit_error" : fallbackType,
code: code || (fallbackType === "rate_limit_error" ? "rate_limit_exceeded" : "bad_gateway"),
message,
};
}
/**
* Translate OpenAI Responses API chunk to OpenAI Chat Completions format
* This is for when Codex returns data and we need to send it to an OpenAI-compatible client
@@ -456,6 +481,19 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
const eventType = chunk.type || chunk.event;
const data = chunk.data || chunk;
if (!state.model) {
const upstreamModel =
(data?.response && typeof data.response === "object" && data.response.model) ||
data?.model ||
data?.modelVersion ||
data?.model_version ||
null;
if (typeof upstreamModel === "string" && upstreamModel.trim().length > 0) {
state.model = upstreamModel.trim();
}
}
// Initialize state
if (!state.started) {
state.started = true;
@@ -716,6 +754,12 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
return null;
}
if (eventType === "response.failed" || eventType === "error") {
state.upstreamError = normalizeUpstreamFailure(data);
state.finishReasonSent = true;
return null;
}
// Reasoning events — emit as reasoning_content in Chat format
if (eventType === "response.reasoning_summary_text.delta") {
const reasoningDelta = data.delta || "";
+41
View File
@@ -26,6 +26,7 @@ import {
sanitizeStreamingChunk,
extractThinkingFromContent,
} from "../handlers/responseSanitizer.ts";
import { buildErrorBody } from "./error.ts";
export { COLORS, formatSSE };
@@ -67,6 +68,12 @@ type TranslateState = ReturnType<typeof initState> & {
finishReason?: unknown;
/** Accumulated message content for call log response body */
accumulatedContent?: string;
upstreamError?: {
status: number;
type: string;
code: string;
message: string;
} | null;
};
type ToolCall = {
@@ -468,6 +475,10 @@ export function createSSEStream(options: StreamOptions = {}) {
// Translate mode
if (!trimmed) continue;
if (state?.upstreamError) {
continue;
}
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
providerPayloadCollector.push(parsed);
@@ -795,6 +806,36 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
if (state?.upstreamError) {
const err = state.upstreamError;
trackPendingRequest(model, provider, connectionId, false);
const errorBody = buildErrorBody(err.status, err.message);
if (onComplete) {
try {
onComplete({
status: err.status,
usage: state?.usage,
responseBody: errorBody,
providerPayload: providerPayloadCollector.build(
buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
includeEvents: false,
}),
});
} catch {}
}
controller.error(new Error(err.message || "Upstream failure"));
return;
}
// Flush remaining events (only once at stream end)
const flushed = translateResponse(targetFormat, sourceFormat, null, state);
+7 -16
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.5.7",
"version": "3.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.5.7",
"version": "3.6.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -17,6 +17,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
@@ -7782,7 +7783,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
@@ -7831,10 +7831,9 @@
}
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"dev": true,
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -8575,7 +8574,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -9627,7 +9625,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -9959,7 +9956,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -11134,7 +11130,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -11151,7 +11146,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -11161,7 +11155,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -11605,7 +11598,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -16671,7 +16663,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -20989,7 +20980,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.5.6"
"version": "3.6.1"
}
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.5.7",
"version": "3.6.1",
"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": {
@@ -93,6 +93,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
+30 -4
View File
@@ -3,6 +3,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
/**
* This repository contains a legacy `app/` snapshot (packaging/runtime artifacts)
@@ -24,6 +25,27 @@ async function exists(targetPath) {
}
}
export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
try {
await fsImpl.rename(sourcePath, destinationPath);
} catch (error) {
if (error?.code !== "EXDEV") {
throw error;
}
console.warn(
`[build-next-isolated] EXDEV while moving ${sourcePath} -> ${destinationPath}; falling back to copy/remove`
);
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
preserveTimestamps: true,
force: false,
errorOnExist: true,
});
await fsImpl.rm(sourcePath, { recursive: true, force: true });
}
}
function runNextBuild() {
return new Promise((resolve) => {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
@@ -52,12 +74,12 @@ function runNextBuild() {
});
}
async function main() {
export async function main() {
let moved = false;
try {
if (await exists(legacyAppDir)) {
await fs.rename(legacyAppDir, backupDir);
await movePath(legacyAppDir, backupDir);
moved = true;
}
@@ -86,7 +108,7 @@ async function main() {
} finally {
if (moved) {
try {
await fs.rename(backupDir, legacyAppDir);
await movePath(backupDir, legacyAppDir);
} catch (restoreError) {
console.error(
`[build-next-isolated] Failed to restore legacy app dir from ${backupDir}:`,
@@ -98,4 +120,8 @@ async function main() {
}
}
await main();
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entryScript === import.meta.url) {
await main();
}
+10 -3
View File
@@ -128,10 +128,17 @@ async function fixBetterSqliteBinary() {
try {
const { execSync } = await import("node:child_process");
execSync("npm rebuild better-sqlite3", {
// On Android/Termux, rebuild from source with --build-from-source flag
const isAndroid = process.platform === "android";
const rebuildCmd = isAndroid
? "npm install better-sqlite3 --build-from-source --force"
: "npm rebuild better-sqlite3";
execSync(rebuildCmd, {
cwd: join(ROOT, "app"),
stdio: "inherit",
timeout: 120_000,
timeout: 300_000, // 5 minutes for source builds
});
process.dlopen({ exports: {} }, appBinary);
@@ -140,7 +147,7 @@ async function fixBetterSqliteBinary() {
} catch (err) {
const isTimeout = err.killed || err.signal === "SIGTERM";
if (isTimeout) {
console.warn(" ⚠️ npm rebuild timed out after 120s.");
console.warn(" ⚠️ npm rebuild timed out after 300s.");
} else {
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
}
+11 -3
View File
@@ -41,6 +41,17 @@ if (existsSync(APP_DIR)) {
console.log(" 📦 Installing dependencies...");
execSync("npm install", { cwd: ROOT, stdio: "inherit" });
// ── Step 2.5: Remove app/ directory before build ───────────
// CRITICAL: The postinstall script may create app/node_modules/@swc/helpers/,
// which causes Next.js 16 to interpret app/ as an App Router directory
// (competing with src/app/). This makes the build silently skip all real
// routes, producing a standalone with only _global-error and _not-found.
// We MUST remove app/ before running `next build`.
if (existsSync(APP_DIR)) {
console.log(" 🧹 Removing app/ created by postinstall (App Router conflict fix)...");
rmSync(APP_DIR, { recursive: true, force: true });
}
// ── Step 3: Build Next.js ──────────────────────────────────
console.log(" 🏗️ Building Next.js (standalone)...");
execSync("npx next build", {
@@ -48,9 +59,6 @@ execSync("npx next build", {
stdio: "inherit",
env: {
...process.env,
// Force webpack codegen — Turbopack emits hashed require() calls for
// server external packages that break npm global installs (#394, #396, #398).
EXPERIMENTAL_TURBOPACK: "0",
NEXT_PRIVATE_BUILD_WORKER: "0",
},
});
+122 -45
View File
@@ -2,7 +2,7 @@
/**
* OmniRoute Environment Sync
*
* Ensures .env exists and contains all keys from .env.example.
* Ensures .env exists and contains the selected keys from .env.example.
* Runs on installs and can be executed manually via `npm run env:sync`.
*
* Rules:
@@ -31,26 +31,111 @@ export function parseEnvFile(filePath) {
const entries = new Map();
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const parsed = parseEnvEntry(line);
if (!parsed) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex < 1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
const [key, value] = parsed;
entries.set(key, value);
}
return entries;
}
function parseEnvEntry(line) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return null;
const eqIndex = trimmed.indexOf("=");
if (eqIndex < 1) return null;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
return [key, value];
}
function parseExampleEntries(content, scope = "full") {
const entries = new Map();
const lines = content.split(/\r?\n/);
if (scope === "oauth") {
let inOauthSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (/OAUTH PROVIDER CREDENTIALS/i.test(trimmed)) {
inOauthSection = true;
continue;
}
if (!inOauthSection) continue;
if (/Provider User-Agent Overrides/i.test(trimmed)) break;
const parsed = parseEnvEntry(line);
if (!parsed) continue;
const [key, value] = parsed;
entries.set(key, value);
}
return entries;
}
for (const line of lines) {
const parsed = parseEnvEntry(line);
if (!parsed) continue;
const [key, value] = parsed;
entries.set(key, value);
}
return entries;
}
export function getEnvSyncPlan({ rootDir, scope = "full" } = {}) {
const root = rootDir || dirname(dirname(fileURLToPath(import.meta.url)));
const envExamplePath = join(root, ".env.example");
const envPath = join(root, ".env");
if (!existsSync(envExamplePath)) {
return {
available: false,
created: false,
added: 0,
missingEntries: [],
};
}
const exampleEntries = parseExampleEntries(readFileSync(envExamplePath, "utf8"), scope);
const currentEntries = parseEnvFile(envPath);
const missingEntries = [];
for (const [key, defaultValue] of exampleEntries) {
if (currentEntries.has(key)) continue;
if (CRYPTO_SECRETS[key] && !defaultValue) {
missingEntries.push({ key, value: CRYPTO_SECRETS[key](), generated: true });
continue;
}
missingEntries.push({ key, value: defaultValue, generated: false });
}
return {
available: true,
created: !existsSync(envPath),
added: missingEntries.length,
missingEntries,
};
}
function replaceBlankSecret(content, key, value) {
const pattern = new RegExp(`^${key}=\\s*$`, "m");
return pattern.test(content) ? content.replace(pattern, `${key}=${value}`) : content;
}
export function syncEnv({ rootDir, quiet = false } = {}) {
export function syncEnv({ rootDir, quiet = false, scope = "full" } = {}) {
const log = quiet ? () => {} : (message) => process.stderr.write(`[sync-env] ${message}\n`);
const root = rootDir || dirname(dirname(fileURLToPath(import.meta.url)));
const envExamplePath = join(root, ".env.example");
@@ -61,50 +146,42 @@ export function syncEnv({ rootDir, quiet = false } = {}) {
return { created: false, added: 0 };
}
const exampleEntries = parseEnvFile(envExamplePath);
const exampleEntries = parseExampleEntries(readFileSync(envExamplePath, "utf8"), scope);
if (!existsSync(envPath)) {
copyFileSync(envExamplePath, envPath);
if (scope === "full") {
copyFileSync(envExamplePath, envPath);
let content = readFileSync(envPath, "utf8");
let generated = 0;
for (const [key, generator] of Object.entries(CRYPTO_SECRETS)) {
const nextContent = replaceBlankSecret(content, key, generator());
if (nextContent !== content) {
content = nextContent;
generated++;
log(`${key} auto-generated`);
let content = readFileSync(envPath, "utf8");
let generated = 0;
for (const [key, generator] of Object.entries(CRYPTO_SECRETS)) {
const nextContent = replaceBlankSecret(content, key, generator());
if (nextContent !== content) {
content = nextContent;
generated++;
log(`${key} auto-generated`);
}
}
writeFileSync(envPath, content, "utf8");
log(
`✨ Created .env from .env.example (${exampleEntries.size} keys, ${generated} secrets generated)`
);
return { created: true, added: exampleEntries.size };
}
const { missingEntries } = getEnvSyncPlan({ rootDir: root, scope });
const content = [
"# ── Auto-added by sync-env (oauth defaults) ──",
...missingEntries.map((entry) => `${entry.key}=${entry.value}`),
"",
].join("\n");
writeFileSync(envPath, content, "utf8");
log(
`✨ Created .env from .env.example (${exampleEntries.size} keys, ${generated} secrets generated)`
);
return { created: true, added: exampleEntries.size };
log(`✨ Created .env with oauth defaults (${missingEntries.length} keys)`);
return { created: true, added: missingEntries.length };
}
const currentEntries = parseEnvFile(envPath);
const missingEntries = [];
for (const [key, defaultValue] of exampleEntries) {
if (currentEntries.has(key)) continue;
if (CRYPTO_SECRETS[key] && !defaultValue) {
missingEntries.push({
key,
value: CRYPTO_SECRETS[key](),
generated: true,
});
continue;
}
missingEntries.push({
key,
value: defaultValue,
generated: false,
});
}
const { missingEntries } = getEnvSyncPlan({ rootDir: root, scope });
if (missingEntries.length === 0) {
log("✅ .env is up to date (0 keys added)");
@@ -133,5 +210,5 @@ export function syncEnv({ rootDir, quiet = false } = {}) {
}
if (process.argv[1]?.endsWith("sync-env.mjs")) {
syncEnv();
syncEnv({ scope: process.argv.includes("--oauth-only") ? "oauth" : "full" });
}
@@ -72,33 +72,43 @@ export default function DiversityScoreCard() {
riskLabel = "Moderate Distribution";
}
const providerEntries = Object.entries(data.providers || {})
.sort(([, a], [, b]) => b.share - a.share)
.slice(0, 4);
return (
<Card className="p-5 flex flex-col gap-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
</div>
<p className="text-sm text-text-muted mt-1">
Provider concentration snapshot for the recent traffic window.
</p>
<Card className="p-5">
{/* Header */}
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
<span className="text-xs text-text-muted hidden sm:inline">
Provider concentration snapshot for the recent traffic window.
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted hidden md:inline">
Window: {data.windowSize} reqs · Last {Math.round(data.ttlMs / 60000)} mins
</span>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${
scorePercentage < 40
? "bg-red-500/10 text-red-500"
: scorePercentage < 70
? "bg-amber-500/10 text-amber-500"
: "bg-green-500/10 text-green-500"
}`}
>
Shannon entropy
</span>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${
scorePercentage < 40
? "bg-red-500/10 text-red-500"
: scorePercentage < 70
? "bg-amber-500/10 text-amber-500"
: "bg-green-500/10 text-green-500"
}`}
>
Shannon entropy
</span>
</div>
<div className="grid gap-4 sm:grid-cols-[112px_minmax(0,1fr)] sm:items-center">
<div className="relative mx-auto h-28 w-28">
{/* Horizontal layout: gauge + risk label + provider bars */}
<div className="flex flex-col sm:flex-row items-center gap-5">
{/* Gauge */}
<div className="relative shrink-0 h-20 w-20">
<svg className="h-full w-full -rotate-90" viewBox="0 0 36 36">
<path
className="text-border/70"
@@ -118,58 +128,54 @@ export default function DiversityScoreCard() {
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-semibold tabular-nums ${riskColor}`}>
<span className={`text-lg font-semibold tabular-nums ${riskColor}`}>
{scorePercentage}%
</span>
<span className="text-[11px] uppercase tracking-[0.18em] text-text-muted">score</span>
<span className="text-[9px] uppercase tracking-[0.15em] text-text-muted">score</span>
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-border/30 bg-surface/20 px-4 py-3">
<div className={`text-sm font-medium ${riskColor}`}>{riskLabel}</div>
<div className="text-xs text-text-muted mt-1">
Higher values indicate traffic is spread across multiple providers instead of
clustering on one vendor.
</div>
{/* Risk label */}
<div className="shrink-0 text-center sm:text-left">
<div className={`text-sm font-medium ${riskColor}`}>{riskLabel}</div>
<div className="text-xs text-text-muted mt-0.5 max-w-[200px]">
Higher values mean traffic is spread across multiple providers.
</div>
</div>
{Object.keys(data.providers || {}).length === 0 ? (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-5 text-sm text-text-muted">
{/* Divider */}
<div className="hidden sm:block w-px h-14 bg-border/30 shrink-0" />
{/* Provider bars */}
<div className="flex-1 min-w-0 w-full">
{providerEntries.length === 0 ? (
<div className="text-sm text-text-muted text-center py-2">
No recent usage data available.
</div>
) : (
<div className="space-y-3">
{Object.entries(data.providers)
.sort(([, a], [, b]) => b.share - a.share)
.slice(0, 4)
.map(([provider, stat]) => (
<div key={provider} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-text-main capitalize">{provider}</span>
<span className="tabular-nums text-text-muted">
{Math.round(stat.share * 100)}%
</span>
</div>
<div className="h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className={`h-full rounded-full ${gaugeColor}`}
style={{ width: `${Math.round(stat.share * 100)}%` }}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{providerEntries.map(([provider, stat]) => (
<div key={provider} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-text-main capitalize truncate">
{provider}
</span>
<span className="tabular-nums text-text-muted shrink-0">
{Math.round(stat.share * 100)}%
</span>
</div>
))}
<div className="h-1.5 rounded-full bg-surface/50 overflow-hidden">
<div
className={`h-full rounded-full ${gaugeColor}`}
style={{ width: `${Math.round(stat.share * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-border/30 pt-4 text-xs text-text-muted">
<div className="rounded-lg bg-surface/20 px-3 py-2">Window: {data.windowSize} reqs</div>
<div className="rounded-lg bg-surface/20 px-3 py-2 text-right">
Based on last {Math.round(data.ttlMs / 60000)} mins
</div>
</div>
</Card>
);
}
@@ -45,12 +45,12 @@ export default function AnalyticsPage() {
/>
{activeTab === "overview" && (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px] xl:items-start">
<>
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
<DiversityScoreCard />
</div>
</>
)}
{activeTab === "evals" && <EvalsTab />}
{activeTab === "search" && <SearchAnalyticsTab />}
+151 -29
View File
@@ -331,6 +331,13 @@ function getI18nOrFallback(t, key, fallback) {
return fallback;
}
function moveArrayItem(items, fromIndex, toIndex) {
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
function getStrategyGuideText(t, strategy, field) {
const strategyFallback =
STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority;
@@ -388,6 +395,9 @@ export default function CombosPage() {
const [providerNodes, setProviderNodes] = useState([]);
const [showUsageGuide, setShowUsageGuide] = useState(true);
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [comboDragIndex, setComboDragIndex] = useState(null);
const [comboDragOverIndex, setComboDragOverIndex] = useState(null);
const [savingComboOrder, setSavingComboOrder] = useState(false);
useEffect(() => {
fetchData();
@@ -560,6 +570,75 @@ export default function CombosPage() {
} catch {}
};
const resetComboDragState = () => {
setComboDragIndex(null);
setComboDragOverIndex(null);
};
const handleComboDragStart = (e, index) => {
if (savingComboOrder || combos.length < 2) {
e.preventDefault();
return;
}
setComboDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", combos[index]?.id || `${index}`);
if (e.currentTarget instanceof HTMLElement) {
setTimeout(() => {
e.currentTarget.style.opacity = "0.5";
}, 0);
}
};
const handleComboDragEnd = (e) => {
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "1";
}
resetComboDragState();
};
const handleComboDragOver = (e, index) => {
e.preventDefault();
if (comboDragIndex === null || comboDragIndex === index) return;
e.dataTransfer.dropEffect = "move";
setComboDragOverIndex(index);
};
const handleComboDrop = async (e, dropIndex) => {
e.preventDefault();
const fromIndex = comboDragIndex;
resetComboDragState();
if (fromIndex === null || fromIndex === dropIndex) return;
const previousCombos = combos;
const nextCombos = moveArrayItem(combos, fromIndex, dropIndex);
setCombos(nextCombos);
setSavingComboOrder(true);
try {
const res = await fetch("/api/combos/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboIds: nextCombos.map((combo) => combo.id) }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || data.error || "Failed to reorder combos");
}
if (Array.isArray(data.combos)) {
setCombos(data.combos);
}
} catch {
setCombos(previousCombos);
notify.error(getI18nOrFallback(t, "failedReorder", "Failed to save combo order"));
} finally {
setSavingComboOrder(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
@@ -655,23 +734,34 @@ export default function CombosPage() {
/>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
{combos.map((combo, index) => (
<div
key={combo.id}
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
/>
data-testid={`combo-card-${combo.id}`}
onDragOver={(e) => handleComboDragOver(e, index)}
onDrop={(e) => handleComboDrop(e, index)}
>
<ComboCard
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
dragDisabled={savingComboOrder || combos.length < 2}
isDragged={comboDragIndex === index}
isDropTarget={comboDragOverIndex === index && comboDragIndex !== index}
onDragStart={(e) => handleComboDragStart(e, index)}
onDragEnd={handleComboDragEnd}
/>
</div>
))}
</div>
)}
@@ -977,6 +1067,11 @@ function ComboCard({
hasProxy,
onToggle,
providerNodes,
dragDisabled,
isDragged,
isDropTarget,
onDragStart,
onDragEnd,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
@@ -997,9 +1092,33 @@ function ComboCard({
};
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<Card
padding="sm"
className={`group transition-all ${
isDisabled ? "opacity-50" : ""
} ${isDropTarget ? "border border-primary/30 bg-primary/5" : ""} ${
isDragged ? "opacity-60" : ""
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
type="button"
draggable={!dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
data-testid={`combo-drag-handle-${combo.id}`}
className={`p-1 rounded-md transition-colors shrink-0 ${
dragDisabled
? "cursor-not-allowed text-text-muted/40"
: "cursor-grab active:cursor-grabbing text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
title={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
aria-label={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
{/* Icon */}
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
@@ -1222,17 +1341,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
agentContextCache: boolean;
};
const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
});
const getEmptyCreateDraftSnapshot = useCallback(
(): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
}),
[]
);
const t = useTranslations("combos");
const tc = useTranslations("common");
@@ -1486,7 +1608,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return () => {
cancelled = true;
};
}, [combo, isOpen, resetFormForCombo]);
}, [combo, getEmptyCreateDraftSnapshot, isOpen, resetFormForCombo]);
useEffect(() => {
if (!strategyChangeMountedRef.current) {
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { ALIAS_TO_ID } from "@/shared/constants/providers";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
@@ -254,7 +255,7 @@ export default function PlaygroundPage() {
for (const conn of data?.connections || []) {
conns.push({
id: conn.id,
name: conn.name || conn.email || conn.id,
name: pickMaskedDisplayValue([conn.name, conn.email], conn.id),
provider: conn.provider,
authType: conn.authType || "apiKey",
});
@@ -43,7 +43,7 @@ import {
type ModelCompatProtocolKey,
} from "@/shared/constants/modelCompat";
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
import { maskEmail } from "@/shared/utils/maskEmail";
import { maskEmail, pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
type CompatByProtocolMap = Partial<
Record<
@@ -62,6 +62,7 @@ type ModelCompatSavePatch = {
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
isHidden?: boolean;
};
type CompatModelRow = {
@@ -72,6 +73,7 @@ type CompatModelRow = {
supportedEndpoints?: string[];
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
isHidden?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
};
@@ -92,6 +94,42 @@ function getProtoSlice(
return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol];
}
function isModelHidden(
modelId: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
if (c && Object.prototype.hasOwnProperty.call(c, "isHidden")) {
return Boolean(c.isHidden);
}
const o = overrideMap.get(modelId);
if (o && Object.prototype.hasOwnProperty.call(o, "isHidden")) {
return Boolean(o.isHidden);
}
return false;
}
function providerText(
t: ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
},
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
if (typeof t.has === "function" && t.has(key)) {
return t(key, values);
}
if (values) {
return Object.entries(values).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return fallback;
}
function effectiveNormalizeForProtocol(
modelId: string,
protocol: string,
@@ -295,6 +333,7 @@ interface ModelRowProps {
interface PassthroughModelRowProps {
modelId: string;
fullModel: string;
isHidden?: boolean;
copied?: string;
onCopy: (text: string, key: string) => void;
onDeleteAlias: () => void;
@@ -305,6 +344,8 @@ interface PassthroughModelRowProps {
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
}
interface PassthroughModelsSectionProps {
@@ -327,6 +368,11 @@ interface PassthroughModelsSectionProps {
}
) => Promise<void>;
compatSavingModelId?: string;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
}
interface CustomModelsSectionProps {
@@ -366,10 +412,16 @@ interface CompatibleModelsSectionProps {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
isHidden?: boolean;
}
) => Promise<void>;
compatSavingModelId?: string;
onModelsChanged?: () => void;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
}
interface CooldownTimerProps {
@@ -860,6 +912,9 @@ export default function ProviderDetailPage() {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [modelFilter, setModelFilter] = useState("");
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>(
null
);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
@@ -1869,6 +1924,11 @@ export default function ProviderDetailPage() {
protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]
) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap);
const effectiveModelHidden = useCallback(
(modelId: string) => isModelHidden(modelId, customMap, overrideMap),
[customMap, overrideMap]
);
const getUpstreamHeadersRecordForModel = useCallback(
(modelId: string, protocol: string) =>
effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap),
@@ -1945,14 +2005,21 @@ export default function ProviderDetailPage() {
}
};
const handleToggleModelHidden = async (modelId: string, hidden: boolean): Promise<void> => {
const handleToggleModelHidden = async (
providerKey: string,
modelId: string,
hidden: boolean
): Promise<void> => {
setTogglingModelId(modelId);
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}&modelId=${encodeURIComponent(modelId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden }),
});
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden }),
}
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
@@ -1967,6 +2034,32 @@ export default function ProviderDetailPage() {
}
};
const handleBulkToggleModelHidden = async (
providerKey: string,
modelIds: string[],
hidden: boolean
): Promise<void> => {
if (modelIds.length === 0) return;
setBulkVisibilityAction(hidden ? "deselect" : "select");
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden, modelIds }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await fetchProviderModelMeta().catch(() => {});
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
setBulkVisibilityAction(null);
}
};
const renderModelsSection = () => {
const autoSyncToggle = compatibleSupportsModelImport && canImportModels && (
<button
@@ -2046,6 +2139,15 @@ export default function ProviderDetailPage() {
compatSavingModelId={compatSavingModelId}
onModelsChanged={fetchProviderModelMeta}
allowImport={compatibleSupportsModelImport}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
/>
</div>
);
@@ -2083,6 +2185,15 @@ export default function ProviderDetailPage() {
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
/>
</div>
);
@@ -2115,31 +2226,43 @@ export default function ProviderDetailPage() {
</div>
);
}
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
}));
const filteredModels = modelFilter
? models.filter((m) => m.id.toLowerCase().includes(modelFilter.toLowerCase()))
: models;
const activeCount = models.filter((m) => !m.isHidden).length;
? modelsWithVisibility.filter((m) => m.id.toLowerCase().includes(modelFilter.toLowerCase()))
: modelsWithVisibility;
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
return (
<div>
{importButton}
{models.length > 0 && (
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1 max-w-sm">
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted pointer-events-none">
search
</span>
<input
type="text"
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
placeholder={t("filterModels") || "Filter models…"}
className="w-full pl-7 pr-3 py-1.5 text-xs rounded-lg border border-border bg-sidebar/50 focus:outline-none focus:ring-1 focus:ring-primary text-text-main placeholder:text-text-muted"
/>
</div>
<span className="text-xs text-text-muted whitespace-nowrap">
{activeCount}/{models.length} {t("modelsActive") || "active"}
</span>
</div>
{modelsWithVisibility.length > 0 && (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={modelsWithVisibility.length}
onSelectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
false
)
}
onDeselectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null}
deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null}
/>
)}
<div className="flex flex-wrap gap-3">
{filteredModels.map((model) => {
@@ -2157,14 +2280,18 @@ export default function ProviderDetailPage() {
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === model.id}
onToggleHidden={handleToggleModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerId, modelId, hidden)
}
togglingHidden={togglingModelId === model.id}
/>
);
})}
{filteredModels.length === 0 && modelFilter && (
<p className="text-sm text-text-muted py-2">
{t("noModelsMatch") || `No models match "${modelFilter}"`}
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
@@ -2493,7 +2620,7 @@ export default function ProviderDetailPage() {
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
label: pickMaskedDisplayValue([conn.name, conn.email], conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
@@ -2602,7 +2729,7 @@ export default function ProviderDetailPage() {
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
label: pickMaskedDisplayValue([conn.name, conn.email], conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
@@ -2981,7 +3108,11 @@ function ModelRow({
onClick={() => onToggleHidden(model.id, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
title={isHidden ? (t("showModel") || "Show model") : (t("hideModel") || "Hide model")}
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
@@ -3020,6 +3151,71 @@ ModelRow.propTypes = {
compatDisabled: PropTypes.bool,
};
function ModelVisibilityToolbar({
t,
filterValue,
onFilterChange,
activeCount,
totalCount,
onSelectAll,
onDeselectAll,
selectAllDisabled,
deselectAllDisabled,
}: {
t: ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
filterValue: string;
onFilterChange: (value: string) => void;
activeCount: number;
totalCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
selectAllDisabled?: boolean;
deselectAllDisabled?: boolean;
}) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
search
</span>
<input
type="text"
value={filterValue}
onChange={(e) => onFilterChange(e.target.value)}
placeholder={providerText(t, "filterModels", "Filter models…")}
className="w-full rounded-lg border border-border bg-sidebar/50 py-1.5 pl-7 pr-3 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
onClick={onSelectAll}
disabled={selectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "selectAllModels", "Select all")}
>
<span className="material-symbols-outlined text-[16px]">done_all</span>
<span>{providerText(t, "selectAllModels", "Select all")}</span>
</button>
<button
onClick={onDeselectAll}
disabled={deselectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "deselectAllModels", "Deselect all")}
>
<span className="material-symbols-outlined text-[16px]">remove_done</span>
<span>{providerText(t, "deselectAllModels", "Deselect all")}</span>
</button>
<span className="whitespace-nowrap text-xs text-text-muted">
{providerText(t, "modelsActiveCount", "{active}/{total} active", {
active: activeCount,
total: totalCount,
})}
</span>
</div>
);
}
function PassthroughModelsSection({
providerAlias,
modelAliases,
@@ -3033,9 +3229,15 @@ function PassthroughModelsSection({
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatSavingModelId,
isModelHidden,
onToggleHidden,
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
}: PassthroughModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerAlias}/`)
@@ -3044,12 +3246,20 @@ function PassthroughModelsSection({
const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => {
const fmStr = fullModel as string;
const prefix = `${providerAlias}/`;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
return {
modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr,
modelId,
fullModel,
alias,
isHidden: isModelHidden(modelId),
};
});
const filteredModels = modelFilter
? allModels.filter(({ modelId }) => modelId.toLowerCase().includes(modelFilter.toLowerCase()))
: allModels;
const activeCount = allModels.filter((model) => !model.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
// Generate default alias from modelId (last part after /)
const generateDefaultAlias = (modelId) => {
@@ -3107,11 +3317,33 @@ function PassthroughModelsSection({
{/* Models list */}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
false
)
}
onDeselectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending}
deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending}
/>
{filteredModels.map(({ modelId, fullModel, alias, isHidden }) => (
<PassthroughModelRow
key={fullModel as string}
modelId={modelId}
fullModel={fullModel}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
@@ -3122,8 +3354,17 @@ function PassthroughModelsSection({
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
onToggleHidden={onToggleHidden}
togglingHidden={togglingModelId === modelId}
/>
))}
{filteredModels.length === 0 && modelFilter && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
@@ -3143,11 +3384,17 @@ PassthroughModelsSection.propTypes = {
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,
isHidden,
copied,
onCopy,
onDeleteAlias,
@@ -3158,11 +3405,20 @@ function PassthroughModelRow({
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatDisabled,
onToggleHidden,
togglingHidden,
}: PassthroughModelRowProps) {
return (
<div className="flex gap-0 rounded-lg border border-border p-3 hover:bg-sidebar/50">
<div
className={`flex gap-0 rounded-lg border border-border p-3 transition-opacity hover:bg-sidebar/50 ${
isHidden ? "opacity-50" : ""
}`}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
<span
className="material-symbols-outlined shrink-0 text-base text-text-muted"
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
>
smart_toy
</span>
<div className="min-w-0 flex-1">
@@ -3184,6 +3440,22 @@ function PassthroughModelRow({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 self-start">
{onToggleHidden && (
<button
onClick={() => onToggleHidden(modelId, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
@@ -3210,6 +3482,7 @@ function PassthroughModelRow({
PassthroughModelRow.propTypes = {
modelId: PropTypes.string.isRequired,
fullModel: PropTypes.string.isRequired,
isHidden: PropTypes.bool,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
@@ -3220,6 +3493,8 @@ PassthroughModelRow.propTypes = {
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatDisabled: PropTypes.bool,
onToggleHidden: PropTypes.func,
togglingHidden: PropTypes.bool,
};
// ============ Custom Models Section (for ALL providers) ============
@@ -3721,10 +3996,16 @@ function CompatibleModelsSection({
compatSavingModelId,
onModelsChanged,
allowImport,
isModelHidden,
onToggleHidden,
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
}: CompatibleModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const notify = useNotificationStore();
const providerAliases = useMemo(
@@ -3739,21 +4020,29 @@ function CompatibleModelsSection({
const rows = providerAliases.map(([alias, fullModel]: [string, any]) => {
const fmStr = fullModel as string;
const prefix = `${providerStorageAlias}/`;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
return {
modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr,
modelId,
alias,
isHidden: isModelHidden(modelId),
};
});
const seenModelIds = new Set(rows.map((row) => row.modelId));
for (const model of fallbackModels) {
if (!model?.id || seenModelIds.has(model.id)) continue;
rows.push({ modelId: model.id, alias: null });
rows.push({ modelId: model.id, alias: null, isHidden: isModelHidden(model.id) });
seenModelIds.add(model.id);
}
return rows;
}, [fallbackModels, providerAliases, providerStorageAlias]);
}, [fallbackModels, isModelHidden, providerAliases, providerStorageAlias]);
const filteredModels = modelFilter
? allModels.filter(({ modelId }) => modelId.toLowerCase().includes(modelFilter.toLowerCase()))
: allModels;
const activeCount = allModels.filter((model) => !model.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
const resolveAlias = useCallback(
(modelId: string, workingAliases: Record<string, string>) =>
@@ -3935,11 +4224,33 @@ function CompatibleModelsSection({
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, alias }) => (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
false
)
}
onDeselectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending}
deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending}
/>
{filteredModels.map(({ modelId, alias, isHidden }) => (
<PassthroughModelRow
key={`${providerStorageAlias}:${modelId}`}
modelId={modelId}
fullModel={`${providerDisplayAlias}/${modelId}`}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => handleDeleteModel(modelId, alias)}
@@ -3950,8 +4261,17 @@ function CompatibleModelsSection({
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
onToggleHidden={onToggleHidden}
togglingHidden={togglingModelId === modelId}
/>
))}
{filteredModels.length === 0 && modelFilter && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
@@ -3986,6 +4306,11 @@ CompatibleModelsSection.propTypes = {
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) {
@@ -4243,7 +4568,10 @@ function ConnectionRow({
}: ConnectionRowProps) {
const t = useTranslations("providers");
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || t("oauthAccount")
? pickMaskedDisplayValue(
[connection.name, connection.email, connection.displayName],
t("oauthAccount")
)
: connection.name;
const applyCodexAuthLabel =
typeof t.has === "function" && t.has("applyCodexAuthLocal")
@@ -4943,6 +5271,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
const [newExtraKey, setNewExtraKey] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const isBailian = connection?.provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
@@ -4976,6 +5305,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
setExtraApiKeys(Array.isArray(existing) ? existing : []);
setNewExtraKey("");
setShowAdvanced(!!existingCustomUserAgent);
setShowEmail(false);
setTestResult(null);
setValidationResult(null);
setSaveError(null);
@@ -5158,9 +5488,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
<p className="font-medium" title={connection.email}>
{maskEmail(connection.email)}
</p>
<div className="flex items-center gap-2">
<p className="font-medium" title={showEmail ? connection.email : undefined}>
{showEmail ? connection.email : maskEmail(connection.email)}
</p>
<button
type="button"
onClick={() => setShowEmail((current) => !current)}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={showEmail ? "Hide email" : "Show email"}
>
<span className="material-symbols-outlined text-sm">
{showEmail ? "visibility_off" : "visibility"}
</span>
</button>
</div>
</div>
)}
{isOAuth && (
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
import PropTypes from "prop-types";
@@ -117,6 +117,11 @@ export default function ProvidersPage() {
const [importingZed, setImportingZed] = useState(false);
const [showConfiguredOnly, setShowConfiguredOnly] = useState(false);
const [configuredOnlyPreferenceReady, setConfiguredOnlyPreferenceReady] = useState(false);
const [oauthEnvRepairStatus, setOauthEnvRepairStatus] = useState<{
available: boolean;
missingCount: number;
} | null>(null);
const [repairingEnv, setRepairingEnv] = useState(false);
const notify = useNotificationStore();
const t = useTranslations("providers");
const tc = useTranslations("common");
@@ -158,6 +163,27 @@ export default function ProvidersPage() {
writeConfiguredOnlyPreference(showConfiguredOnly);
}, [configuredOnlyPreferenceReady, showConfiguredOnly]);
const fetchOauthEnvRepairStatus = useCallback(async () => {
try {
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
const data = await res.json();
if (res.ok) {
setOauthEnvRepairStatus({
available: Boolean(data.available),
missingCount: Number(data.missingCount || 0),
});
} else {
setOauthEnvRepairStatus(null);
}
} catch {
setOauthEnvRepairStatus(null);
}
}, []);
useEffect(() => {
void fetchOauthEnvRepairStatus();
}, [fetchOauthEnvRepairStatus]);
const handleZedImport = async () => {
setImportingZed(true);
try {
@@ -185,6 +211,30 @@ export default function ProvidersPage() {
}
};
const handleRepairEnv = async () => {
if (!oauthEnvRepairStatus?.available || repairingEnv) return;
setRepairingEnv(true);
try {
const res = await fetch("/api/system/env/repair", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || t("repairEnvFailed"));
}
notify.success(
data.backupPath ? `${t("repairEnvSuccess")} (${data.backupPath})` : t("repairEnvSuccess")
);
await fetchOauthEnvRepairStatus();
} catch (error) {
notify.error(error instanceof Error ? error.message : t("repairEnvFailed"));
} finally {
setRepairingEnv(false);
}
};
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter((c) => {
if (c.provider !== providerId) return false;
@@ -450,6 +500,24 @@ export default function ProvidersPage() {
</span>
{importingZed ? "Importing..." : "Import from Zed"}
</button>
{oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && (
<button
onClick={handleRepairEnv}
disabled={repairingEnv}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
repairingEnv
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("repairEnvHint")}
aria-label={t("repairEnv")}
>
<span className="material-symbols-outlined text-[14px]">
{repairingEnv ? "sync" : "settings_backup_restore"}
</span>
{repairingEnv ? t("repairEnvWorking") : t("repairEnv")}
</button>
)}
<button
onClick={() => handleBatchTest("oauth")}
disabled={!!testingMode}
@@ -35,6 +35,10 @@ export default function SystemStorageTab() {
app: 7,
call: 7,
},
tableMaxRows: {
callLogs: 100000,
proxyLogs: 100000,
},
lastBackupAt: null,
});
@@ -191,7 +195,7 @@ export default function SystemStorageTab() {
});
return;
}
// Auto import JSON
const reader = new FileReader();
reader.onload = async (e) => {
@@ -372,8 +376,9 @@ export default function SystemStorageTab() {
<div>
<p className="text-sm font-medium text-text-main">Log retention policy</p>
<p className="text-xs text-text-muted">
Request logs follow <code>CALL_LOG_RETENTION_DAYS</code>. Application and audit logs
follow <code>APP_LOG_RETENTION_DAYS</code>.
Request logs retain up to <code>CALL_LOGS_TABLE_MAX_ROWS</code> rows (default:
100,000). Proxy logs retain up to <code>PROXY_LOGS_TABLE_MAX_ROWS</code> rows. Older
entries auto-deleted.
</p>
</div>
<div className="flex items-center gap-2">
@@ -383,6 +388,9 @@ export default function SystemStorageTab() {
<Badge variant="default" size="sm">
App {storageHealth.retentionDays.app}d
</Badge>
<Badge variant="default" size="sm">
{storageHealth.tableMaxRows?.callLogs?.toLocaleString() || "100K"} rows
</Badge>
</div>
</div>
</div>
@@ -15,6 +15,7 @@ import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
const LS_GROUP_BY = "omniroute:limits:groupBy";
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
@@ -545,7 +546,10 @@ export default function ProviderLimits() {
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || conn.displayName || conn.email || config.label}
{pickMaskedDisplayValue(
[conn.name, conn.displayName, conn.email],
config.label
)}
</div>
<div className="flex items-center gap-1.5 mt-1 min-h-5">
<span
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { reorderCombos, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { reorderCombosSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// POST /api/combos/reorder - Persist combo ordering
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(reorderCombosSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const combos = await reorderCombos(validation.data.comboIds);
await syncToCloudIfEnabled();
return NextResponse.json({ combos });
} catch (error) {
console.log("Error reordering combos:", error);
return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 });
}
}
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud:", error);
}
}
+94
View File
@@ -18,6 +18,21 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function normalizeRequestedModelIds(
searchParams: URLSearchParams,
body: Record<string, unknown>
): string[] {
const bodyModelIds = Array.isArray(body.modelIds)
? body.modelIds
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: [];
const singleModelId = searchParams.get("modelId") || searchParams.get("model");
const allModelIds = [...bodyModelIds, ...(singleModelId ? [singleModelId.trim()] : [])];
return Array.from(new Set(allModelIds)).filter(Boolean);
}
/**
* GET /api/provider-models?provider=<id>
* List custom models (all providers if no provider param)
@@ -226,6 +241,85 @@ export async function PUT(request) {
}
}
/**
* PATCH /api/provider-models?provider=<id>&modelId=<modelId>
* Body: { isHidden: boolean, modelIds?: string[] }
*/
export async function PATCH(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "validation_error" } },
{ status: 400 }
);
}
try {
if (!(await isAuthenticated(request))) {
return Response.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
const body =
rawBody && typeof rawBody === "object" && !Array.isArray(rawBody)
? (rawBody as Record<string, unknown>)
: {};
if (!provider) {
return Response.json(
{ error: { message: "provider query param is required", type: "validation_error" } },
{ status: 400 }
);
}
if (typeof body.isHidden !== "boolean") {
return Response.json(
{ error: { message: "isHidden boolean is required", type: "validation_error" } },
{ status: 400 }
);
}
const modelIds = normalizeRequestedModelIds(searchParams, body);
if (modelIds.length === 0) {
return Response.json(
{
error: {
message: "modelId query param or body.modelIds is required",
type: "validation_error",
},
},
{ status: 400 }
);
}
for (const modelId of modelIds) {
const updatedModel = await updateCustomModel(provider, modelId, { isHidden: body.isHidden });
if (!updatedModel) {
mergeModelCompatOverride(provider, modelId, { isHidden: body.isHidden });
}
}
return Response.json({
ok: true,
updated: modelIds.length,
models: await getCustomModels(provider),
modelCompatOverrides: getModelCompatOverrides(provider),
});
} catch (error) {
console.error("Error patching provider models:", error);
return Response.json(
{ error: { message: "Failed to update provider models", type: "server_error" } },
{ status: 500 }
);
}
}
/**
* DELETE /api/provider-models?provider=<id>&model=<modelId>
*/
+10 -1
View File
@@ -2,7 +2,12 @@ import { NextResponse } from "next/server";
import path from "path";
import fs from "fs";
import { resolveDataDir } from "@/lib/dataPaths";
import { getAppLogRetentionDays, getCallLogRetentionDays } from "@/lib/logEnv";
import {
getAppLogRetentionDays,
getCallLogRetentionDays,
getCallLogsTableMaxRows,
getProxyLogsTableMaxRows,
} from "@/lib/logEnv";
/**
* GET /api/storage/health Return database storage information.
@@ -61,6 +66,10 @@ export async function GET() {
app: getAppLogRetentionDays(),
call: getCallLogRetentionDays(),
},
tableMaxRows: {
callLogs: getCallLogsTableMaxRows(),
proxyLogs: getProxyLogsTableMaxRows(),
},
dataDir: dataDir.startsWith(homeDir) ? "~" + dataDir.slice(homeDir.length) : dataDir,
});
} catch (error) {
+84
View File
@@ -0,0 +1,84 @@
/**
* GET /api/system/env/repair Returns OAuth env repair status
* POST /api/system/env/repair Backups .env and adds missing OAuth defaults into .env
*
* Security: Requires admin authentication (same as other management routes).
* Safety: Only fills missing OAuth defaults from .env.example.
*/
import { copyFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
const SYNC_HELPER_PATH = join(process.cwd(), "scripts/sync-env.mjs");
async function loadSyncHelpers() {
return import(pathToFileURL(SYNC_HELPER_PATH).href);
}
function createEnvBackup() {
const envPath = join(process.cwd(), ".env");
if (!existsSync(envPath)) {
return null;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = join(process.cwd(), `.env.backup-${timestamp}`);
copyFileSync(envPath, backupPath);
return backupPath;
}
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { getEnvSyncPlan } = await loadSyncHelpers();
const plan = getEnvSyncPlan({ scope: "oauth" });
return NextResponse.json({
available: plan.available,
created: plan.created,
added: plan.added,
missingCount: plan.missingEntries.length,
missingKeys: plan.missingEntries.map((entry: { key: string }) => entry.key),
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error)?.message || "Failed to inspect env defaults" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { syncEnv, getEnvSyncPlan } = await loadSyncHelpers();
const backupPath = createEnvBackup();
const result = syncEnv({ scope: "oauth", quiet: true });
const plan = getEnvSyncPlan({ scope: "oauth" });
return NextResponse.json({
success: true,
backupPath,
created: result.created,
added: result.added,
missingCount: plan.missingEntries.length,
missingKeys: plan.missingEntries.map((entry: { key: string }) => entry.key),
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error)?.message || "Failed to repair env defaults" },
{ status: 500 }
);
}
}
+70 -3
View File
@@ -21,6 +21,23 @@
/** @type {Map<string, UnavailableEntry>} */
const unavailable = new Map();
/** @type {Map<string, { failureCount: number, lastFailureAt: number }>} */
const failureState = new Map();
const FAILURE_WINDOW_MS = 30 * 60 * 1000;
const PROBLEMATIC_STATUS_COOLDOWNS = {
429: 5 * 60 * 1000,
408: 60 * 1000,
500: 2 * 60 * 1000,
502: 2 * 60 * 1000,
503: 2 * 60 * 1000,
504: 2 * 60 * 1000,
};
const MIN_PROBLEMATIC_COOLDOWN_MS = 60 * 1000;
const MAX_PROBLEMATIC_COOLDOWN_MS = 30 * 60 * 1000;
/**
* Build a composite key for provider+model.
* @param {string} provider
@@ -62,15 +79,62 @@ export function isModelAvailable(provider, model) {
*/
export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) {
const key = makeKey(provider, model);
const now = Date.now();
const safeCooldownMs = Number.isFinite(cooldownMs) && cooldownMs > 0 ? cooldownMs : 60000;
const existing = unavailable.get(key);
const existingRemainingMs =
existing && Date.now() - existing.unavailableSince < existing.cooldownMs
? existing.cooldownMs - (Date.now() - existing.unavailableSince)
: 0;
const effectiveCooldownMs = Math.max(safeCooldownMs, existingRemainingMs);
unavailable.set(key, {
provider,
model,
unavailableSince: Date.now(),
cooldownMs,
unavailableSince: now,
cooldownMs: effectiveCooldownMs,
reason: reason || "unknown",
});
}
/**
* Marca provider/model como problemático com cooldown adaptativo.
* Mantém retrocompatibilidade: não altera o comportamento de setModelUnavailable,
* apenas oferece uma estratégia mais agressiva para falhas recorrentes.
*
* @param {string} provider
* @param {string} model
* @param {{ status?: number, baseCooldownMs?: number, reason?: string }} [options]
* @returns {{ cooldownMs: number, failureCount: number }}
*/
export function markModelAsProblematic(provider, model, options = {}) {
const key = makeKey(provider, model);
const now = Date.now();
const status = Number.isFinite(options.status) ? Number(options.status) : null;
const statusBaseCooldown =
status && Object.prototype.hasOwnProperty.call(PROBLEMATIC_STATUS_COOLDOWNS, status)
? PROBLEMATIC_STATUS_COOLDOWNS[status]
: 0;
const baseCooldownMs =
Number.isFinite(options.baseCooldownMs) && Number(options.baseCooldownMs) > 0
? Number(options.baseCooldownMs)
: 0;
const prev = failureState.get(key);
const withinFailureWindow = prev && now - prev.lastFailureAt <= FAILURE_WINDOW_MS;
const failureCount = withinFailureWindow ? prev.failureCount + 1 : 1;
failureState.set(key, { failureCount, lastFailureAt: now });
const cooldownBase = Math.max(baseCooldownMs, statusBaseCooldown, MIN_PROBLEMATIC_COOLDOWN_MS);
const cooldownMs = Math.min(
cooldownBase * Math.pow(2, Math.max(0, failureCount - 1)),
MAX_PROBLEMATIC_COOLDOWN_MS
);
setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model");
return { cooldownMs, failureCount };
}
/**
* Clear unavailability for a model (e.g. after manual reset).
*
@@ -79,7 +143,9 @@ export function setModelUnavailable(provider, model, cooldownMs = 60000, reason)
* @returns {boolean} true if entry existed and was removed
*/
export function clearModelUnavailability(provider, model) {
return unavailable.delete(makeKey(provider, model));
const key = makeKey(provider, model);
failureState.delete(key);
return unavailable.delete(key);
}
/**
@@ -125,4 +191,5 @@ export function getUnavailableCount() {
*/
export function resetAllAvailability() {
unavailable.clear();
failureState.clear();
}
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "جارٍ الإضافة...",
"importingModelsTitle": "استيراد النماذج",
"copyModel": "نموذج النسخ",
"filterModels": "تصفية النماذج…",
"modelsActive": "نشط",
"showModel": "إظهار النموذج",
"hideModel": "إخفاء النموذج",
"removeModel": "إزالة النموذج",
"rateLimitProtected": "محمي",
"rateLimitUnprotected": "غير محمية",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Добавяне...",
"importingModelsTitle": "Импортиране на модели",
"copyModel": "Копиране на модел",
"filterModels": "Филтриране на модели…",
"modelsActive": "активни",
"showModel": "Показване на модел",
"hideModel": "Скриване на модел",
"removeModel": "Премахване на модела",
"rateLimitProtected": "Защитен",
"rateLimitUnprotected": "Незащитен",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Přidávám...",
"importingModelsTitle": "Import modelů",
"copyModel": "Kopírovat model",
"filterModels": "Filtrovat modely…",
"modelsActive": "aktivní",
"showModel": "Zobrazit model",
"hideModel": "Skrýt model",
"removeModel": "Odebrat model",
"rateLimitProtected": "Chráněn",
"rateLimitUnprotected": "Nechráněn",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Tilføjer...",
"importingModelsTitle": "Import af modeller",
"copyModel": "Kopi model",
"filterModels": "Filtrer modeller…",
"modelsActive": "aktive",
"showModel": "Vis model",
"hideModel": "Skjul model",
"removeModel": "Fjern modellen",
"rateLimitProtected": "Beskyttet",
"rateLimitUnprotected": "Ubeskyttet",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Hinzufügen...",
"importingModelsTitle": "Modelle importieren",
"copyModel": "Modell kopieren",
"filterModels": "Modelle filtern…",
"modelsActive": "aktiv",
"showModel": "Modell anzeigen",
"hideModel": "Modell ausblenden",
"removeModel": "Modell entfernen",
"rateLimitProtected": "Geschützt",
"rateLimitUnprotected": "Ungeschützt",
+16 -2
View File
@@ -1636,6 +1636,10 @@
"close": "Close",
"importingModelsTitle": "Importing Models",
"copyModel": "Copy model",
"filterModels": "Filter models...",
"modelsActive": "Active",
"showModel": "Show model",
"hideModel": "Hide model",
"removeModel": "Remove model",
"rateLimitProtected": "Protected",
"rateLimitUnprotected": "Unprotected",
@@ -1670,6 +1674,11 @@
"configured": "configured",
"providerProxyConfigureHint": "Configure proxy for all connections of this provider",
"providerProxy": "Provider Proxy",
"repairEnv": "Repair env",
"repairEnvWorking": "Repairing...",
"repairEnvHint": "Restore missing OAuth defaults into .env without overwriting existing values.",
"repairEnvSuccess": "OAuth defaults restored",
"repairEnvFailed": "Failed to repair .env",
"noConnectionsYet": "No connections yet",
"addFirstConnectionHint": "Add your first connection to get started",
"addConnection": "Add Connection",
@@ -1770,6 +1779,13 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
"filterModels": "Filter models...",
"showModel": "Show model",
"hideModel": "Hide model",
"selectAllModels": "Select all",
"deselectAllModels": "Deselect all",
"modelsActiveCount": "{active}/{total} active",
"noModelsMatch": "No models match \"{filter}\"",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Failed to test connection",
@@ -2229,7 +2245,6 @@
"backupCreated": "Backup created: {file}",
"restoreSuccess": "Restored! {connections} connections, {nodes} nodes, {combos} combos, {apiKeys} API keys.",
"importSuccess": "Database imported! {connections} connections, {nodes} nodes, {combos} combos, {apiKeys} API keys.",
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago",
@@ -2244,7 +2259,6 @@
"errorDuringImport": "An error occurred during import",
"modelPricing": "Model Pricing",
"modelPricingDesc": "Configure cost rates per model • All rates in $/1M tokens",
"providers": "Providers",
"registry": "Registry",
"priced": "Priced",
"searchProvidersModels": "Search providers or models...",
+9
View File
@@ -1572,6 +1572,10 @@
"adding": "Añadiendo...",
"importingModelsTitle": "Importar modelos",
"copyModel": "Copiar modelo",
"filterModels": "Filtrar modelos…",
"modelsActive": "activos",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"removeModel": "Quitar modelo",
"rateLimitProtected": "Protegido",
"rateLimitUnprotected": "Desprotegido",
@@ -1606,6 +1610,11 @@
"configured": "configurado",
"providerProxyConfigureHint": "Configurar proxy para todas las conexiones de este proveedor",
"providerProxy": "Proxy del proveedor",
"repairEnv": "Reparar .env",
"repairEnvWorking": "Reparando...",
"repairEnvHint": "Restaura en .env las variables OAuth predeterminadas que faltan sin sobrescribir los valores existentes.",
"repairEnvSuccess": "Valores OAuth predeterminados restaurados",
"repairEnvFailed": "No se pudo reparar .env",
"noConnectionsYet": "Aún no hay conexiones",
"addFirstConnectionHint": "Agregue su primera conexión para comenzar",
"addConnection": "Agregar conexión",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Lisätään...",
"importingModelsTitle": "Mallien tuonti",
"copyModel": "Kopioi malli",
"filterModels": "Suodata malleja…",
"modelsActive": "aktiivista",
"showModel": "Näytä malli",
"hideModel": "Piilota malli",
"removeModel": "Poista malli",
"rateLimitProtected": "Suojattu",
"rateLimitUnprotected": "Suojaamaton",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Ajout...",
"importingModelsTitle": "Importation de modèles",
"copyModel": "Copier le modèle",
"filterModels": "Filtrer les modèles…",
"modelsActive": "actifs",
"showModel": "Afficher le modèle",
"hideModel": "Masquer le modèle",
"removeModel": "Supprimer le modèle",
"rateLimitProtected": "Protégé",
"rateLimitUnprotected": "Non protégé",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "מוסיף...",
"importingModelsTitle": "ייבוא דגמים",
"copyModel": "העתק דגם",
"filterModels": "סינון מודלים…",
"modelsActive": "פעילים",
"showModel": "הצג מודל",
"hideModel": "הסתר מודל",
"removeModel": "הסר את הדגם",
"rateLimitProtected": "מוגן",
"rateLimitUnprotected": "לא מוגן",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "जोड़ा जा रहा है...",
"importingModelsTitle": "मॉडल आयात करना",
"copyModel": "मॉडल कॉपी करें",
"filterModels": "मॉडल फ़िल्टर करें…",
"modelsActive": "सक्रिय",
"showModel": "मॉडल दिखाएँ",
"hideModel": "मॉडल छिपाएँ",
"removeModel": "मॉडल हटाएँ",
"rateLimitProtected": "संरक्षित",
"rateLimitUnprotected": "असुरक्षित",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Hozzáadás...",
"importingModelsTitle": "Modellek importálása",
"copyModel": "Modell másolása",
"filterModels": "Modellek szűrése…",
"modelsActive": "aktív",
"showModel": "Modell megjelenítése",
"hideModel": "Modell elrejtése",
"removeModel": "Modell eltávolítása",
"rateLimitProtected": "Védett",
"rateLimitUnprotected": "Nem védett",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Menambahkan...",
"importingModelsTitle": "Mengimpor Model",
"copyModel": "Salin modelnya",
"filterModels": "Filter model…",
"modelsActive": "aktif",
"showModel": "Tampilkan model",
"hideModel": "Sembunyikan model",
"removeModel": "Hapus modelnya",
"rateLimitProtected": "Dilindungi",
"rateLimitUnprotected": "Tidak terlindungi",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Aggiunta...",
"importingModelsTitle": "Importazione di modelli",
"copyModel": "Copia modello",
"filterModels": "Filtra modelli…",
"modelsActive": "attivi",
"showModel": "Mostra modello",
"hideModel": "Nascondi modello",
"removeModel": "Rimuovi il modello",
"rateLimitProtected": "Protetto",
"rateLimitUnprotected": "Non protetto",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "追加中...",
"importingModelsTitle": "モデルのインポート",
"copyModel": "モデルをコピーする",
"filterModels": "モデルを絞り込む…",
"modelsActive": "有効",
"showModel": "モデルを表示",
"hideModel": "モデルを非表示",
"removeModel": "モデルの削除",
"rateLimitProtected": "保護されています",
"rateLimitUnprotected": "保護されていない",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "추가 중...",
"importingModelsTitle": "모델 가져오기",
"copyModel": "모델 복사",
"filterModels": "모델 필터링…",
"modelsActive": "활성",
"showModel": "모델 표시",
"hideModel": "모델 숨기기",
"removeModel": "모델 삭제",
"rateLimitProtected": "보호됨",
"rateLimitUnprotected": "보호되지 않음",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Menambah...",
"importingModelsTitle": "Mengimport Model",
"copyModel": "Salin model",
"filterModels": "Tapis model…",
"modelsActive": "aktif",
"showModel": "Tunjukkan model",
"hideModel": "Sembunyikan model",
"removeModel": "Alih keluar model",
"rateLimitProtected": "Dilindungi",
"rateLimitUnprotected": "Tidak dilindungi",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Toevoegen...",
"importingModelsTitle": "Modellen importeren",
"copyModel": "Kopieermodel",
"filterModels": "Modellen filteren…",
"modelsActive": "actief",
"showModel": "Model tonen",
"hideModel": "Model verbergen",
"removeModel": "Model verwijderen",
"rateLimitProtected": "Beschermd",
"rateLimitUnprotected": "Onbeschermd",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Legger til...",
"importingModelsTitle": "Importere modeller",
"copyModel": "Kopier modell",
"filterModels": "Filtrer modeller…",
"modelsActive": "aktive",
"showModel": "Vis modell",
"hideModel": "Skjul modell",
"removeModel": "Fjern modellen",
"rateLimitProtected": "Beskyttet",
"rateLimitUnprotected": "Ubeskyttet",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Idinaragdag...",
"importingModelsTitle": "Pag-import ng mga Modelo",
"copyModel": "Kopyahin ang modelo",
"filterModels": "I-filter ang mga modelo…",
"modelsActive": "aktibo",
"showModel": "Ipakita ang modelo",
"hideModel": "Itago ang modelo",
"removeModel": "Alisin ang modelo",
"rateLimitProtected": "Pinoprotektahan",
"rateLimitUnprotected": "Hindi protektado",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Dodawanie...",
"importingModelsTitle": "Importowanie modeli",
"copyModel": "Skopiuj model",
"filterModels": "Filtruj modele…",
"modelsActive": "aktywne",
"showModel": "Pokaż model",
"hideModel": "Ukryj model",
"removeModel": "Usuń model",
"rateLimitProtected": "Chronione",
"rateLimitUnprotected": "Niechroniony",
+11
View File
@@ -1632,6 +1632,10 @@
"adding": "Adicionando...",
"importingModelsTitle": "Importando Modelos",
"copyModel": "Copiar modelo",
"filterModels": "Filtrar modelos…",
"modelsActive": "ativos",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"removeModel": "Remover modelo",
"rateLimitProtected": "Protegido",
"rateLimitUnprotected": "Desprotegido",
@@ -1766,6 +1770,13 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
"filterModels": "Filtrar modelos...",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"selectAllModels": "Selecionar todos",
"deselectAllModels": "Desmarcar todos",
"modelsActiveCount": "{active}/{total} ativos",
"noModelsMatch": "Nenhum modelo corresponde a \"{filter}\"",
"groupLabel": "Ambiente",
"groupPlaceholder": "ex: eKaizen, Pessoal",
"failedTestConnection": "Falha ao testar conexão",
+11
View File
@@ -1623,6 +1623,10 @@
"adding": "Adicionando...",
"importingModelsTitle": "Importando Modelos",
"copyModel": "Copiar modelo",
"filterModels": "Filtrar modelos…",
"modelsActive": "ativos",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"removeModel": "Remover modelo",
"rateLimitProtected": "Protegido",
"rateLimitUnprotected": "Desprotegido",
@@ -1757,6 +1761,13 @@
"email": "E-mail",
"healthCheckMinutes": "Verificação de integridade (min)",
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
"filterModels": "Filtrar modelos...",
"showModel": "Mostrar modelo",
"hideModel": "Ocultar modelo",
"selectAllModels": "Selecionar todos",
"deselectAllModels": "Desmarcar todos",
"modelsActiveCount": "{active}/{total} ativos",
"noModelsMatch": "Nenhum modelo corresponde a \"{filter}\"",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Falha ao testar a conexão",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Se adaugă...",
"importingModelsTitle": "Importul modelelor",
"copyModel": "Copiați modelul",
"filterModels": "Filtrează modelele…",
"modelsActive": "active",
"showModel": "Afișează modelul",
"hideModel": "Ascunde modelul",
"removeModel": "Eliminați modelul",
"rateLimitProtected": "Protejat",
"rateLimitUnprotected": "Neprotejat",
+9
View File
@@ -1596,6 +1596,10 @@
"adding": "Добавление...",
"importingModelsTitle": "Импорт моделей",
"copyModel": "Копировать модель",
"filterModels": "Фильтровать модели…",
"modelsActive": "активные",
"showModel": "Показать модель",
"hideModel": "Скрыть модель",
"removeModel": "Удалить модель",
"rateLimitProtected": "Защищено",
"rateLimitUnprotected": "незащищенный",
@@ -1632,6 +1636,11 @@
"configured": "настроен",
"providerProxyConfigureHint": "Настроить прокси для всех подключений этого провайдера",
"providerProxy": "Прокси-сервер провайдера",
"repairEnv": "Починить .env",
"repairEnvWorking": "Исправляем...",
"repairEnvHint": "Восстановить недостающие стандартные OAuth-переменные в .env, не перезаписывая существующие значения.",
"repairEnvSuccess": "Стандартные OAuth-переменные восстановлены",
"repairEnvFailed": "Не удалось починить .env",
"noConnectionsYet": "Пока нет подключений",
"addFirstConnectionHint": "Добавьте первое соединение, чтобы начать",
"addConnection": "Добавить соединение",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Pridáva sa...",
"importingModelsTitle": "Importovanie modelov",
"copyModel": "Kopírovať model",
"filterModels": "Filtrovať modely…",
"modelsActive": "aktívne",
"showModel": "Zobraziť model",
"hideModel": "Skryť model",
"removeModel": "Odstráňte model",
"rateLimitProtected": "Chránené",
"rateLimitUnprotected": "Nechránené",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Lägger till...",
"importingModelsTitle": "Importera modeller",
"copyModel": "Kopiera modell",
"filterModels": "Filtrera modeller…",
"modelsActive": "aktiva",
"showModel": "Visa modell",
"hideModel": "Dölj modell",
"removeModel": "Ta bort modellen",
"rateLimitProtected": "Skyddad",
"rateLimitUnprotected": "Oskyddad",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "กำลังเพิ่ม...",
"importingModelsTitle": "การนำเข้าโมเดล",
"copyModel": "คัดลอกโมเดล",
"filterModels": "กรองโมเดล…",
"modelsActive": "ใช้งานอยู่",
"showModel": "แสดงโมเดล",
"hideModel": "ซ่อนโมเดล",
"removeModel": "ลบโมเดล",
"rateLimitProtected": "ได้รับการคุ้มครอง",
"rateLimitUnprotected": "ไม่มีการป้องกัน",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Ekleniyor...",
"importingModelsTitle": "Modelleri İçe Aktarma",
"copyModel": "Modeli kopyala",
"filterModels": "Modelleri filtrele…",
"modelsActive": "etkin",
"showModel": "Modeli göster",
"hideModel": "Modeli gizle",
"removeModel": "Modeli kaldır",
"rateLimitProtected": "Korumalı",
"rateLimitUnprotected": "Korumasız",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Додавання...",
"importingModelsTitle": "Імпорт моделей",
"copyModel": "Копія моделі",
"filterModels": "Фільтрувати моделі…",
"modelsActive": "активні",
"showModel": "Показати модель",
"hideModel": "Приховати модель",
"removeModel": "Зняти модель",
"rateLimitProtected": "Захищений",
"rateLimitUnprotected": "Незахищений",
+4
View File
@@ -1572,6 +1572,10 @@
"adding": "Đang thêm...",
"importingModelsTitle": "Nhập mô hình",
"copyModel": "Sao chép mô hình",
"filterModels": "Lọc mô hình…",
"modelsActive": "đang hoạt động",
"showModel": "Hiển thị mô hình",
"hideModel": "Ẩn mô hình",
"removeModel": "Xóa mô hình",
"rateLimitProtected": "Được bảo vệ",
"rateLimitUnprotected": "không được bảo vệ",
+9
View File
@@ -1585,6 +1585,10 @@
"close": "关闭",
"importingModelsTitle": "导入模型",
"copyModel": "复制模型",
"filterModels": "筛选模型…",
"modelsActive": "已启用",
"showModel": "显示模型",
"hideModel": "隐藏模型",
"removeModel": "删除模型",
"rateLimitProtected": "受保护",
"rateLimitUnprotected": "无保护",
@@ -1619,6 +1623,11 @@
"configured": "已配置",
"providerProxyConfigureHint": "为该提供商的所有连接配置代理",
"providerProxy": "提供商代理",
"repairEnv": "修复 .env",
"repairEnvWorking": "修复中...",
"repairEnvHint": "将缺失的 OAuth 默认值补充到 .env 中,不会覆盖现有值。",
"repairEnvSuccess": "OAuth 默认值已恢复",
"repairEnvFailed": "修复 .env 失败",
"noConnectionsYet": "还没有连接",
"addFirstConnectionHint": "添加您的第一个连接以开始使用",
"addConnection": "添加连接",
+21 -5
View File
@@ -1,5 +1,9 @@
type JsonRecord = Record<string, unknown>;
const COMBO_TEST_MAX_TOKENS = 2048;
const COMBO_TEST_OPERAND_MIN = 10000;
const COMBO_TEST_OPERAND_RANGE = 90000;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
@@ -103,14 +107,26 @@ function hasReasoningOnlyCompletion(body: JsonRecord): boolean {
});
}
function getRandomFiveDigitNumber() {
return COMBO_TEST_OPERAND_MIN + Math.floor(Math.random() * COMBO_TEST_OPERAND_RANGE);
}
function buildComboTestPrompt() {
const left = getRandomFiveDigitNumber();
const right = getRandomFiveDigitNumber();
return `Calculate ${left}+${right}, and reply with the result only.`;
}
export function buildComboTestRequestBody(modelStr: string) {
return {
model: modelStr,
messages: [{ role: "user", content: "Reply with OK only." }],
// Give reasoning-heavy models enough headroom to emit a tiny visible answer
// without turning the smoke test into a full-cost real request.
max_tokens: 64,
temperature: 0,
// Randomize the arithmetic prompt so upstream providers are less likely to
// satisfy the smoke test with cached completions.
messages: [{ role: "user", content: buildComboTestPrompt() }],
// Give reasoning-heavy models enough headroom to finish the request and
// still emit a visible answer without immediate truncation.
max_tokens: COMBO_TEST_MAX_TOKENS,
stream: false,
};
}
+75 -2
View File
@@ -10,7 +10,12 @@
*/
import { getDbInstance } from "../db/core";
import { getAppLogRetentionDays, getCallLogRetentionDays } from "../logEnv";
import {
getAppLogRetentionDays,
getCallLogRetentionDays,
getCallLogsTableMaxRows,
getProxyLogsTableMaxRows,
} from "../logEnv";
/** @returns {import("better-sqlite3").Database | null} */
function getDb() {
@@ -231,14 +236,20 @@ export function getRetentionDays() {
* deletedRequestDetailLogs: number,
* deletedAuditLogs: number,
* deletedMcpAuditLogs: number,
* trimmedCallLogs: number,
* trimmedProxyLogs: number,
* appRetentionDays: number,
* callRetentionDays: number
* callRetentionDays: number,
* callLogsMaxRows: number,
* proxyLogsMaxRows: number
* }}
*/
export function cleanupExpiredLogs() {
const db = getDb();
const appRetentionDays = getAppLogRetentionDays();
const callRetentionDays = getCallLogRetentionDays();
const callLogsMaxRows = getCallLogsTableMaxRows();
const proxyLogsMaxRows = getProxyLogsTableMaxRows();
if (!db) {
return {
@@ -248,8 +259,12 @@ export function cleanupExpiredLogs() {
deletedRequestDetailLogs: 0,
deletedAuditLogs: 0,
deletedMcpAuditLogs: 0,
trimmedCallLogs: 0,
trimmedProxyLogs: 0,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
};
}
@@ -262,6 +277,8 @@ export function cleanupExpiredLogs() {
let deletedRequestDetailLogs = 0;
let deletedAuditLogs = 0;
let deletedMcpAuditLogs = 0;
let trimmedCallLogs = 0;
let trimmedProxyLogs = 0;
try {
const r1 = db.prepare("DELETE FROM usage_history WHERE timestamp < ?").run(callCutoff);
@@ -305,6 +322,54 @@ export function cleanupExpiredLogs() {
/* table may not exist */
}
// Enforce row count limits to prevent unbounded DB growth (batched to avoid long locks)
const BATCH_SIZE = 5000;
if (callLogsMaxRows > 0) {
try {
let currentCount = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get() as {
cnt: number;
};
while (currentCount.cnt > callLogsMaxRows) {
const toDelete = Math.min(currentCount.cnt - callLogsMaxRows, BATCH_SIZE);
const trimmed = db
.prepare(
`DELETE FROM call_logs WHERE id IN (
SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?
)`
)
.run(toDelete);
trimmedCallLogs += trimmed.changes;
currentCount.cnt -= trimmed.changes;
if (trimmed.changes === 0) break;
}
} catch {
/* best effort */
}
}
if (proxyLogsMaxRows > 0) {
try {
let currentProxyCount = db.prepare("SELECT COUNT(*) as cnt FROM proxy_logs").get() as {
cnt: number;
};
while (currentProxyCount.cnt > proxyLogsMaxRows) {
const toDelete = Math.min(currentProxyCount.cnt - proxyLogsMaxRows, BATCH_SIZE);
const trimmed = db
.prepare(
`DELETE FROM proxy_logs WHERE id IN (
SELECT id FROM proxy_logs ORDER BY timestamp ASC LIMIT ?
)`
)
.run(toDelete);
trimmedProxyLogs += trimmed.changes;
currentProxyCount.cnt -= trimmed.changes;
if (trimmed.changes === 0) break;
}
} catch {
/* best effort */
}
}
logAuditEvent({
action: "compliance.cleanup",
details: {
@@ -314,8 +379,12 @@ export function cleanupExpiredLogs() {
deletedRequestDetailLogs,
deletedAuditLogs,
deletedMcpAuditLogs,
trimmedCallLogs,
trimmedProxyLogs,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
},
});
@@ -326,7 +395,11 @@ export function cleanupExpiredLogs() {
deletedRequestDetailLogs,
deletedAuditLogs,
deletedMcpAuditLogs,
trimmedCallLogs,
trimmedProxyLogs,
appRetentionDays,
callRetentionDays,
callLogsMaxRows,
proxyLogsMaxRows,
};
}
+121 -20
View File
@@ -17,33 +17,57 @@ function getSerializedData(value: unknown): string | null {
return typeof row.data === "string" ? row.data : null;
}
function getSortOrder(value: unknown): number | null {
const row = asRecord(value);
return typeof row.sort_order === "number" ? row.sort_order : null;
}
function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
const parsed = JSON.parse(payload) as JsonRecord;
if (typeof sortOrder === "number") {
parsed.sortOrder = sortOrder;
}
return parsed;
}
function getNextSortOrder() {
const db = getDbInstance();
const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
const sortOrder = getSortOrder(row);
return (sortOrder ?? 0) + 1;
}
export async function getCombos() {
const db = getDbInstance();
return db
.prepare("SELECT data FROM combos ORDER BY name")
.prepare("SELECT data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC")
.all()
.map((row) => getSerializedData(row))
.filter((row): row is string => row !== null)
.map((row) => JSON.parse(row));
.map((row) => {
const payload = getSerializedData(row);
if (!payload) return null;
return withSortOrder(payload, getSortOrder(row));
})
.filter((row): row is JsonRecord => row !== null);
}
export async function getComboById(id: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function getComboByName(name: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE name = ?").get(name);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function createCombo(data: JsonRecord) {
const db = getDbInstance();
const now = new Date().toISOString();
const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
const combo = {
id: uuidv4(),
@@ -52,13 +76,14 @@ export async function createCombo(data: JsonRecord) {
strategy: data.strategy || "priority",
config: data.config || {},
isHidden: Boolean(data.isHidden),
sortOrder,
createdAt: now,
updatedAt: now,
};
db.prepare(
"INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), now, now);
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now);
backupDbFile("pre-write");
return combo;
@@ -66,23 +91,99 @@ export async function createCombo(data: JsonRecord) {
export async function updateCombo(id: string, data: JsonRecord) {
const db = getDbInstance();
const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const existing = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
if (!existing) return null;
const serializedCurrent = getSerializedData(existing);
if (!serializedCurrent) return null;
const current = JSON.parse(serializedCurrent);
const merged = { ...current, ...data, updatedAt: new Date().toISOString() };
const current = withSortOrder(serializedCurrent, getSortOrder(existing));
const sortOrder =
typeof data.sortOrder === "number"
? data.sortOrder
: typeof current.sortOrder === "number"
? current.sortOrder
: getNextSortOrder();
const merged: JsonRecord = {
...current,
...data,
sortOrder,
updatedAt: new Date().toISOString(),
};
const currentName = typeof current.name === "string" ? current.name : "";
const nextName =
typeof merged["name"] === "string" && merged["name"].trim().length > 0
? merged["name"]
: currentName;
db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run(
merged.name,
JSON.stringify(merged),
merged.updatedAt,
id
);
db.prepare(
"UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
).run(nextName, JSON.stringify({ ...merged, name: nextName }), sortOrder, merged.updatedAt, id);
backupDbFile("pre-write");
return merged;
return { ...merged, name: nextName };
}
export async function reorderCombos(comboIds: string[]) {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
)
.all();
if (rows.length === 0) return [];
const existingIds = new Set(
rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null)
);
const seen = new Set<string>();
const requestedIds = comboIds.filter((id) => {
if (!existingIds.has(id) || seen.has(id)) return false;
seen.add(id);
return true;
});
const orderedIds = [
...requestedIds,
...rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null && !seen.has(id)),
];
const update = db.prepare(
"UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
);
const now = new Date().toISOString();
const rowById = new Map(
rows.map((row) => {
const record = asRecord(row);
return [String(record.id), row];
})
);
const reorderTransaction = db.transaction(() => {
orderedIds.forEach((id, index) => {
const row = rowById.get(id);
const payload = row ? getSerializedData(row) : null;
if (!payload) return;
const combo = withSortOrder(payload, getSortOrder(row));
const sortOrder = index + 1;
const updatedCombo = { ...combo, sortOrder, updatedAt: now };
update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
});
});
reorderTransaction();
backupDbFile("pre-write");
return getCombos();
}
export async function deleteCombo(id: string) {
+25 -8
View File
@@ -111,6 +111,7 @@ const SCHEMA_SQL = `
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
data TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -406,6 +407,11 @@ function ensureCallLogsColumns(db: SqliteDatabase) {
}
}
function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
}
export function getDbInstance(): SqliteDatabase {
const existing = getDb();
if (existing) return existing;
@@ -512,6 +518,12 @@ export function getDbInstance(): SqliteDatabase {
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
VALUES ('001', 'initial_schema');
`);
if (hasColumn(db, "combos", "sort_order")) {
db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
"020",
"combo_sort_order"
);
}
runMigrations(db);
// Auto-migrate from db.json if exists
@@ -702,16 +714,21 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
// 4. Combos
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
for (const combo of data.combos || []) {
for (const [index, combo] of (data.combos || []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt || new Date().toISOString(),
updatedAt: combo.updatedAt || new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt || new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}
+13 -8
View File
@@ -75,8 +75,8 @@ export function runJsonMigration(
);
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
const insertKey = db.prepare(`
@@ -171,13 +171,18 @@ export function runJsonMigration(
}
// 5. Combos
for (const combo of data.combos ?? []) {
for (const [index, combo] of (data.combos ?? []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt ?? new Date().toISOString(),
updatedAt: combo.updatedAt ?? new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt ?? new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt ?? new Date().toISOString(),
});
}
@@ -0,0 +1,16 @@
ALTER TABLE combos ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
WITH ordered_combos AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY created_at ASC, updated_at ASC, name COLLATE NOCASE ASC
) AS next_sort_order
FROM combos
)
UPDATE combos
SET sort_order = (
SELECT next_sort_order
FROM ordered_combos
WHERE ordered_combos.id = combos.id
);
+1
View File
@@ -75,6 +75,7 @@ export {
getComboByName,
createCombo,
updateCombo,
reorderCombos,
deleteCombo,
} from "./db/combos";
+10
View File
@@ -5,6 +5,8 @@ const DEFAULT_CALL_LOG_RETENTION_DAYS = 7;
const DEFAULT_APP_LOG_MAX_SIZE = 50 * 1024 * 1024;
const DEFAULT_APP_LOG_MAX_FILES = 20;
const DEFAULT_CALL_LOG_MAX_ENTRIES = 10000;
const DEFAULT_CALL_LOGS_TABLE_MAX_ROWS = 100000;
const DEFAULT_PROXY_LOGS_TABLE_MAX_ROWS = 100000;
const DEFAULT_APP_LOG_PATH = path.join(process.cwd(), "logs", "application", "app.log");
function parsePositiveInt(value: string | undefined, fallback: number): number {
@@ -62,6 +64,14 @@ export function getCallLogMaxEntries(): number {
return parsePositiveInt(process.env.CALL_LOG_MAX_ENTRIES, DEFAULT_CALL_LOG_MAX_ENTRIES);
}
export function getCallLogsTableMaxRows(): number {
return parsePositiveInt(process.env.CALL_LOGS_TABLE_MAX_ROWS, DEFAULT_CALL_LOGS_TABLE_MAX_ROWS);
}
export function getProxyLogsTableMaxRows(): number {
return parsePositiveInt(process.env.PROXY_LOGS_TABLE_MAX_ROWS, DEFAULT_PROXY_LOGS_TABLE_MAX_ROWS);
}
export function getAppLogLevel(defaultLevel: string): string {
return process.env.APP_LOG_LEVEL || defaultLevel;
}
+41 -23
View File
@@ -10,12 +10,18 @@
* updates the DB, and logs the result.
*/
import { getProviderConnections, updateProviderConnection, getSettings, resolveProxyForConnection } from "@/lib/localDb";
import {
getProviderConnections,
updateProviderConnection,
getSettings,
resolveProxyForConnection,
} from "@/lib/localDb";
import {
getAccessToken,
supportsTokenRefresh,
isUnrecoverableRefreshError,
} from "@omniroute/open-sse/services/tokenRefresh.ts";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
// ── Constants ────────────────────────────────────────────────────────────────
const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds
@@ -25,6 +31,29 @@ const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes
const LOG_PREFIX = "[HealthCheck]";
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
function getConnectionLogLabel(conn: { name?: string; email?: string; id?: string }): string {
return pickMaskedDisplayValue([conn.name, conn.email], conn.id || "-");
}
export function buildRefreshFailureUpdate(conn: any, now: string) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
return {
lastHealthCheckAt: now,
// A failed background refresh should not evict otherwise healthy accounts
// from request routing. Keep non-expired connections active and only persist
// the refresh error metadata for observability.
testStatus: wasExpired ? "expired" : "active",
lastError: "Health check: token refresh failed",
lastErrorAt: now,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
};
}
function isEnvFlagEnabled(name: string): boolean {
const value = process.env[name];
if (!value) return false;
@@ -190,7 +219,7 @@ async function checkConnection(conn) {
if (Date.now() - lastRetry < backoffMs) return;
log(
`${LOG_PREFIX} Retrying expired ${conn.provider}/${conn.name || conn.email || conn.id} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})`
`${LOG_PREFIX} Retrying expired ${conn.provider}/${getConnectionLogLabel(conn)} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})`
);
}
@@ -198,7 +227,7 @@ async function checkConnection(conn) {
const now = new Date().toISOString();
await updateProviderConnection(conn.id, { lastHealthCheckAt: now });
log(
`${LOG_PREFIX} Skipping ${conn.provider}/${conn.name || conn.email || conn.id} (refresh unsupported)`
`${LOG_PREFIX} Skipping ${conn.provider}/${getConnectionLogLabel(conn)} (refresh unsupported)`
);
return;
}
@@ -216,9 +245,7 @@ async function checkConnection(conn) {
if (Date.now() - lastCheck < intervalMs && !isAboutToExpire) return;
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
log(
`${LOG_PREFIX} Refreshing ${conn.provider}/${conn.name || conn.email || conn.id} (${reason})`
);
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
const credentials = {
refreshToken: conn.refreshToken,
@@ -265,7 +292,7 @@ async function checkConnection(conn) {
refreshToken: null,
});
logError(
`${LOG_PREFIX}${conn.provider}/${conn.name || conn.email || conn.id}` +
`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)}` +
`Refresh token is permanently invalid (${result.error}). ` +
`Connection deactivated. Re-authenticate to restore.`
);
@@ -302,24 +329,15 @@ async function checkConnection(conn) {
}
await updateProviderConnection(conn.id, updateData);
log(`${LOG_PREFIX}${conn.provider}/${conn.name || conn.email || conn.id} refreshed`);
log(`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)} refreshed`);
} else {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: wasExpired ? "expired" : "error",
lastError: "Health check: token refresh failed",
lastErrorAt: now,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
});
const updateData = buildRefreshFailureUpdate(conn, now);
await updateProviderConnection(conn.id, updateData);
logWarn(
`${LOG_PREFIX}${conn.provider}/${conn.name || conn.email || conn.id} refresh failed` +
(wasExpired ? ` (expired retry ${retryCount}/${EXPIRED_RETRY_MAX})` : "")
`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)} refresh failed` +
(conn.testStatus === "expired"
? ` (${updateData.expiredRetryCount}/${EXPIRED_RETRY_MAX} expired retries used)`
: "")
);
}
}
+2 -1
View File
@@ -28,6 +28,7 @@ import {
serializePayloadForStorage,
} from "../logPayloads";
import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
type JsonRecord = Record<string, unknown>;
@@ -159,7 +160,7 @@ async function resolveAccountName(connectionId: string | null | undefined) {
const connections = await getProviderConnections();
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
account = conn.name || conn.email || account;
account = pickMaskedDisplayValue([conn.name, conn.email], account);
}
} catch {
// Best-effort lookup only.
+32 -22
View File
@@ -109,6 +109,15 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
: [];
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
const tokenStats = {
totalIn: detail?.tokens?.in ?? log.tokens?.in ?? 0,
totalOut: detail?.tokens?.out ?? log.tokens?.out ?? 0,
cacheRead: detail?.tokens?.cacheRead ?? log.tokens?.cacheRead,
cacheWrite: detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite,
reasoning: detail?.tokens?.reasoning ?? log.tokens?.reasoning,
};
const formatTokenValue = (value) => (value != null ? value.toLocaleString() : "N/A");
return (
<div
@@ -146,9 +155,14 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div className="p-6 flex flex-col gap-6">
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border"
data-testid="request-log-metadata-grid"
>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Time</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Completed Time
</div>
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
</div>
<div>
@@ -158,33 +172,29 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Tokens
</div>
<div className="flex flex-wrap items-center gap-1.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Input</div>
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-input">
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
Total In: {(detail?.tokens?.in ?? log.tokens?.in ?? 0).toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Total Out: {(detail?.tokens?.out ?? log.tokens?.out ?? 0).toLocaleString()}
Total In: {tokenStats.totalIn.toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs font-bold">
Cache Read:{" "}
{(detail?.tokens?.cacheRead ?? log.tokens?.cacheRead) != null
? (detail?.tokens?.cacheRead ?? log.tokens?.cacheRead).toLocaleString()
: "N/A"}
Cache Read: {formatTokenValue(tokenStats.cacheRead)}
</span>
<span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs font-bold">
Cache Write:{" "}
{(detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite) != null
? (detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite).toLocaleString()
: "N/A"}
Cache Write: {formatTokenValue(tokenStats.cacheWrite)}
</span>
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Output
</div>
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-output">
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Total Out: {tokenStats.totalOut.toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-violet-500/20 text-violet-700 dark:text-violet-400 text-xs font-bold">
Reasoning:{" "}
{(detail?.tokens?.reasoning ?? log.tokens?.reasoning) != null
? (detail?.tokens?.reasoning ?? log.tokens?.reasoning).toLocaleString()
: "N/A"}
Reasoning: {formatTokenValue(tokenStats.reasoning)}
</span>
</div>
</div>
+64 -48
View File
@@ -6,6 +6,7 @@ import { CardSkeleton } from "./Loading";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
import {
StatCard,
CompactStatGrid,
ActivityHeatmap,
DailyTrendChart,
AccountDonut,
@@ -109,7 +110,7 @@ export default function UsageAnalytics() {
const ioRatio = s.completionTokens > 0 ? (s.promptTokens / s.completionTokens).toFixed(1) : "—";
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5 min-w-0">
{/* Header + Time Range */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
@@ -133,8 +134,8 @@ export default function UsageAnalytics() {
</div>
</div>
{/* Summary Cards — Row 1: Core metrics */}
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
{/* Primary KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
icon="generating_tokens"
label="Total Tokens"
@@ -159,56 +160,71 @@ export default function UsageAnalytics() {
value={fmtCost(s.totalCost)}
color="text-amber-500"
/>
<StatCard icon="group" label="Accounts" value={s.uniqueAccounts || 0} />
<StatCard icon="vpn_key" label="API Keys" value={s.uniqueApiKeys || 0} />
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
<StatCard
icon="swap_horiz"
label="Fallback Rate"
value={`${Number(s.fallbackRatePct || 0).toFixed(1)}%`}
subValue={`${fmtFull(s.fallbackCount || 0)} fallbacks`}
color="text-amber-500"
/>
</div>
{/* Summary Cards — Row 2: Derived insights */}
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
<StatCard
icon="speed"
label="Avg Tokens/Req"
value={fmt(avgTokensPerReq)}
color="text-cyan-500"
/>
<StatCard
icon="request_quote"
label="Cost/Request"
value={fmtCost(costPerReq)}
color="text-orange-500"
/>
<StatCard
icon="compare_arrows"
label="I/O Ratio"
value={`${ioRatio}x`}
color="text-violet-500"
/>
<StatCard icon="star" label="Top Model" value={topModel} color="text-pink-500" />
<StatCard icon="cloud" label="Top Provider" value={topProvider} color="text-teal-500" />
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
<StatCard
icon="network_node"
label="Diversity Score"
value={`${providerDiversity.toFixed(1)}%`}
color="text-sky-500"
/>
</div>
{/* Secondary Metrics — compact grid with sections */}
<CompactStatGrid
sections={[
{
title: "Infrastructure",
items: [
{ icon: "group", label: "Accounts", value: s.uniqueAccounts || 0 },
{ icon: "dns", label: "Providers", value: providerCount, color: "text-indigo-500" },
{ icon: "vpn_key", label: "API Keys", value: s.uniqueApiKeys || 0 },
{ icon: "model_training", label: "Models", value: s.uniqueModels || 0 },
],
},
{
title: "Performance",
items: [
{
icon: "speed",
label: "Avg Tokens/Req",
value: fmt(avgTokensPerReq),
color: "text-cyan-500",
},
{
icon: "request_quote",
label: "Cost/Req",
value: fmtCost(costPerReq),
color: "text-orange-500",
},
{
icon: "compare_arrows",
label: "I/O Ratio",
value: `${ioRatio}x`,
color: "text-violet-500",
},
{
icon: "swap_horiz",
label: "Fallback Rate",
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
color: "text-amber-500",
},
],
},
{
title: "Highlights",
wideValues: true,
items: [
{ icon: "star", label: "Top Model", value: topModel, color: "text-pink-500" },
{ icon: "cloud", label: "Top Provider", value: topProvider, color: "text-teal-500" },
{ icon: "today", label: "Busiest Day", value: busiestDay, color: "text-rose-500" },
{
icon: "network_node",
label: "Diversity",
value: `${providerDiversity.toFixed(1)}%`,
color: "text-sky-500",
},
],
},
]}
/>
{/* Activity Heatmap + Weekly Widgets */}
<div
style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, alignItems: "stretch" }}
>
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4 items-stretch">
<ActivityHeatmap activityMap={analytics?.activityMap} />
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="flex flex-col gap-4">
<MostActiveDay7d activityMap={analytics?.activityMap} />
<WeeklySquares7d activityMap={analytics?.activityMap} />
</div>
+68 -8
View File
@@ -84,7 +84,7 @@ export function SortIndicator({ active, sortOrder }: { active: boolean; sortOrde
);
}
// ── StatCard ───────────────────────────────────────────────────────────────
// ── StatCard (primary KPI) ─────────────────────────────────────────────────
export function StatCard({
icon,
@@ -100,13 +100,73 @@ export function StatCard({
color?: string;
}) {
return (
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex items-center gap-2 text-text-muted text-xs uppercase font-semibold tracking-wider">
<span className="material-symbols-outlined text-[16px]">{icon}</span>
{label}
<Card className="px-4 py-3 flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-1.5 text-text-muted text-[11px] uppercase font-semibold tracking-wide min-w-0">
<span className="material-symbols-outlined text-[14px] shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<span className={`text-2xl font-bold ${color} truncate`} title={String(value)}>
{value}
</span>
{subValue && <span className="text-xs text-text-muted truncate">{subValue}</span>}
</Card>
);
}
// ── CompactStatGrid (secondary metrics in a single card, grouped) ─────────
export type CompactStatSection = {
title: string;
items: Array<{ icon: string; label: string; value: any; color?: string }>;
/** On mobile use 1 column instead of 2 — useful when values can be long (model names, etc.) */
wideValues?: boolean;
};
export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }) {
return (
<Card className="px-5 py-4">
<div className="flex flex-col gap-3">
{sections.map((section, si) => (
<div key={si}>
{si > 0 && (
<div className="border-t border-black/[0.06] dark:border-white/[0.06] mb-3" />
)}
<div className="text-[10px] uppercase font-semibold tracking-widest text-text-muted/50 mb-2">
{section.title}
</div>
<div
className={
section.wideValues
? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2"
: "grid grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2"
}
>
{section.items.map((stat, i) => (
<div key={i} className="flex items-center justify-between gap-2 min-w-0 py-0.5">
<div
className={`flex items-center gap-1.5 ${section.wideValues ? "shrink-0" : "min-w-0"}`}
>
<span className="material-symbols-outlined text-[14px] text-text-muted shrink-0">
{stat.icon}
</span>
<span
className={`text-[11px] uppercase font-semibold tracking-wide text-text-muted ${section.wideValues ? "whitespace-nowrap" : "truncate"}`}
>
{stat.label}
</span>
</div>
<span
className={`text-sm font-bold text-right ${section.wideValues ? "truncate min-w-0" : "shrink-0"} ${stat.color || "text-text-main"}`}
title={String(stat.value)}
>
{stat.value}
</span>
</div>
))}
</div>
</div>
))}
</div>
<span className={`text-2xl font-bold ${color}`}>{value}</span>
{subValue && <span className="text-xs text-text-muted">{subValue}</span>}
</Card>
);
}
@@ -189,7 +249,7 @@ export function ActivityHeatmap({ activityMap }) {
}
return (
<Card className="p-4 h-full">
<Card className="p-4 h-full min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
<span className="text-xs text-text-muted">
@@ -11,6 +11,7 @@
export {
SortIndicator,
StatCard,
CompactStatGrid,
ActivityHeatmap,
DailyTrendChart,
AccountDonut,
+4 -4
View File
@@ -7,8 +7,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"auto-combo",
"costs",
"analytics",
"limits",
"cache",
"limits",
"cli-tools",
"agents",
"memory",
@@ -17,8 +17,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"playground",
"media",
"search-tools",
"health",
"logs",
"health",
"audit",
"settings",
"docs",
@@ -55,8 +55,8 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" },
];
@@ -79,8 +79,8 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
];
const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" },
{ id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" },
];
+20 -2
View File
@@ -587,8 +587,26 @@ const checkKnownPath = async (commandPath: string) => {
const realPath = await fs.realpath(commandPath);
// Verify the resolved path is still within expected directories
// Use pre-computed expected parent paths (cached at module startup for performance)
const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent));
// Use pre-computed expected parent paths (cached at module startup for performance).
// On macOS temp directories often resolve from /var -> /private/var, so compare both
// the configured parent and its canonical realpath when available.
let isWithinExpected = false;
for (const parent of EXPECTED_PARENT_PATHS) {
if (isPathWithin(realPath, parent)) {
isWithinExpected = true;
break;
}
try {
const resolvedParent = await fs.realpath(parent);
if (isPathWithin(realPath, resolvedParent)) {
isWithinExpected = true;
break;
}
} catch {
// Ignore missing/unresolvable parents and continue checking the remaining ones.
}
}
if (!isWithinExpected) {
return { installed: false, commandPath: null, reason: "symlink_escape" };
+27 -5
View File
@@ -23,14 +23,36 @@ export function maskEmail(email: string | null | undefined, visibleChars = 2): s
// If username is too short to mask meaningfully, return as-is
if (username.length <= visibleChars) return email;
const maskedUser =
username.slice(0, visibleChars) + "*".repeat(username.length - visibleChars);
const maskedUser = username.slice(0, visibleChars) + "*".repeat(username.length - visibleChars);
// Mask domain name: keep first char, mask the rest
const maskedDomain =
domainName.length > 1
? domainName.slice(0, 1) + "*".repeat(domainName.length - 1)
: domainName;
domainName.length > 1 ? domainName.slice(0, 1) + "*".repeat(domainName.length - 1) : domainName;
return `${maskedUser}@${maskedDomain}${tld}`;
}
/**
* Masks the value only when it looks like an email address.
* Useful for fields like `name` that may be normalized to the raw email.
*/
export function maskEmailLikeValue(value: string | null | undefined, visibleChars = 2): string {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
return trimmed.includes("@") ? maskEmail(trimmed, visibleChars) : trimmed;
}
/**
* Returns the first non-empty display value, masking it if it contains an email.
*/
export function pickMaskedDisplayValue(
values: Array<string | null | undefined>,
fallback = ""
): string {
for (const value of values) {
const masked = maskEmailLikeValue(value);
if (masked) return masked;
}
return fallback;
}
+14
View File
@@ -925,6 +925,20 @@ export const updateComboSchema = z
}
});
export const reorderCombosSchema = z
.object({
comboIds: z.array(z.string().trim().min(1).max(200)).min(1).max(1000),
})
.superRefine((value, ctx) => {
if (new Set(value.comboIds).size !== value.comboIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "comboIds must be unique",
path: ["comboIds"],
});
}
});
export const testComboSchema = z.object({
comboName: z.string().trim().min(1, "comboName is required"),
});
+13 -5
View File
@@ -35,7 +35,7 @@ import {
import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import {
isModelAvailable,
setModelUnavailable,
markModelAsProblematic,
clearModelUnavailability,
} from "../../domain/modelAvailability";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
@@ -468,6 +468,7 @@ async function handleSingleModelChat(
let excludeConnectionId = null;
let lastError = null;
let lastStatus = null;
let lastCooldownMs = 0;
while (true) {
const credentials = await getProviderCredentials(
@@ -484,11 +485,15 @@ async function handleSingleModelChat(
);
if (!credentials || credentials.allRateLimited) {
if (lastStatus === 429 || lastStatus === 503) {
setModelUnavailable(provider, model, 60000, `HTTP ${lastStatus}`);
if ([408, 429, 500, 502, 503, 504].includes(Number(lastStatus))) {
const quarantine = markModelAsProblematic(provider, model, {
status: Number(lastStatus),
baseCooldownMs: lastCooldownMs,
reason: `HTTP ${lastStatus}`,
});
log.info(
"AVAILABILITY",
`${provider}/${model} marked unavailable — all accounts exhausted (HTTP ${lastStatus})`
`${provider}/${model} marked unavailable — all accounts exhausted (HTTP ${lastStatus}, cooldown ${Math.ceil(quarantine.cooldownMs / 1000)}s, failureCount ${quarantine.failureCount})`
);
}
return handleNoCredentials(
@@ -654,7 +659,7 @@ async function handleSingleModelChat(
}
// 7. Fallback to next account
const { shouldFallback } = await markAccountUnavailable(
const { shouldFallback, cooldownMs } = await markAccountUnavailable(
credentials.connectionId,
result.status,
result.error,
@@ -663,6 +668,9 @@ async function handleSingleModelChat(
);
if (shouldFallback) {
if (Number.isFinite(cooldownMs) && cooldownMs > 0) {
lastCooldownMs = cooldownMs;
}
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
excludeConnectionId = credentials.connectionId;
lastError = result.error;
+9 -5
View File
@@ -16,11 +16,15 @@ test.describe("API Health Checks", () => {
expect(Array.isArray(body.data)).toBe(true);
});
test("GET /api/providers returns provider list", async ({ request }) => {
test("GET /api/providers returns provider list or requires auth", async ({ request }) => {
const res = await request.get("/api/providers");
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body).toHaveProperty("connections");
expect(Array.isArray(body.connections)).toBe(true);
// In CI with auth enabled, 401 is acceptable — endpoint is reachable
if (res.ok()) {
const body = await res.json();
expect(body).toHaveProperty("connections");
expect(Array.isArray(body.connections)).toBe(true);
} else {
expect([401, 403, 307]).toContain(res.status());
}
});
});
+131
View File
@@ -7,6 +7,7 @@ type ComboStub = {
models: unknown[];
config: Record<string, unknown>;
isActive: boolean;
sortOrder?: number;
};
type ComboCreatePayload = {
@@ -210,4 +211,134 @@ test.describe("Combos flow", () => {
const testResultsModal = page.getByRole("dialog").last();
await expect(testResultsModal).toContainText(/qa-test-model/i);
});
test("allows dragging combo cards to persist manual order", async ({ page }) => {
const state: {
combos: ComboStub[];
reorderRequests: number;
} = {
combos: [
{
id: "combo-1",
name: "alpha-combo",
strategy: "priority",
models: ["openai/alpha"],
config: {},
isActive: true,
sortOrder: 1,
},
{
id: "combo-2",
name: "bravo-combo",
strategy: "priority",
models: ["openai/bravo"],
config: {},
isActive: true,
sortOrder: 2,
},
{
id: "combo-3",
name: "charlie-combo",
strategy: "priority",
models: ["openai/charlie"],
config: {},
isActive: true,
sortOrder: 3,
},
],
reorderRequests: 0,
};
await page.route("**/api/combos/metrics", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ metrics: {} }),
});
});
await page.route("**/api/settings/proxy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: {} }),
});
});
await page.route("**/api/providers", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.route("**/api/combos/reorder", async (route) => {
state.reorderRequests += 1;
const payload = route.request().postDataJSON() as { comboIds?: string[] };
const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : [];
const comboById = new Map(state.combos.map((combo) => [combo.id, combo]));
state.combos = nextIds
.map((id, index) => {
const combo = comboById.get(id);
return combo ? { ...combo, sortOrder: index + 1 } : null;
})
.filter((combo): combo is ComboStub => combo !== null);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.route("**/api/combos", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.goto("/dashboard/combos");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const comboCards = page.locator('[data-testid^="combo-card-"]');
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]);
await page
.getByTestId("combo-drag-handle-combo-3")
.dragTo(page.getByTestId("combo-card-combo-1"));
await expect.poll(() => state.reorderRequests).toBe(1);
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
await page.reload();
await page.waitForLoadState("networkidle");
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
});
});
+2
View File
@@ -364,6 +364,8 @@ test("launchAutoUpdate returns validation failures and starts detached update sc
},
};
},
existsImpl: async (targetPath) =>
targetPath === repoDir || targetPath === composeFile || targetPath === "/var/run/docker.sock",
});
try {
+36
View File
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
import {
isModelAvailable,
setModelUnavailable,
markModelAsProblematic,
clearModelUnavailability,
getAvailabilityReport,
getUnavailableCount,
@@ -58,6 +59,41 @@ describe("modelAvailability", () => {
while (Date.now() - start < 5) {} // spin wait
assert.equal(isModelAvailable("anthropic", "claude-sonnet-4-20250514"), true);
});
it("should apply adaptive quarantine for repeated problematic failures", () => {
const first = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
status: 502,
baseCooldownMs: 3000,
reason: "HTTP 502",
});
const second = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
status: 502,
baseCooldownMs: 3000,
reason: "HTTP 502",
});
assert.equal(first.failureCount, 1);
assert.equal(first.cooldownMs, 120000);
assert.equal(second.failureCount, 2);
assert.equal(second.cooldownMs, 240000);
const report = getAvailabilityReport();
const entry = report.find((r) => r.provider === "nvidia" && r.model === "z-ai/glm4.7");
assert.ok(entry, "nvidia model should be quarantined");
assert.ok(entry.remainingMs >= 200000, "cooldown should be preserved/escalated");
});
it("should reset failure history after model recovery", () => {
clearModelUnavailability("nvidia", "z-ai/glm4.7");
const afterReset = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
status: 502,
baseCooldownMs: 3000,
reason: "HTTP 502",
});
assert.equal(afterReset.failureCount, 1);
assert.equal(afterReset.cooldownMs, 120000);
});
});
// ──────────────── T-19: Cost Rules ────────────────
+90
View File
@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
const { movePath } = await import("../../scripts/build-next-isolated.mjs");
async function withTempDir(fn) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-build-next-isolated-"));
try {
await fn(tempDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test("movePath falls back to copy/remove when rename raises EXDEV", async () => {
await withTempDir(async (tempDir) => {
const sourceDir = path.join(tempDir, "app");
const destinationDir = path.join(tempDir, ".app-build-backup");
const nestedFile = path.join(sourceDir, "nested", "file.txt");
await fs.mkdir(path.dirname(nestedFile), { recursive: true });
await fs.writeFile(nestedFile, "legacy payload");
let copyCalled = false;
let removeCalled = false;
const warnings = [];
const originalWarn = console.warn;
console.warn = (message) => warnings.push(String(message));
try {
await movePath(sourceDir, destinationDir, {
rename: async () => {
const error = new Error("cross-device link not permitted");
error.code = "EXDEV";
throw error;
},
cp: async (...args) => {
copyCalled = true;
return fs.cp(...args);
},
rm: async (...args) => {
removeCalled = true;
return fs.rm(...args);
},
});
} finally {
console.warn = originalWarn;
}
assert.equal(copyCalled, true);
assert.equal(removeCalled, true);
assert.equal(fsSync.existsSync(sourceDir), false);
assert.equal(
await fs.readFile(path.join(destinationDir, "nested", "file.txt"), "utf8"),
"legacy payload"
);
assert.match(warnings[0] ?? "", /EXDEV while moving/);
});
});
test("movePath rethrows non-EXDEV rename failures", async () => {
await withTempDir(async (tempDir) => {
const sourceDir = path.join(tempDir, "app");
const destinationDir = path.join(tempDir, ".app-build-backup");
await fs.mkdir(sourceDir, { recursive: true });
await assert.rejects(
movePath(sourceDir, destinationDir, {
rename: async () => {
const error = new Error("permission denied");
error.code = "EACCES";
throw error;
},
cp: async () => {
throw new Error("copy fallback should not run");
},
rm: async () => {
throw new Error("remove fallback should not run");
},
}),
(error) => error?.code === "EACCES"
);
});
});
+16 -4
View File
@@ -5,12 +5,24 @@ const { buildComboTestRequestBody, extractComboTestResponseText } =
await import("../../src/lib/combos/testHealth.ts");
test("combo test helper builds a realistic smoke payload", () => {
const body = buildComboTestRequestBody("openrouter/openai/gpt-5.4");
const originalRandom = Math.random;
let callCount = 0;
let body;
try {
Math.random = () => {
callCount += 1;
return callCount === 1 ? 0.4680222223 : 0.2677;
};
body = buildComboTestRequestBody("openrouter/openai/gpt-5.4");
} finally {
Math.random = originalRandom;
}
assert.equal(body.model, "openrouter/openai/gpt-5.4");
assert.equal(body.messages[0].content, "Reply with OK only.");
assert.equal(body.max_tokens, 64);
assert.equal(body.temperature, 0);
assert.equal(body.messages[0].content, "Calculate 52122+34093, and reply with the result only.");
assert.equal(body.max_tokens, 2048);
assert.equal("temperature" in body, false);
assert.equal(body.stream, false);
});
+18 -4
View File
@@ -88,6 +88,8 @@ test("combo test route marks a model healthy only when it returns assistant text
await createTestCombo();
const fetchCalls = [];
const originalRandom = Math.random;
let callCount = 0;
globalThis.fetch = async (url, init = {}) => {
fetchCalls.push({ url: String(url), init });
return new Response(
@@ -108,7 +110,16 @@ test("combo test route marks a model healthy only when it returns assistant text
);
};
const response = await route.POST(makeRequest());
let response;
try {
Math.random = () => {
callCount += 1;
return callCount === 1 ? 0.4680222223 : 0.2677;
};
response = await route.POST(makeRequest());
} finally {
Math.random = originalRandom;
}
const body = await response.json();
const forwardedBody = JSON.parse(fetchCalls[0].init.body);
@@ -119,9 +130,12 @@ test("combo test route marks a model healthy only when it returns assistant text
assert.equal(fetchCalls[0].init.headers["X-OmniRoute-No-Cache"], "true");
assert.match(fetchCalls[0].init.headers["X-Request-Id"], /^combo-test-/);
assert.equal(forwardedBody.model, "openrouter/openai/gpt-5.4");
assert.equal(forwardedBody.messages[0].content, "Reply with OK only.");
assert.equal(forwardedBody.max_tokens, 64);
assert.equal(forwardedBody.temperature, 0);
assert.equal(
forwardedBody.messages[0].content,
"Calculate 52122+34093, and reply with the result only."
);
assert.equal(forwardedBody.max_tokens, 2048);
assert.equal("temperature" in forwardedBody, false);
assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4");
assert.equal(body.results[0].status, "ok");
assert.equal(body.results[0].responseText, "OK");
+8
View File
@@ -178,8 +178,12 @@ test("cleanupExpiredLogs removes stale rows across all log tables and records an
deletedRequestDetailLogs: 1,
deletedAuditLogs: 1,
deletedMcpAuditLogs: 1,
trimmedCallLogs: 0,
trimmedProxyLogs: 0,
appRetentionDays: 10,
callRetentionDays: 5,
callLogsMaxRows: result.callLogsMaxRows,
proxyLogsMaxRows: result.proxyLogsMaxRows,
});
assert.equal(usageCount, 1);
assert.equal(callCount, 1);
@@ -216,7 +220,11 @@ test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures wit
deletedRequestDetailLogs: 0,
deletedAuditLogs: 0,
deletedMcpAuditLogs: 0,
trimmedCallLogs: 0,
trimmedProxyLogs: 0,
appRetentionDays: 10,
callRetentionDays: 5,
callLogsMaxRows: result.callLogsMaxRows,
proxyLogsMaxRows: result.proxyLogsMaxRows,
});
});
+36 -2
View File
@@ -47,11 +47,12 @@ test("createCombo stores default strategy and supports lookup by id and name", a
});
assert.equal(combo.strategy, "priority");
assert.equal(combo.sortOrder, 1);
assert.deepEqual(await combosDb.getComboById(combo.id), combo);
assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo);
});
test("getCombos returns parsed combos sorted by name", async () => {
test("getCombos returns parsed combos in persisted sort order", async () => {
await combosDb.createCombo({
name: "Zulu",
models: [{ provider: "openai", model: "gpt-4.1" }],
@@ -65,7 +66,11 @@ test("getCombos returns parsed combos sorted by name", async () => {
assert.deepEqual(
combos.map((combo) => combo.name),
["Alpha", "Zulu"]
["Zulu", "Alpha"]
);
assert.deepEqual(
combos.map((combo) => combo.sortOrder),
[1, 2]
);
});
@@ -91,6 +96,35 @@ test("updateCombo merges fields while preserving immutable data", async () => {
assert.deepEqual(await combosDb.getComboById(combo.id), updated);
});
test("reorderCombos persists manual combo ordering in sqlite", async () => {
const alpha = await combosDb.createCombo({
name: "Alpha",
models: [{ provider: "openai", model: "gpt-4.1" }],
});
const bravo = await combosDb.createCombo({
name: "Bravo",
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
});
const charlie = await combosDb.createCombo({
name: "Charlie",
models: [{ provider: "google", model: "gemini-2.5-pro" }],
});
const reordered = await combosDb.reorderCombos([charlie.id, alpha.id, bravo.id]);
assert.deepEqual(
reordered.map((combo) => combo.name),
["Charlie", "Alpha", "Bravo"]
);
assert.deepEqual(
reordered.map((combo) => combo.sortOrder),
[1, 2, 3]
);
assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1);
assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2);
assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3);
});
test("deleteCombo reports missing ids and removes existing rows", async () => {
const combo = await combosDb.createCombo({
name: "Delete Me",
+55
View File
@@ -8,6 +8,8 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-log-reten
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.APP_LOG_RETENTION_DAYS = "2";
process.env.CALL_LOG_RETENTION_DAYS = "1";
process.env.CALL_LOGS_TABLE_MAX_ROWS = "5";
process.env.PROXY_LOGS_TABLE_MAX_ROWS = "5";
const core = await import("../../src/lib/db/core.ts");
const compliance = await import("../../src/lib/compliance/index.ts");
@@ -132,3 +134,56 @@ test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => {
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM audit_log").get().cnt, 2);
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM mcp_tool_audit").get().cnt, 1);
});
test("cleanupExpiredLogs enforces row count limits", () => {
compliance.initAuditLog();
const db = core.getDbInstance();
const now = new Date().toISOString();
for (let i = 0; i < 10; i++) {
db.prepare(
"INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, account, duration, tokens_in, tokens_out, has_pipeline_details) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
`call-${i}`,
now,
"POST",
"/v1/chat/completions",
200,
"model",
"provider",
"-",
1,
1,
1,
0
);
}
for (let i = 0; i < 10; i++) {
db.prepare(
"INSERT INTO proxy_logs (id, timestamp, status, level, latency_ms) VALUES (?, ?, ?, ?, ?)"
).run(`proxy-${i}`, now, "success", "direct", 1);
}
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 10);
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 10);
const result = compliance.cleanupExpiredLogs();
assert.equal(result.trimmedCallLogs, 5);
assert.equal(result.trimmedProxyLogs, 5);
assert.equal(result.callLogsMaxRows, 5);
assert.equal(result.proxyLogsMaxRows, 5);
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 5);
assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 5);
});
test("getCallLogsTableMaxRows returns configured value", async () => {
const { getCallLogsTableMaxRows, getProxyLogsTableMaxRows } =
await import("../../src/lib/logEnv.ts");
assert.equal(getCallLogsTableMaxRows(), 5);
assert.equal(getProxyLogsTableMaxRows(), 5);
});
+18 -1
View File
@@ -1,6 +1,10 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { maskEmail } from "../../src/shared/utils/maskEmail.ts";
import {
maskEmail,
maskEmailLikeValue,
pickMaskedDisplayValue,
} from "../../src/shared/utils/maskEmail.ts";
describe("maskEmail", () => {
it("masks standard email correctly", () => {
@@ -48,4 +52,17 @@ describe("maskEmail", () => {
const result = maskEmail("hello@example.com", 3);
assert.ok(result.startsWith("hel"), `Expected to start with 'hel', got: ${result}`);
});
it("masks email-like values stored in generic labels", () => {
assert.equal(maskEmailLikeValue("person@example.com"), "pe****@e******.com");
assert.equal(maskEmailLikeValue("Work Account"), "Work Account");
});
it("picks the first non-empty masked display value", () => {
assert.equal(
pickMaskedDisplayValue(["", "person@example.com", "fallback"], "fallback"),
"pe****@e******.com"
);
assert.equal(pickMaskedDisplayValue([null, "Workspace"], "fallback"), "Workspace");
});
});
+1 -1
View File
@@ -435,7 +435,7 @@ test("model sync route records added, removed, and updated model diffs with fall
assert.equal(logs.length, 1);
assert.equal(logs[0].status, 200);
assert.equal(logs[0].provider, "openrouter");
assert.equal(logs[0].account, "sync@example.com");
assert.ok(logs[0].account.includes("**"), `Expected masked email, got: ${logs[0].account}`);
});
test("model sync route accepts external API-key auth, forwards cookies, filters built-ins, and syncs aliases", async () => {
@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-provider-model-management-route-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const providerModelsRoute = await import("../../src/app/api/provider-models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function buildPatchRequest(url, body) {
return new Request(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("provider-models PATCH updates hidden flag for custom models", async () => {
await modelsDb.addCustomModel("openai", "gpt-test", "GPT Test", "manual", "chat-completions", [
"chat",
]);
const response = await providerModelsRoute.PATCH(
buildPatchRequest("http://localhost/api/provider-models?provider=openai&modelId=gpt-test", {
isHidden: true,
})
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.ok, true);
const models = await modelsDb.getCustomModels("openai");
assert.equal(models.find((model) => model.id === "gpt-test")?.isHidden, true);
});
test("provider-models PATCH persists visibility overrides for catalog models", async () => {
const response = await providerModelsRoute.PATCH(
buildPatchRequest(
"http://localhost/api/provider-models?provider=claude&modelId=claude-sonnet-4-6",
{ isHidden: true }
)
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.ok, true);
const overrides = modelsDb.getModelCompatOverrides("claude");
assert.equal(overrides.find((model) => model.id === "claude-sonnet-4-6")?.isHidden, true);
});
test("provider-models PATCH supports bulk visibility updates", async () => {
await providerModelsRoute.PATCH(
buildPatchRequest("http://localhost/api/provider-models?provider=claude", {
isHidden: true,
modelIds: ["claude-opus-4-6", "claude-sonnet-4-6"],
})
);
const response = await providerModelsRoute.PATCH(
buildPatchRequest("http://localhost/api/provider-models?provider=claude", {
isHidden: false,
modelIds: ["claude-opus-4-6", "claude-sonnet-4-6"],
})
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.updated, 2);
const overrides = modelsDb.getModelCompatOverrides("claude");
assert.equal(overrides.find((model) => model.id === "claude-opus-4-6")?.isHidden, false);
assert.equal(overrides.find((model) => model.id === "claude-sonnet-4-6")?.isHidden, false);
});
test("provider-models PATCH validates required fields", async () => {
const response = await providerModelsRoute.PATCH(
buildPatchRequest("http://localhost/api/provider-models?provider=claude", {
modelIds: ["claude-sonnet-4-6"],
})
);
const body = await response.json();
assert.equal(response.status, 400);
assert.equal(body.error.message, "isHidden boolean is required");
});
@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
const { default: RequestLoggerDetail } =
await import("../../src/shared/components/RequestLoggerDetail.tsx");
test("request log detail splits token badges into input and output groups", () => {
const html = renderToStaticMarkup(
React.createElement(RequestLoggerDetail, {
log: {
status: 200,
method: "POST",
path: "/v1/chat/completions",
timestamp: "2026-04-09T21:27:08.000Z",
duration: 2500,
provider: "openai-compatible-sp-openai",
sourceFormat: "openai-chat",
model: "gpt-5.4",
requestedModel: "openai-compatible-sp-openai/gpt-5.4",
account: "main",
apiKeyName: "tools",
apiKeyId: "29d9***7e37",
comboName: "_Latest-Discounted",
tokens: {
in: 21818,
out: 42,
cacheRead: 21632,
cacheWrite: null,
reasoning: null,
},
},
detail: {
account: "main",
apiKeyName: "tools",
apiKeyId: "29d9***7e37",
comboName: "_Latest-Discounted",
requestedModel: "openai-compatible-sp-openai/gpt-5.4",
tokens: {
in: 21818,
out: 42,
cacheRead: 21632,
cacheWrite: null,
reasoning: null,
},
},
loading: false,
onClose: () => {},
onCopy: async () => true,
})
);
const inputLabelIndex = html.indexOf(">Input<");
const outputLabelIndex = html.indexOf(">Output<");
const modelLabelIndex = html.indexOf(">Model<");
const requestedModelLabelIndex = html.indexOf(">Requested Model<");
assert.notEqual(html.indexOf(">Completed Time<"), -1);
assert.equal(html.includes(">Time<"), false);
assert.notEqual(inputLabelIndex, -1);
assert.notEqual(outputLabelIndex, -1);
assert.notEqual(modelLabelIndex, -1);
assert.notEqual(requestedModelLabelIndex, -1);
assert.equal(html.includes(">Tokens<"), false);
assert.equal(inputLabelIndex < outputLabelIndex, true);
assert.equal(outputLabelIndex < modelLabelIndex, true);
assert.equal(modelLabelIndex < requestedModelLabelIndex, true);
assert.match(
html,
/data-testid="token-group-input"[\s\S]*Total In: 21[\s\S]*818[\s\S]*Cache Read: 21[\s\S]*632[\s\S]*Cache Write: N\/A/
);
assert.match(html, /data-testid="token-group-output"[\s\S]*Total Out: 42[\s\S]*Reasoning: N\/A/);
});
+40
View File
@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
test("system sidebar items place logs before health", () => {
const systemSection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "system"
);
assert.ok(systemSection, "expected system sidebar section to exist");
assert.deepEqual(
systemSection.items.map((item) => item.id),
["logs", "health", "audit", "settings"]
);
});
test("primary sidebar items place limits after cache", () => {
const primarySection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "primary"
);
assert.ok(primarySection, "expected primary sidebar section to exist");
assert.deepEqual(
primarySection.items.map((item) => item.id),
[
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"auto-combo",
"costs",
"analytics",
"cache",
"limits",
"media",
]
);
});
+53
View File
@@ -237,6 +237,59 @@ test("createSSEStream passthrough preserves Responses API events and completion
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
});
test("createSSEStream translate mode aborts on Responses failure with rate limit error", async () => {
let onCompletePayload = null;
await assert.rejects(
readTransformed(
[
`data: ${JSON.stringify({
type: "response.created",
response: {
id: "resp_fail",
object: "response",
model: "gpt-5.4",
status: "in_progress",
output: [],
},
})}\n\n`,
`data: ${JSON.stringify({
type: "response.failed",
response: {
id: "resp_fail",
object: "response",
model: "gpt-5.4",
status: "failed",
error: {
message: "Rate limit reached for gpt-5.4",
code: "rate_limit_exceeded",
},
},
})}\n\n`,
`data: [DONE]\n\n`,
],
{
mode: "translate",
targetFormat: FORMATS.OPENAI_RESPONSES,
sourceFormat: FORMATS.OPENAI,
provider: "codex",
model: "gpt-5.4",
body: { messages: [{ role: "user", content: "hello" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
),
/Rate limit reached for gpt-5\.4|Upstream failure/
);
assert.ok(onCompletePayload, "should capture completion payload before aborting");
assert.equal(onCompletePayload.status, 429);
assert.equal(onCompletePayload.responseBody.error.type, "rate_limit_error");
assert.equal(onCompletePayload.responseBody.error.code, "rate_limit_exceeded");
assert.match(onCompletePayload.responseBody.error.message, /Rate limit reached/);
});
test("createSSEStream passthrough restores Claude tool names from the mapping table", async () => {
const toolNameMap = new Map([["tool_alias", "read_file"]]);
const text = await readTransformed(
+38
View File
@@ -27,6 +27,25 @@ function writeEnvExample(rootDir) {
);
}
function writeOauthEnvExample(rootDir) {
fs.writeFileSync(
path.join(rootDir, ".env.example"),
[
"# ═══════════════════════════════════════════════════",
"# OAUTH PROVIDER CREDENTIALS",
"# ═══════════════════════════════════════════════════",
"CLAUDE_OAUTH_CLIENT_ID=claude-default",
"CODEX_OAUTH_CLIENT_ID=codex-default",
"# ─────────────────────────────────────────────────────────────────────────────",
"# Provider User-Agent Overrides (optional — customize per-provider UA headers)",
"# ─────────────────────────────────────────────────────────────────────────────",
"JWT_SECRET=should-not-be-copied",
"",
].join("\n"),
"utf8"
);
}
test("syncEnv creates .env from .env.example and generates blank secrets", () => {
const rootDir = createTempRoot();
@@ -97,3 +116,22 @@ test("syncEnv is idempotent when .env is already complete", () => {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("syncEnv oauth scope only copies oauth defaults", () => {
const rootDir = createTempRoot();
try {
writeOauthEnvExample(rootDir);
const result = syncEnv({ rootDir, quiet: true, scope: "oauth" });
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
assert.deepEqual(result, { created: true, added: 2 });
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m);
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);
assert.doesNotMatch(envContent, /^JWT_SECRET=/m);
assert.doesNotMatch(envContent, /^Provider User-Agent Overrides/m);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});

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